diff --git a/.github/actions/setup-tauri-env/action.yml b/.github/actions/setup-tauri-env/action.yml new file mode 100644 index 00000000..01bbe75f --- /dev/null +++ b/.github/actions/setup-tauri-env/action.yml @@ -0,0 +1,75 @@ +name: 'Setup Tauri build environment' +description: > + Node, Rust, the Rust build cache, and (on Linux) Tauri's system deps — + the same five steps release.yml, build-cache.yml, and coverage.yml each + used to repeat with identical bodies, kept in one place so pinned action + SHAs and the apt package list only need updating once. + +inputs: + rust-target: + description: 'Extra Rust target to install (matrix.rust-target), or empty for the host target' + default: '' + rust-components: + description: 'Comma-separated rustup components, e.g. llvm-tools-preview' + default: '' + cache-shared-key: + description: 'Swatinem/rust-cache shared-key — must match between the job that saves and the job(s) that restore' + required: true + cache-save-if: + description: > + Swatinem/rust-cache save-if. Restore-only jobs (release.yml's tag + builds) pass 'false' — the cache is refreshed on main by + build-cache.yml instead, so a tag run saving its own copy would + never be reused by any other tag. + default: 'true' + install-frontend-deps: + description: "Run 'npm ci'. Set to 'false' if the caller needs Rust/cache only." + default: 'true' + +runs: + using: composite + steps: + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: lts/* + cache: npm + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch + with: + toolchain: stable + targets: ${{ inputs.rust-target }} + components: ${{ inputs.rust-components }} + + - name: Cache Rust build artifacts + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: src-tauri -> target + cache-on-failure: true + shared-key: ${{ inputs.cache-shared-key }} + save-if: ${{ inputs.cache-save-if }} + + - name: Install Linux system dependencies + if: runner.os == 'Linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libjavascriptcoregtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf + + # --ignore-scripts: SonarCloud flagged the bare `npm ci` (S4830-class — + # "package manager scripts should not be executed during installation"). + # Safe here: package-lock.json's only `hasInstallScript` entry is + # fsevents, an optional macOS-only file watcher used by `npm run dev` + # — nothing this repo's CI (build/lint/test) ever touches. Verified + # `npm ci --ignore-scripts` still installs cleanly and `tsc --noEmit` + # still passes. + - name: Install frontend dependencies + if: inputs.install-frontend-deps == 'true' + shell: bash + run: npm ci --prefer-offline --no-audit --no-fund --ignore-scripts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1257e287..80c56742 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -26,30 +26,17 @@ updates: open-pull-requests-limit: 5 # The docker-compose test fixtures under testing/ are not shipped in the app, - # but they are still code in this repo and they still go stale. Without these - # entries only npm and cargo were kept current, and the Spring fixture sat a - # year behind its parent BOM until a security alert surfaced it. Dependabot - # security alerts fire for every detected manifest regardless of this file — - # version updates do not, which is the gap these close. - - package-ecosystem: maven - directory: /testing/rabbitmq/spring-rpc - schedule: - interval: weekly - open-pull-requests-limit: 5 - groups: - minor-and-patch: - update-types: [minor, patch] - - # Both requirements.txt files are pinned with --require-hashes; Dependabot - # regenerates the hashes along with the version. + # but they are still code in this repo and they still go stale. Without this + # entry only npm and cargo were kept current. Dependabot security alerts fire + # for every detected manifest regardless of this file — version updates do + # not, which is the gap this closes. + # + # testing/rabbitmq (and its spring-rpc fixture) moved to + # developer-desktop-util-plugin along with the RabbitMQ plugin — see + # docs/decisions/architecture/platform-plugin-architecture.md. Configure + # dependabot there instead. - package-ecosystem: pip directory: /testing/kafka schedule: interval: weekly open-pull-requests-limit: 5 - - - package-ecosystem: pip - directory: /testing/rabbitmq - schedule: - interval: weekly - open-pull-requests-limit: 5 diff --git a/.github/workflows/build-cache.yml b/.github/workflows/build-cache.yml index b98c6880..75aba532 100644 --- a/.github/workflows/build-cache.yml +++ b/.github/workflows/build-cache.yml @@ -45,40 +45,15 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + # Setup shared with release.yml/coverage.yml — see + # .github/actions/setup-tauri-env. Same shared-key as release.yml so + # the cache this saves is the exact cache the tag release restores + # (default cache-save-if: 'true' — this is the job that refreshes it). + - name: Setup build environment + uses: ./.github/actions/setup-tauri-env with: - node-version: lts/* - cache: npm - - - name: Install Rust stable - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch - with: - toolchain: stable - targets: ${{ matrix.rust-target }} - - # Same shared-key as release.yml so the cache this saves is the exact cache - # the tag release restores. Saves on this run (main scope). - - name: Cache Rust build artifacts - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - workspaces: src-tauri -> target - cache-on-failure: true - shared-key: ${{ matrix.platform }}-${{ matrix.rust-target }} - - - name: Install Linux system dependencies - if: matrix.platform == 'ubuntu-22.04' - run: | - sudo apt-get update - sudo apt-get install -y \ - libwebkit2gtk-4.1-dev \ - libjavascriptcoregtk-4.1-dev \ - libappindicator3-dev \ - librsvg2-dev \ - patchelf - - - name: Install frontend dependencies - run: npm ci + rust-target: ${{ matrix.rust-target }} + cache-shared-key: ${{ matrix.platform }}-${{ matrix.rust-target }} - name: Build frontend run: npm run build diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 31601246..67c0c0e6 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -39,52 +39,44 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # The Rust bin crate embeds the frontend via `generate_context!`, which - # reads `frontendDist` (../dist) at compile time. Build the frontend first - # so the crate (and therefore its tests) compiles. - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + # Setup shared with release.yml/build-cache.yml — see + # .github/actions/setup-tauri-env (Node, Rust + llvm-tools-preview, + # Linux system deps, npm ci). GitHub scopes Actions caches per ref: a + # PR can RESTORE the default branch's cache, but a cache it SAVES is + # visible only to that PR branch — saving on every PR would mint + # large, un-shareable, instrumented-build caches that just churn the + # 10 GB limit and evict main's useful one. So only main saves. + - name: Setup build environment + uses: ./.github/actions/setup-tauri-env with: - node-version: lts/* - cache: npm - - - name: Install frontend dependencies - run: npm ci + rust-components: llvm-tools-preview + cache-shared-key: coverage + cache-save-if: ${{ github.ref == 'refs/heads/main' }} + # The Rust bin crate embeds the frontend via `generate_context!`, which + # reads `frontendDist` (../dist) at compile time. Needed before the + # first `cargo`/`cargo llvm-cov` invocation below. - name: Build frontend (creates ../dist for generate_context!) run: npm run build - # Tauri's system dependencies — same set the release build uses. - - name: Install Linux system dependencies + # tauri-build's build.rs checks that every `bundle.externalBin` resource + # (tauri.conf.json) exists on disk, even for a plain `cargo test`/`cargo + # llvm-cov` that never bundles anything — normally `beforeBuildCommand` + # (npm run build:mcp-sidecar / build:service-sidecars) produces the real + # binaries, but this job only builds the frontend, so `cargo` fails at + # the very first build-script run with "resource path ... doesn't exist". + # Coverage doesn't run these sidecars, only compiles their crate — empty + # placeholders named exactly like the real target-triple-suffixed + # binaries satisfy the resource check without the cost of a real build. + - name: Placeholder externalBin resources (coverage doesn't run sidecars, just needs them to exist) run: | - sudo apt-get update - sudo apt-get install -y \ - libwebkit2gtk-4.1-dev \ - libjavascriptcoregtk-4.1-dev \ - libappindicator3-dev \ - librsvg2-dev \ - patchelf - - - name: Install Rust stable - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch - with: - toolchain: stable - components: llvm-tools-preview - - # GitHub scopes Actions caches per ref: a PR can RESTORE the default - # branch's cache, but a cache it SAVES is visible only to that PR branch - # (never to other PRs or main). Saving on every PR therefore mints large, - # un-shareable, instrumented-build caches that just churn the 10 GB limit - # and evict main's useful one. So only main saves; PRs restore-only. - - name: Cache Rust build artifacts - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - workspaces: src-tauri -> target - shared-key: coverage - save-if: ${{ github.ref == 'refs/heads/main' }} + mkdir -p src-tauri/binaries + triple=$(rustc -vV | sed -n 's/^host: //p') + touch "src-tauri/binaries/devtool-mcp-server-$triple" "src-tauri/binaries/devtool-svc-echo-$triple" + chmod +x src-tauri/binaries/devtool-mcp-server-"$triple" src-tauri/binaries/devtool-svc-echo-"$triple" - name: Install cargo-llvm-cov - uses: taiki-e/install-action@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 # v2 + uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2 with: tool: cargo-llvm-cov diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml index 8b3491bd..9fd538c7 100644 --- a/.github/workflows/pullfrog.yml +++ b/.github/workflows/pullfrog.yml @@ -22,11 +22,11 @@ jobs: contents: read steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 - name: Run agent - uses: pullfrog/pullfrog@v0 + uses: pullfrog/pullfrog@4fe55647c66683c209243f2171b013c760d125a7 # v0 (currently v0.1.80) with: prompt: ${{ inputs.prompt }} env: diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml deleted file mode 100644 index 62d7005c..00000000 --- a/.github/workflows/release-canary.yml +++ /dev/null @@ -1,235 +0,0 @@ -name: Release (canary) - -# Kênh phát hành THỨ HAI, song song với release.yml — dùng để thử nghiệm kiến -# trúc Platform/Plugin mới (Tier B, cài sidecar từ URL, v.v.) với một nhóm -# người dùng trước khi thay hẳn bản ổn định. -# -# CÁCH LY VỚI KÊNH ỔN ĐỊNH, có chủ ý ở BA lớp: -# 1. Tag pattern riêng (`canary-v*`) — không đụng gì tới trigger `v*` của -# release.yml. -# 2. `identifier`/`productName` riêng (src-tauri/tauri.canary.conf.json, hợp -# nhất đè lên tauri.conf.json qua `--config`) — app cài song song trên -# cùng máy, app_data (secrets/store/plugin-data/service-data) tách biệt -# hoàn toàn, không đè lên bản ổn định. -# 3. Endpoint updater riêng, trỏ vào một tag GitHub Release CỐ ĐỊNH -# (`canary-latest`), KHÔNG dùng alias `releases/latest/` — alias đó luôn -# trỏ bản release mới nhất trong CẢ REPO bất kể kênh nào, nên nếu canary -# dùng chung alias thì người dùng bản ổn định sẽ bị đề nghị cập nhật lên -# bản đang thử nghiệm. -# -# Tag `canary-latest` bị XOÁ VÀ TẠO LẠI mỗi lần chạy (rolling release) — ý -# định là "bản canary mới nhất luôn có" chứ không giữ lịch sử nhiều bản canary -# cũ trên GitHub Releases. Lịch sử thật nằm ở git tag `canary-vX.Y.Z...` đã -# push, không mất đi khi release bị xoá tạo lại. - -on: - push: - tags: - - 'canary-v*' - -permissions: - contents: read - -jobs: - # Job RIÊNG, chạy MỘT LẦN trước matrix — ba job platform bên dưới publish - # đồng thời (song song, giống release.yml), nên nếu mỗi job tự xoá+tạo lại - # `canary-latest` trong THÂN matrix, ba job sẽ giẫm lên nhau (job A xoá đúng - # lúc job B đang upload asset vào release mà job A vừa xoá). Tách reset ra - # một job riêng, `needs:` matrix job đợi nó xong, mới không có race. - reset-canary-release: - runs-on: ubuntu-22.04 - permissions: - contents: write - steps: - - name: Reset rolling canary-latest release - shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release delete canary-latest --yes --cleanup-tag --repo "${GITHUB_REPOSITORY}" || true - - publish: - needs: reset-canary-release - strategy: - fail-fast: false - matrix: - include: - - platform: ubuntu-22.04 - args: '' - rust-target: '' - - platform: macos-latest - args: '--target aarch64-apple-darwin' - rust-target: aarch64-apple-darwin - - platform: windows-latest - args: '' - rust-target: '' - - runs-on: ${{ matrix.platform }} - timeout-minutes: 90 - - permissions: - contents: write - id-token: write - attestations: write - - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - # Tag thật là `canary-v1.2.0` (hoặc bất kỳ hậu tố nào sau `canary-v`) — - # bóc đúng phần version để ghi vào tauri.conf.json/package.json/Cargo.toml, - # KHÁC cách bóc của release.yml (`#v`) vì tiền tố ở đây dài hơn. - - name: Sync version from tag - shell: bash - run: | - VERSION="${GITHUB_REF_NAME#canary-v}" - echo "Setting version to $VERSION" - node -e "const f='src-tauri/tauri.conf.json',j=require('./'+f);j.version='$VERSION';require('fs').writeFileSync(f,JSON.stringify(j,null,2)+'\n')" - node -e "const f='package.json',j=require('./'+f);j.version='$VERSION';require('fs').writeFileSync(f,JSON.stringify(j,null,2)+'\n')" - node -e "const fs=require('fs'),f='src-tauri/Cargo.toml';let c=fs.readFileSync(f,'utf8');c=c.replace(/^version = \".*\"/m,'version = \"$VERSION\"');fs.writeFileSync(f,c)" - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: lts/* - cache: npm - - - name: Install Rust stable - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch - with: - toolchain: stable - targets: ${{ matrix.rust-target }} - - - name: Cache Rust build artifacts - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - workspaces: src-tauri -> target - cache-on-failure: true - # Clé riêng cho canary: identifier khác nên Cargo.toml version string - # đổi theo một lịch khác hẳn kênh ổn định — chia sẻ cache với - # release.yml chỉ tổ invalidation lẫn nhau không cần thiết. - shared-key: canary-${{ matrix.platform }}-${{ matrix.rust-target }} - save-if: false - - - name: Install Linux system dependencies - if: matrix.platform == 'ubuntu-22.04' - run: | - sudo apt-get update - sudo apt-get install -y \ - libwebkit2gtk-4.1-dev \ - libjavascriptcoregtk-4.1-dev \ - libappindicator3-dev \ - librsvg2-dev \ - patchelf - - - name: Install frontend dependencies - run: npm ci - - - name: Generate release notes - id: notes - shell: bash - run: | - NEW_TAG="${GITHUB_REF_NAME}" - PREV_TAG=$(git describe --tags --abbrev=0 --match 'canary-v*' "${NEW_TAG}^" 2>/dev/null || true) - - if [ -n "$PREV_TAG" ]; then - RANGE="${PREV_TAG}..${NEW_TAG}" - else - RANGE="${NEW_TAG}" - fi - - commits=$(git log --no-merges --pretty=format:'- (%h) %s ' "$RANGE") - [ -z "$commits" ] && commits="- No changes recorded." - - { - echo "RELEASE_NOTES<<__NOTES_EOF__" - echo "## Changes (canary):" - echo "$commits" - echo "__NOTES_EOF__" - } >> "$GITHUB_ENV" - - - name: Cache Tauri bundler tools (Windows) - if: matrix.platform == 'windows-latest' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/AppData/Local/tauri - key: ${{ runner.os }}-tauri-bundler-${{ hashFiles('src-tauri/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-tauri-bundler- - - - name: Build and publish canary release - id: tauri - uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - with: - # Tag GitHub Release CỐ ĐỊNH — khác `github.ref_name` (tag git thật, - # mang version) có chủ ý: đây chính là điều làm endpoint updater của - # canary ổn định qua nhiều lần phát hành mà không cần đổi - # tauri.canary.conf.json mỗi lần. - tagName: canary-latest - releaseName: DevTool Canary ${{ github.ref_name }} - releaseBody: ${{ env.RELEASE_NOTES }} - releaseDraft: false - prerelease: true - args: ${{ matrix.args }} --config src-tauri/tauri.canary.conf.json - - - name: Collect built artifact paths - id: artifacts - shell: bash - env: - ARTIFACT_PATHS: ${{ steps.tauri.outputs.artifactPaths }} - run: | - node -e ' - const paths = JSON.parse(process.env.ARTIFACT_PATHS || "[]") - .filter((p) => !p.endsWith(".sig")); - require("fs").appendFileSync(process.env.GITHUB_OUTPUT, - `paths<<__EOF__\n${paths.join("\n")}\n__EOF__\n`); - console.log(paths.join("\n")); - ' - - - name: Attest build provenance - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 - with: - subject-path: ${{ steps.artifacts.outputs.paths }} - - - name: Publish SHA256 checksums - shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARTIFACT_LIST: ${{ steps.artifacts.outputs.paths }} - run: | - set -euo pipefail - sums="SHA256SUMS-${{ matrix.platform }}${{ matrix.rust-target && format('-{0}', matrix.rust-target) || '' }}.txt" - : > "$sums" - while IFS= read -r artifact; do - [ -n "$artifact" ] || continue - [ -f "$artifact" ] || continue - if command -v sha256sum >/dev/null 2>&1; then - (cd "$(dirname "$artifact")" && sha256sum "$(basename "$artifact")") - else - (cd "$(dirname "$artifact")" && shasum -a 256 "$(basename "$artifact")") - fi >> "$sums" - done <<< "$ARTIFACT_LIST" - cat "$sums" - gh release upload canary-latest "$sums" --clobber --repo "${GITHUB_REPOSITORY}" - - - name: Generate SBOM - if: matrix.platform == 'ubuntu-22.04' - uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 - with: - path: . - format: spdx-json - output-file: devtool-sbom.spdx.json - upload-artifact: false - upload-release-assets: false - - - name: Publish SBOM - if: matrix.platform == 'ubuntu-22.04' - shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release upload canary-latest devtool-sbom.spdx.json --clobber --repo "${GITHUB_REPOSITORY}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3489b010..2acd7192 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,18 +1,36 @@ name: Release +# Tag push is the primary trigger. Also runnable manually (workflow_dispatch) +# for environments that can drive the GitHub Actions API but can't push a tag +# ref (e.g. a scoped CI bot token). +# +# Build-then-tag, not tag-then-build: `build` only ever produces workflow +# artifacts, never touches git tags or GitHub Releases. `publish` (needs: +# build) only runs once EVERY platform in the matrix has succeeded — if any +# one fails, `publish` is skipped by GitHub Actions' own `needs:` semantics, +# and no tag/release is ever created. This replaced an earlier design where +# each platform's job created/updated the release itself as it finished: +# v0.9.0's first run published live with macOS+Linux assets while Windows +# was still failing, because "publish" wasn't a separate, later step gated +# on the whole matrix — it was baked into each platform's own build step. on: push: tags: - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Release tag to create (e.g. v0.9.0)' + required: true + type: string -# Least privilege at the top level: the only job here declares the write scopes -# it actually needs (contents/id-token/attestations). Anything added later -# starts read-only and has to opt in explicitly. +# Least privilege at the top level: only `publish` needs write scopes, and +# only once `build`'s full matrix has already succeeded. permissions: contents: read jobs: - publish: + build: strategy: fail-fast: false matrix: @@ -42,83 +60,164 @@ jobs: rust-target: '' runs-on: ${{ matrix.platform }} - # Bundling + signing + attestation on three platforms. + # Bundling + signing on three platforms. No release/tag work here — that's + # all in `publish`, so this job needs no elevated permissions. timeout-minutes: 90 - permissions: - contents: write # publish the release and upload assets - id-token: write # OIDC token used to sign the provenance attestation - attestations: write # write the attestation to the repo's attestation store - steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 # full history + tags so the changelog can diff previous→new tag - # Derive the app version from the pushed tag (e.g. v0.2.4 -> 0.2.4) and write - # it into the config files, so you only have to push a tag — no manual version - # bump in tauri.conf.json. tauri-action reads the version from tauri.conf.json. + - name: Resolve release tag (workflow_dispatch input, or the pushed tag) + # shell: bash — the default shell on windows-latest is pwsh, where + # "$GITHUB_ENV" doesn't expand to the env-file path the way it does + # in bash, so the append silently no-ops. Confirmed the hard way: + # v0.9.0's first release run left RELEASE_TAG empty on Windows only + # (macOS/Linux default to bash already). + shell: bash + run: echo "RELEASE_TAG=${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }}" >> "$GITHUB_ENV" + + # Derive the app version from the tag (e.g. v0.2.4 -> 0.2.4) and write + # it into the config files, so you only have to push a tag — no manual + # version bump in tauri.conf.json. - name: Sync version from tag shell: bash run: | - VERSION="${GITHUB_REF_NAME#v}" + VERSION="${RELEASE_TAG#v}" echo "Setting version to $VERSION" node -e "const f='src-tauri/tauri.conf.json',j=require('./'+f);j.version='$VERSION';require('fs').writeFileSync(f,JSON.stringify(j,null,2)+'\n')" node -e "const f='package.json',j=require('./'+f);j.version='$VERSION';require('fs').writeFileSync(f,JSON.stringify(j,null,2)+'\n')" node -e "const fs=require('fs'),f='src-tauri/Cargo.toml';let c=fs.readFileSync(f,'utf8');c=c.replace(/^version = \".*\"/m,'version = \"$VERSION\"');fs.writeFileSync(f,c)" - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + # Setup shared with build-cache.yml/coverage.yml — see + # .github/actions/setup-tauri-env. save-if: false here: the cache is + # created/refreshed by build-cache.yml on main; a tag run can read + # main's cache but would otherwise save a tag-scoped copy that no + # future tag can reuse. + - name: Setup build environment + uses: ./.github/actions/setup-tauri-env with: - node-version: lts/* - cache: npm + rust-target: ${{ matrix.rust-target }} + cache-shared-key: ${{ matrix.platform }}-${{ matrix.rust-target }} + cache-save-if: 'false' - - name: Install Rust stable - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch + # Windows bundling downloads NSIS (and nsis_tauri_utils.dll) into + # %LOCALAPPDATA%\tauri on every run. Cache that dir so later releases reuse it. + - name: Cache Tauri bundler tools (Windows) + if: matrix.platform == 'windows-latest' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - toolchain: stable - targets: ${{ matrix.rust-target }} + path: ~/AppData/Local/tauri + key: ${{ runner.os }}-tauri-bundler-${{ hashFiles('src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-tauri-bundler- - - name: Cache Rust build artifacts - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - workspaces: src-tauri -> target - cache-on-failure: true - shared-key: ${{ matrix.platform }}-${{ matrix.rust-target }} - # Restore only. The cache is created/refreshed by build-cache.yml on main; - # a tag run can read main's cache but would otherwise save a tag-scoped - # copy that no future tag can reuse, so we never save here. - save-if: false - - - name: Install Linux system dependencies - if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm' + # Plain `tauri build` CLI, not tauri-action: tauri-action bundles + # build+release+updater-manifest into one step, which is exactly the + # coupling this workflow is undoing. Signing (.sig files) and the + # updater-artifact bundle (tauri.conf.json's `bundle.createUpdaterArtifacts`) + # are native `tauri build` behavior, not something tauri-action adds — + # they still happen here as long as the signing env vars are set. + - name: Build app + shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: npm run tauri:build -- ${{ matrix.args }} + + - name: Locate bundle output directory + id: bundle-dir + shell: bash run: | - sudo apt-get update - sudo apt-get install -y \ - libwebkit2gtk-4.1-dev \ - libjavascriptcoregtk-4.1-dev \ - libappindicator3-dev \ - librsvg2-dev \ - patchelf + set -euo pipefail + if [ -n "${{ matrix.rust-target }}" ]; then + DIR="src-tauri/target/${{ matrix.rust-target }}/release/bundle" + else + DIR="src-tauri/target/release/bundle" + fi + [ -d "$DIR" ] || { echo "Bundle dir not found: $DIR" >&2; exit 1; } + echo "dir=$DIR" >> "$GITHUB_OUTPUT" + + # Only the installer formats tauri.conf.json's bundle.targets produces + # (app/dmg/nsis/appimage/deb) plus each one's .sig — no source/debug + # cruft from the bundle dir. + - name: Upload build artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: bundle-${{ matrix.platform }} + path: | + ${{ steps.bundle-dir.outputs.dir }}/**/*.dmg + ${{ steps.bundle-dir.outputs.dir }}/**/*.app.tar.gz + ${{ steps.bundle-dir.outputs.dir }}/**/*.app.tar.gz.sig + ${{ steps.bundle-dir.outputs.dir }}/**/*.AppImage + ${{ steps.bundle-dir.outputs.dir }}/**/*.AppImage.sig + ${{ steps.bundle-dir.outputs.dir }}/**/*.deb + ${{ steps.bundle-dir.outputs.dir }}/**/*.exe + ${{ steps.bundle-dir.outputs.dir }}/**/*.exe.sig + if-no-files-found: error + retention-days: 1 - - name: Install frontend dependencies - run: npm ci + publish: + needs: build + runs-on: ubuntu-latest + # Everything below is metadata/publish work — no compiled toolchain needed. + timeout-minutes: 15 + + permissions: + contents: write # create the tag, publish the release, upload assets + id-token: write # OIDC token used to sign the provenance attestation + attestations: write # write the attestation to the repo's attestation store + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # full history + tags so the changelog can diff previous→new tag + + - name: Resolve release tag (workflow_dispatch input, or the pushed tag) + shell: bash + run: echo "RELEASE_TAG=${{ github.event_name == 'workflow_dispatch' && inputs.version || github.ref_name }}" >> "$GITHUB_ENV" + + # No merge-multiple: each artifact lands in its own downloaded/bundle-/ + # subdirectory, so scripts/build-updater-manifest.mjs can tell which + # platform a file came from from its DIRECTORY, not by guessing from the + # filename (which only encodes arch, e.g. "aarch64"/"amd64"/"x64" — not + # OS, and not reliably even that if naming ever changes). + - name: Download all platform bundles + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v6.0.0 + with: + pattern: 'bundle-*' + path: downloaded + + - name: List downloaded artifacts + run: find downloaded -type f | sort + + # gh release create/upload want a flat file list; the per-platform + # subdirectory structure only matters to build-updater-manifest.mjs + # (needs: [next step]). + - name: Flatten artifacts into a single upload directory + shell: bash + run: | + set -euo pipefail + mkdir -p release-assets + find downloaded -type f -exec cp {} release-assets/ \; + ls -la release-assets # Build the release notes from the full commit log between the previous tag # and this tag. This becomes both the GitHub release body AND the updater # manifest `notes` field, so the in-app update popup shows the real changes. - name: Generate release notes - id: notes shell: bash run: | - NEW_TAG="${GITHUB_REF_NAME}" - # Nearest tag reachable from this tag's parent = the previous release. - PREV_TAG=$(git describe --tags --abbrev=0 "${NEW_TAG}^" 2>/dev/null || true) + NEW_TAG="${RELEASE_TAG}" + # Nearest tag reachable from HEAD's parent = the previous release. + # HEAD^ instead of "${NEW_TAG}^": NEW_TAG doesn't exist as a git ref + # yet at this point (the tag is created a few steps below), so + # "${NEW_TAG}^" would fail to resolve — HEAD^ works either way. + PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || true) if [ -n "$PREV_TAG" ]; then - RANGE="${PREV_TAG}..${NEW_TAG}" + RANGE="${PREV_TAG}..HEAD" else - RANGE="${NEW_TAG}" # first release — list the whole history + RANGE="HEAD" # first release — list the whole history fi # Every commit in the range (excluding merge commits), as "- subject (hash)". @@ -132,101 +231,87 @@ jobs: echo "__NOTES_EOF__" } >> "$GITHUB_ENV" - # Windows bundling downloads NSIS (and nsis_tauri_utils.dll) into - # %LOCALAPPDATA%\tauri on every run. Cache that dir so later releases reuse it. - - name: Cache Tauri bundler tools (Windows) - if: matrix.platform == 'windows-latest' - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: ~/AppData/Local/tauri - key: ${{ runner.os }}-tauri-bundler-${{ hashFiles('src-tauri/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-tauri-bundler- + # This is the point of no return: every platform in `build` already + # succeeded (that's what `needs: build` guarantees), so the tag is + # created now, for the first time — `gh release create` creates it + # itself, pointed at this exact commit, same as + # developer-desktop-util-plugin's release.yml already does. + # + # --draft: everything past this point (updater manifest, checksums, + # SBOM, attestation) can still fail, and a draft is invisible to the + # in-app updater (see tauri.conf.json's updater endpoint comment + # elsewhere in this repo) — so a failure here leaves an unfinished + # DRAFT, not a live release missing files. v0.9.1-test1's manual test + # run hit exactly this: the manifest step failed right after this one + # published live, leaving a real release with installers but no + # latest.json. "Publish the release" (the last step below) is what + # flips draft -> published, once every later step has actually + # succeeded. + - name: Create GitHub Release (draft), tag, and upload installers + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + gh release create "${RELEASE_TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --title "DevTool ${RELEASE_TAG}" \ + --notes "${RELEASE_NOTES}" \ + --target "${{ github.sha }}" \ + --draft \ + release-assets/* - # Best-effort: cache Tauri's downloaded bundler tooling on Linux (AppImage - # linuxdeploy/AppRun + plugins). Depending on the Tauri version these may be - # fetched into the build dir instead, in which case this is a harmless no-op. - # - name: Cache Tauri bundler tools (Linux) - # if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm' - # uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - # with: - # path: | - # ~/.cache/tauri - # ~/.local/share/tauri - # key: ${{ runner.os }}-tauri-bundler-${{ hashFiles('src-tauri/Cargo.lock') }} - # restore-keys: | - # ${{ runner.os }}-tauri-bundler- - - - name: Build and publish release - id: tauri - uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # v1.0.0 + # ── Updater manifest ───────────────────────────────────────────────── + # tauri-action used to build this incrementally (each platform's job + # merged its entry into the existing release's latest.json). Now that + # publishing happens once, after every platform, it's built in one + # shot instead — no merge race between matrix jobs to worry about. + # + # Schema and key names (short "-" the updater plugin actually + # reads, plus "--" aliases) copied verbatim from a + # real tauri-action-generated latest.json (v0.9.0's), not guessed — + # getting this wrong breaks auto-update silently for every user. + - name: Generate and upload latest.json (updater manifest) + shell: bash env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - with: - tagName: ${{ github.ref_name }} - releaseName: DevTool ${{ github.ref_name }} - releaseBody: ${{ env.RELEASE_NOTES }} - # Must be published (not draft) for the updater: the endpoint - # releases/latest/download/latest.json only resolves to the latest - # PUBLISHED release, so drafts are invisible to the in-app updater. - releaseDraft: false - prerelease: false - args: ${{ matrix.args }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + node scripts/build-updater-manifest.mjs + gh release upload "${RELEASE_TAG}" latest.json --clobber --repo "${GITHUB_REPOSITORY}" # ── Verifiable supply chain ───────────────────────────────────────── # Everything below turns "trust us" into something a user can check with # `gh attestation verify` / `shasum -c`. See SECURITY.md and # scripts/verify-release.sh. - - name: Collect built artifact paths - id: artifacts - shell: bash - env: - ARTIFACT_PATHS: ${{ steps.tauri.outputs.artifactPaths }} - run: | - # tauri-action reports the bundles it produced as a JSON array. - # Keep only the installers/bundles: the .sig files are already covered - # by the updater's own minisign signature. - node -e ' - const paths = JSON.parse(process.env.ARTIFACT_PATHS || "[]") - .filter((p) => !p.endsWith(".sig")); - require("fs").appendFileSync(process.env.GITHUB_OUTPUT, - `paths<<__EOF__\n${paths.join("\n")}\n__EOF__\n`); - console.log(paths.join("\n")); - ' - - name: Attest build provenance uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: - subject-path: ${{ steps.artifacts.outputs.paths }} + subject-path: | + release-assets/*.dmg + release-assets/*.app.tar.gz + release-assets/*.AppImage + release-assets/*.deb + release-assets/*.exe - name: Publish SHA256 checksums shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARTIFACT_LIST: ${{ steps.artifacts.outputs.paths }} run: | set -euo pipefail - sums="SHA256SUMS-${{ matrix.platform }}${{ matrix.rust-target && format('-{0}', matrix.rust-target) || '' }}.txt" + sums="SHA256SUMS.txt" : > "$sums" - while IFS= read -r artifact; do - [ -n "$artifact" ] || continue + for artifact in release-assets/*; do [ -f "$artifact" ] || continue - if command -v sha256sum >/dev/null 2>&1; then - (cd "$(dirname "$artifact")" && sha256sum "$(basename "$artifact")") - else - (cd "$(dirname "$artifact")" && shasum -a 256 "$(basename "$artifact")") - fi >> "$sums" - done <<< "$ARTIFACT_LIST" + case "$artifact" in *.sig) continue ;; esac + (cd "$(dirname "$artifact")" && sha256sum "$(basename "$artifact")") >> "$sums" + done cat "$sums" - gh release upload "${GITHUB_REF_NAME}" "$sums" --clobber --repo "${GITHUB_REPOSITORY}" + gh release upload "${RELEASE_TAG}" "$sums" --clobber --repo "${GITHUB_REPOSITORY}" - # One SBOM per release is enough — the dependency tree is the same on - # every platform, so only the Linux job publishes it. - name: Generate SBOM - if: matrix.platform == 'ubuntu-22.04' uses: anchore/sbom-action@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 with: path: . @@ -236,8 +321,19 @@ jobs: upload-release-assets: false - name: Publish SBOM - if: matrix.platform == 'ubuntu-22.04' shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release upload "${GITHUB_REF_NAME}" devtool-sbom.spdx.json --clobber --repo "${GITHUB_REPOSITORY}" + run: gh release upload "${RELEASE_TAG}" devtool-sbom.spdx.json --clobber --repo "${GITHUB_REPOSITORY}" + + # The point of no return: every step above succeeded (a failure in any + # of them would have stopped this job before reaching here — no + # continue-on-error anywhere in this job), so this is the first and + # only point where the release actually becomes visible to the in-app + # updater. If this workflow run stops before this step for any reason, + # what's left behind is a draft — not a broken published release. + - name: Publish the release (flip draft -> published) + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release edit "${RELEASE_TAG}" --draft=false --repo "${GITHUB_REPOSITORY}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 673d2684..6930e595 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -58,6 +58,6 @@ jobs: retention-days: 5 - name: Upload to code scanning - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 + uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 with: sarif_file: results.sarif diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 5aa0d5f2..721a7311 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -54,7 +54,7 @@ jobs: - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch with: toolchain: stable - - uses: taiki-e/install-action@5bf6ce016fd2e72eefc647cbca1e4213f65955b8 # v2 + - uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2 with: tool: cargo-audit # `cargo audit` fails on vulnerabilities; unmaintained/unsound/yanked diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 430bc377..5df936f5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,7 +42,7 @@ jobs: # Node-only job: no Tauri system libraries and no Rust toolchain, so this # reports in about a minute rather than waiting on the coverage build. - name: Install frontend dependencies - run: npm ci + run: npm ci --prefer-offline --no-audit --no-fund --ignore-scripts - name: Typecheck run: npx tsc --noEmit diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..0be6c869 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable user-facing changes to DevTool are documented here. Internal/dev-facing +implementation notes live under `docs/changelog/`; this file is for people upgrading +the app. + +## Unreleased + +### ⚠️ Breaking: Redis, RabbitMQ, Container Manager, and Kafka Explorer are now optional plugins + +These four tools used to ship built into the app. As of this release they've been +extracted into a separate, optional-install repo +([`developer-desktop-util-plugin`](https://github.com/DianaSensei/developer-desktop-util-plugin)), +so the base app installer stays small and you only download what you actually use. + +**If you used any of these tools before upgrading:** + +- After updating, they will no longer appear in the sidebar — this is expected, not a bug. +- Install the ones you need from **Settings → Extensions**, or from the plugins page: + **https://dianasensei.github.io/starlight-site/plugins** +- Saved connections for these tools are **not migrated automatically** — Tier A + data for each tool now lives in its own isolated plugin storage location + instead of the app's shared store. You'll need to re-enter your connection + details once after installing the plugin. See + `docs/decisions/architecture/platform-plugin-architecture.md` for why this + isolation was worth the one-time re-entry cost. +- Nothing else about the app changed — every other tool (API Client, Mock + Server, Port Scanner, JWT/Base64/Hash utilities, etc.) is still built in as + before. + +Relevant commits: `1b59500` (Redis/RabbitMQ/Container Manager), `5ad6ba2` (Kafka +Explorer), and the Rust-side sidecar cutovers `9a76337`/`d777bb0`/`8566311`. diff --git a/docker-compose.consumers.yml b/docker-compose.consumers.yml deleted file mode 100644 index 966a4da0..00000000 --- a/docker-compose.consumers.yml +++ /dev/null @@ -1,76 +0,0 @@ -# Simulated producers/consumers for exercising the Kafka Explorer's Consumers tab. -# -# Requires the main stack to be running first: -# docker-compose up -d -# -# Then: -# docker-compose -f docker-compose.consumers.yml up -d # start simulators -# docker-compose -f docker-compose.consumers.yml down # stop them -# -# In the Kafka tool, open the demo-events topic -> Consumers tab -> Refresh. -# You should see: app-consumers (lag ~0), lagging-group (growing lag), -# and an anonymous console-consumer-NNNN group. -services: - # Continuously produces one JSON message per second to demo-events, - # so consumers always have a moving stream and lag is observable. - producer: - image: confluentinc/cp-kafka:7.6.1 - container_name: devtool-kafka-producer - entrypoint: ["bash", "-c"] - command: - - | - i=0 - while true; do - i=$$((i+1)) - echo "{\"event\":\"tick\",\"n\":$$i,\"ts\":\"$$(date -u +%FT%TZ)\"}" - sleep 1 - done | kafka-console-producer --bootstrap-server kafka:29092 --topic demo-events - restart: unless-stopped - - # Named group that stays connected and keeps up -> lag stays ~0 (green). - consumer-group: - image: confluentinc/cp-kafka:7.6.1 - container_name: devtool-kafka-consumer-group - entrypoint: ["bash", "-c"] - command: - - | - exec kafka-console-consumer --bootstrap-server kafka:29092 \ - --topic demo-events --group app-consumers --from-beginning \ - --consumer-property enable.auto.commit=true \ - --consumer-property auto.commit.interval.ms=1000 - restart: unless-stopped - - # Anonymous consumer: no --group, so Kafka assigns an ephemeral - # console-consumer-NNNN group id. Stays connected and keeps up. - consumer-anon: - image: confluentinc/cp-kafka:7.6.1 - container_name: devtool-kafka-consumer-anon - entrypoint: ["bash", "-c"] - command: - - | - exec kafka-console-consumer --bootstrap-server kafka:29092 \ - --topic demo-events --from-beginning \ - --consumer-property enable.auto.commit=true \ - --consumer-property auto.commit.interval.ms=1000 - restart: unless-stopped - - # Lagging group: consumes a handful of messages, commits, then exits. - # Because the producer keeps going, this group's lag grows over time (orange). - consumer-lagging: - image: confluentinc/cp-kafka:7.6.1 - container_name: devtool-kafka-consumer-lagging - entrypoint: ["bash", "-c"] - command: - - | - kafka-console-consumer --bootstrap-server kafka:29092 \ - --topic demo-events --group lagging-group --from-beginning \ - --max-messages 5 \ - --consumer-property enable.auto.commit=true \ - --consumer-property auto.commit.interval.ms=500 - echo "lagging-group committed and exited; lag will now grow." - restart: "no" - -networks: - default: - name: devtool_default - external: true diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 645fc63e..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,63 +0,0 @@ -# Local Kafka for testing the Kafka Explorer tool. -# -# docker compose up -d # start broker (and seed demo topics) -# docker compose down -v # stop and wipe data -# -# In the Kafka tool, add a broker with Bootstrap Servers = localhost:9092 -# (plaintext — no TLS/SASL, which is what the tool currently supports). -services: - kafka: - image: confluentinc/cp-kafka:7.6.1 - container_name: devtool-kafka - ports: - - "9092:9092" - environment: - # --- KRaft (no ZooKeeper) single-node combined broker + controller --- - KAFKA_NODE_ID: 1 - KAFKA_PROCESS_ROLES: broker,controller - KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:29093 - CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk - # --- Listeners --- - # PLAINTEXT_HOST is what your host (and the app) connects to on localhost:9092. - # PLAINTEXT is for any in-network clients; CONTROLLER is internal KRaft traffic. - KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:29092,PLAINTEXT_HOST://0.0.0.0:9092,CONTROLLER://0.0.0.0:29093 - KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT,CONTROLLER:PLAINTEXT - KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT - KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER - # --- Single-node friendly replication settings --- - KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 - KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 - KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 - KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 - KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true" - healthcheck: - test: ["CMD", "kafka-broker-api-versions", "--bootstrap-server", "localhost:9092"] - interval: 5s - timeout: 10s - retries: 12 - volumes: - - kafka-data:/var/lib/kafka/data - - # One-shot helper that seeds a few topics with sample messages, then exits. - kafka-init: - image: confluentinc/cp-kafka:7.6.1 - container_name: devtool-kafka-init - depends_on: - kafka: - condition: service_healthy - entrypoint: ["bash", "-c"] - command: - - | - set -e - B=kafka:29092 - kafka-topics --bootstrap-server $$B --create --if-not-exists --topic demo-events --partitions 3 --replication-factor 1 - kafka-topics --bootstrap-server $$B --create --if-not-exists --topic user-signups --partitions 1 --replication-factor 1 - kafka-topics --bootstrap-server $$B --create --if-not-exists --topic orders --partitions 6 --replication-factor 1 - printf '{"event":"hello","n":1}\n{"event":"world","n":2}\n{"event":"kafka","n":3}\n' \ - | kafka-console-producer --bootstrap-server $$B --topic demo-events - echo "Seed complete." - restart: "no" - -volumes: - kafka-data: diff --git a/docs/ai/CLAUDE.md b/docs/ai/CLAUDE.md index 2451f602..64fbdc1f 100644 --- a/docs/ai/CLAUDE.md +++ b/docs/ai/CLAUDE.md @@ -4,7 +4,20 @@ ## Project Overview -**DevTool** is a cross-platform desktop application built with Tauri 2 + React + TypeScript providing developer utilities (text processing, encoding, hashing, color tools, Kafka explorer, RabbitMQ client, API client, mock server, etc.). +**DevTool** is a cross-platform desktop application built with Tauri 2 + React + TypeScript providing developer utilities (text processing, encoding, hashing, color tools, API client, mock server, etc.). + +> **Redis/RabbitMQ/Container Manager/Kafka Explorer are no longer compiled into this repo.** +> They moved to [`developer-desktop-util-plugin`](https://github.com/DianaSensei/developer-desktop-util-plugin) +> as optional, install-from-URL plugins (Settings → Extensions) — see +> `docs/decisions/architecture/platform-plugin-architecture.md` and +> `docs/decisions/architecture/optional-broker-plugins.md`. Sections +> below that still describe their internals (liveConnections examples, +> `docs/human/TOOLS.md`, etc.) are describing code that now lives in that +> repo; the pattern still applies verbatim there. Each such tool bundles its +> own MCP tool schema and self-registers with the platform MCP bridge at +> runtime (see `mcp_bridge.rs`'s `mcp_register_tools`/`mcp_unregister_tools` +> and `devtool-mcp-server.rs`'s `fetch_registered_tools()`) instead of +> being hardcoded into `devtool-mcp-server.rs`. **Key Technologies:** - **Frontend**: React 18, TypeScript, Vite 8 (Rolldown bundler) @@ -108,7 +121,7 @@ devtool/ │ ├── ai/ # AI agent guides (this file) │ ├── human/ # Human contributor guides │ └── design/DESIGN-SYSTEM.md -├── testing/rabbitmq/ # RabbitMQ integration test harness (Python + Docker) +├── testing/kafka/ # Kafka integration test harness (Python + Docker) ├── public/ # Static assets └── package.json ``` diff --git a/docs/decisions/architecture/canary-release-channel.md b/docs/decisions/architecture/canary-release-channel.md index 169abab1..68b69c2c 100644 --- a/docs/decisions/architecture/canary-release-channel.md +++ b/docs/decisions/architecture/canary-release-channel.md @@ -1,6 +1,11 @@ # Kênh phát hành canary — thử nghiệm kiến trúc mới song song với bản ổn định -**Trạng thái**: đã dựng cơ chế CI, chưa phát hành lần đầu. +**Trạng thái**: đã RETIRE. Kiến trúc Platform/Plugin đã qua canary +(`canary-v1.1.0-arch1`, GitHub Release `canary-latest`), được xác nhận ổn +định, và đã hợp nhất thẳng vào `main` (`v0.9.0` trở đi phát hành từ đó qua +`release.yml` như bình thường — không còn kênh song song). `release-canary.yml` +và `src-tauri/tauri.canary.conf.json` đã bị xoá khỏi repo; nội dung dưới đây +giữ lại làm tài liệu lịch sử cho cơ chế, không còn áp dụng. ## Vì sao diff --git a/docs/decisions/architecture/optional-broker-plugins.md b/docs/decisions/architecture/optional-broker-plugins.md new file mode 100644 index 00000000..c634f8b1 --- /dev/null +++ b/docs/decisions/architecture/optional-broker-plugins.md @@ -0,0 +1,89 @@ +# Redis/RabbitMQ/Container Manager/Kafka Explorer moved to a separate plugin repo + +## Bối cảnh + +`redis-client`, `rabbit-client`, `container-manager` từng là ba plugin +compile-time như mọi tool khác trong `src/plugins/`. Cả ba đã là plugin Tier +B (sidecar riêng: `devtool-svc-redis`/`-rabbit`/`-container`) từ trước — +nghĩa là chúng đã tách khỏi tiến trình chính, chỉ chưa tách khỏi BẢN BUILD. +Không phải ai dùng DevTool cũng làm việc với Redis, RabbitMQ, hay +Docker/Podman — ba dependency đó (crate `redis`, `lapin`, `bollard` cùng cây +phụ thuộc của chúng) kéo dài thời gian build và tăng kích thước app cho +100% người dùng để phục vụ một nhóm nhỏ hơn. + +Kafka Explorer ban đầu KHÔNG chuyển theo đợt đầu: logic Kafka nằm thẳng +trong `src-tauri/src/kafka.rs` như native command (không phải sidecar riêng +như ba tool kia), nên tách nó đòi viết lại thành sidecar JSONL trước +(`devtool-svc-kafka`) — cùng khuôn với `devtool-svc-redis` (đăng ký luồng +qua `ConsumerRegistry`/`Notify` cho live-consume). Việc viết lại đó đã xong +ở một phiên sau; Kafka Explorer giờ theo đúng khuôn ba tool kia. + +## Quyết định + +Chuyển UI + sidecar của cả bốn (`redis-client`, `rabbit-client`, +`container-manager`, `kafka-explorer`) sang repo riêng, +[`developer-desktop-util-plugin`](https://github.com/DianaSensei/developer-desktop-util-plugin), +cài qua URL (Settings → Extensions) thay vì compile-time. Cơ chế cài-từ-URL +đã tồn tại từ trước (`docs/plugin-sdk/05-external-install.md`) — quyết định +này không thêm cơ chế mới, chỉ là lần đầu dùng nó cho một plugin ĐÃ CÓ giá trị +người dùng thật (trước đó chỉ có `devtool-svc-echo`, một ví dụ tối giản). + +## Hai lỗ hổng phải vá để việc chuyển này hoạt động thật + +Cơ chế `kind: "plugin"` (external-install) trước đây được thiết kế cho một +plugin KHÔNG quyền — ví dụ trong tài liệu chỉ render `
Hello!
`. +Redis/Rabbit/Container cần `sdk.storage`, `sdk.service` (gọi sidecar), +`sdk.clipboard`… Hai chỗ phải mở rộng: + +1. **`window.__DEVTOOL_VENDOR__` chỉ mang React trước đó.** Một bundle cài từ + URL không `import` được `@/platform` (module đó không tồn tại ngoài build + của app chính) — nên không gọi được `usePluginSdk()` để lấy SDK của chính + mình. Thêm `window.__DEVTOOL_VENDOR__.platform.*` (`usePluginSdk`, + `usePluginSdkFor`, `getPluginSdk`, `usePluginState`, `migrateLegacyKey`, + `usePluginConfig`, `useLiveConnection`, `usePluginMcpBridgeActive`) — mỗi + hàm là ĐÚNG hàm app chính dùng nội bộ, quyền vẫn kiểm y hệt, chỉ khác chỗ + plugin lấy tham chiếu hàm (`main.tsx` thay vì `import`). Xem + `docs/plugin-sdk/05-external-install.md`. + +2. **`RemotePluginManifest` thiếu `service`.** Trước bản này, một manifest cài + từ URL khai `permissions: ['service']` mà không có field `service.bin`/ + `service.methods` tương ứng bị `validateManifest()` từ chối thẳng + (mismatch giữa `permissions` và `service`) — nghĩa là KHÔNG plugin nào gọi + được sidecar qua đường cài-từ-URL, dù chính sidecar đó cài được. Thêm + `service?: ServiceDescriptor` vào `RemotePluginManifest` (`installer.ts`) + và `RemoteServiceDescriptor` phía Rust (`artifact_installer.rs`), truyền + xuyên qua `installedPluginManifests()`. `bin` vẫn phải nằm trong + `ALLOWED_SERVICES` (service_host.rs) — trường mới chỉ mang allowlist + METHOD, không tự cấp quyền chạy một bin mới. + +## Điều gì KHÔNG đổi + +- `ALLOWED_SERVICES` (service_host.rs) vẫn giữ tên `devtool-svc-redis`/ + `-rabbit`/`-container` — ranh giới tin cậy "bin nào được phép chạy" là + quyết định của TÁC GIẢ lúc build app, cơ chế cài-từ-URL chỉ đổi CHỖ LẤY + BYTES. Bỏ tên khỏi allowlist thì việc cài sidecar từ URL sẽ luôn bị chặn, + vĩnh viễn — xem comment tại chỗ khai hằng số đó. +- `BUNDLED_SERVICES` (hằng số MỚI, tách khỏi `ALLOWED_SERVICES`) mới là tập + con thực sự đóng gói sẵn (`devtool-svc-echo`) — test + `allowlist_khop_voi_external_bin` giờ chỉ khoá tập con này khớp + `bundle.externalBin`, không khoá toàn bộ allowlist như trước. + +## Hệ quả chấp nhận được + +- **MCP**: `McpBackgroundBridge.tsx` không còn giữ runtime nền cho bốn tool + này (một plugin cài từ URL chỉ mount khi route của nó đang mở — không có + compile-time runtime context nào để share lúc tool không ở trên màn hình). + `devtool-mcp-server.rs` không còn khai tool MCP tĩnh cho bất cứ plugin nào + (kể cả các tool compile-in như API Client/Mock Server) — mỗi plugin tự + mang schema MCP của nó (`mcpTools.ts` trong chính plugin) và tự đăng ký + qua `mcp_register_tools`/`mcp_unregister_tools` (`mcp_bridge.rs`) khi + bridge của nó mount, tự huỷ đăng ký khi unmount. `devtool-mcp-server.rs` + đọc danh sách tool đã đăng ký qua `GET /tools` trên MỖI lần `list_tools()` + (không cache lúc khởi động process) — nên với bốn tool này, lời gọi MCP + chỉ xuất hiện sau khi người dùng cài plugin VÀ mở đúng route của nó (lúc + đó bridge mới mount và đăng ký). +- **Tailwind**: các class utility riêng của ba tool này được giữ lại trong + CSS đã build qua `src/styles/externalPluginClassnamesSafelist.ts` (một + file text thuần, không import ở đâu) — nếu không, Tailwind JIT sẽ âm thầm + loại các class đó khỏi CSS ngay khi source rời `src/`, và ai cài lại plugin + sau này sẽ thấy nó render thiếu style. diff --git a/docs/plugin-sdk/05-external-install.md b/docs/plugin-sdk/05-external-install.md index 1dd8fcbe..a346e73d 100644 --- a/docs/plugin-sdk/05-external-install.md +++ b/docs/plugin-sdk/05-external-install.md @@ -93,6 +93,26 @@ export default function HelloPlugin() { } ``` +**Dùng SDK DÙNG CHUNG của app nếu cần `permissions`** — một plugin khai +`permissions`/`service` trong manifest (vd để gọi một sidecar Tier B) đọc SDK +của chính nó qua `window.__DEVTOOL_VENDOR__.platform.*`, KHÔNG `import` từ +`@/platform` — bundle của bạn không có module đó. Bề mặt hiện expose: +`usePluginSdk`, `usePluginSdkFor`, `getPluginSdk`, `usePluginState`, +`migrateLegacyKey`, `usePluginConfig`, `useLiveConnection`, +`usePluginMcpBridgeActive`. Đây là ĐÚNG cùng hàm mà app chính dùng nội bộ; +`usePluginSdk()` vẫn kiểm đúng `permissions` đã khai và audit lại mọi lời gọi +như plugin compile-time, chỉ khác chỗ lấy bytes của hàm, không khác chỗ áp +luật: + +```js +const { usePluginSdk } = window.__DEVTOOL_VENDOR__.platform; + +export default function MyPlugin() { + const sdk = usePluginSdk(); + // sdk.storage / sdk.service / sdk.clipboard / … — giống hệt plugin compile-time +} +``` + **Dùng React DÙNG CHUNG của app** — không tự bundle `react`/`react-dom`. Một bundle mang theo bản React riêng sẽ vỡ hook ("Invalid hook call" kinh điển, vì hook đọc trạng thái nội bộ gắn với ĐÚNG một instance React). App @@ -186,3 +206,37 @@ import { currentTargetTriple, // () => Promise } from '@/platform'; ``` + +## Link `desktop-devtool-app://install` — bấm trên trang web thay vì copy/dán tay + +Trang plugin (`starlight-site`'s `plugins.astro`) không bắt người dùng tự +copy URL manifest rồi dán vào Settings nữa — mỗi thẻ plugin có một nút "Cài +đặt" trỏ tới: + +``` +desktop-devtool-app://install?manifest=&service= +``` + +Cơ chế (xem `src-tauri/src/main.rs`, `src/lib/deepLink.ts`, +`src/lib/pendingInstall.ts`): + +1. `tauri-plugin-deep-link` đăng ký scheme `desktop-devtool-app` (config ở + `tauri.conf.json`'s `plugins.deep-link.desktop.schemes`) — hệ điều hành + mở app khi người dùng bấm link này. Bản canary đăng ký scheme riêng + (`desktop-devtool-app-canary`, `tauri.canary.conf.json`) để không giành + đăng ký OS-level với bản stable — nhưng cả hai bản chạy chung một bundle + JS, nên `deepLink.ts`'s `INSTALL_LINK_SCHEMES` chấp nhận cả hai. +2. `tauri-plugin-single-instance` (feature `deep-link`, đăng ký TRƯỚC mọi + plugin khác trong `main.rs`) đảm bảo bấm link lần hai khi app đã mở + không mở thêm cửa sổ — chuyển tiếp URL sang tiến trình đang chạy. +3. `deepLink.ts` (JS) nghe qua `@tauri-apps/plugin-deep-link`'s + `onOpenUrl`/`getCurrent`, tách `manifest`/`service` thành hàng đợi + (`pendingInstall.ts`), rồi điều hướng tới `/settings`. +4. `SettingsExtensionInstaller.tsx` tự lấy URL kế tiếp trong hàng đợi, điền + vào ô URL và gọi `fetchArtifactManifestPreview` ngay — **không tự cài**. + Người dùng vẫn phải tự bấm "Cài đặt" sau khi xem tên/quyền/nguồn, đúng + luồng xác nhận đã có sẵn. Cài xong plugin, nếu hàng đợi còn URL service + (sidecar của plugin đó), màn xem trước tiếp theo tự hiện ra. + +Không có phần nào ở đây bỏ qua bước xác nhận — deep link chỉ thay việc +copy/dán URL bằng một cú bấm. diff --git a/package-lock.json b/package-lock.json index 4d0152e0..0e648926 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@radix-ui/react-slot": "^1.0.2", "@tauri-apps/api": "^2", "@tauri-apps/plugin-clipboard-manager": "^2", + "@tauri-apps/plugin-deep-link": "^2.4.10", "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-fs": "^2", "@tauri-apps/plugin-http": "^2.6.0", @@ -46,19 +47,19 @@ "fast-xml-parser": "^5.11.1", "hash-wasm": "^4.12.0", "jose": "^6.2.12", - "js-yaml": "^5.4.1", + "js-yaml": "^5.4.2", "jsqr": "^1.4.0", "jwt-decode": "^4.0.0", "lodash": "^4.18.1", - "lucide-react": "^1.41.0", + "lucide-react": "^1.45.0", "nanoid": "^6.0.1", "qrcode": "^1.5.3", - "react": "^19.2.8", - "react-dom": "^19.2.8", + "react": "^19.3.0", + "react-dom": "^19.3.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.18.3", "smol-toml": "^1.8.0", - "tailwind-merge": "^3.6.0", + "tailwind-merge": "^3.7.0", "tailwindcss-animate": "^1.0.7", "uuid": "^14.0.2", "xml2js": "^0.6.2" @@ -71,20 +72,20 @@ "@types/crypto-js": "^4.2.2", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.25", - "@types/node": "^26.4.1", + "@types/node": "^26.5.1", "@types/qrcode": "^1.5.5", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.7", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@types/xml2js": "^0.4.14", "@vitejs/plugin-react": "^6.1.1", - "@vitest/coverage-v8": "^4.1.11", + "@vitest/coverage-v8": "^5.0.0", "jsdom": "^30.0.1", "postcss": "^8.5.28", "sharp": "^0.35.3", "tailwindcss": "^4.3.3", "typescript": "^7.0.2", - "vite": "^8.2.2", - "vitest": "^4.1.11" + "vite": "^8.3.0", + "vitest": "^5.0.0" } }, "node_modules/@alloc/quick-lru": { @@ -1219,9 +1220,9 @@ } }, "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==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "dev": true, "license": "MIT" }, @@ -2167,13 +2168,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, "node_modules/@tailwindcss/node": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", @@ -3008,6 +3002,15 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@tauri-apps/plugin-deep-link": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.10.tgz", + "integrity": "sha512-SuXI47ORUzYuwG6c8dluLDrStTvli5bAZDRa2+VuUEzK/9TEa7FZz7UKXBQriL7+CPFXRHQsz5tSocc47tRXQQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@tauri-apps/plugin-dialog": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.3.tgz", @@ -3214,13 +3217,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.4.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.1.tgz", - "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", + "version": "26.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz", + "integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~8.9.0" } }, "node_modules/@types/qrcode": { @@ -3234,22 +3237,22 @@ } }, "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "devOptional": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^19.3.0" } }, "node_modules/@types/unist": { @@ -3645,29 +3648,27 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", - "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.1.tgz", + "integrity": "sha512-FRC8ACiudC3dI6MTplzRSYWHDRnIv2IPfbzs4FdoJNsMal/35sWV8hwIfV8ZcqzSPy+uXHeMVONt9CEqtOU17w==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.11", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" + "@vitest/istanbul-lib-coverage": "^1.0.0", + "@vitest/istanbul-lib-report": "^1.0.0", + "ast-v8-to-istanbul": "^1.0.5", + "magicast": "^0.5.4", + "obug": "^2.1.4", + "std-env": "^4.2.0", + "tinyrainbow": "^3.1.1" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.11", - "vitest": "4.1.11" + "@vitest/browser": "5.0.1", + "vitest": "5.0.1" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -3675,34 +3676,40 @@ } } }, - "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "node_modules/@vitest/istanbul-lib-coverage": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.1.tgz", + "integrity": "sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@vitest/istanbul-lib-report": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.1.tgz", + "integrity": "sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "@vitest/istanbul-lib-coverage": "1.0.1" }, - "funding": { - "url": "https://opencollective.com/vitest" + "engines": { + "node": ">=22" } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "magic-string": "^1.2.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3720,70 +3727,22 @@ } } }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.11", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", "dev": true, "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.11", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, "funding": { "url": "https://opencollective.com/vitest" } @@ -4149,13 +4108,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "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": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -4627,16 +4579,6 @@ "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", - "engines": { - "node": ">=8" - } - }, "node_modules/hash-wasm": { "version": "4.12.0", "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz", @@ -4696,13 +4638,6 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -4846,45 +4781,6 @@ ], "license": "MIT" }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -4912,9 +4808,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.1.tgz", - "integrity": "sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz", + "integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==", "funding": [ { "type": "github", @@ -5310,9 +5206,9 @@ } }, "node_modules/lucide-react": { - "version": "1.41.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.41.0.tgz", - "integrity": "sha512-6lksP35l6KszDKUeRTi4LV7i6DEe0Yzl2ALJm9j4c5xEYN91GdW1xGsawGMOg2mgjF5GHBVX8pKX9kP+cWsP3Q==", + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.45.0.tgz", + "integrity": "sha512-yH1ubCAduho9UR7oJhRXIQXogksRILBiTuZC4/bQIGeB9JOkxMlSuEHyyZpo1Z3S0yWJO2KTSUZbjiNvVxeOUw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -5350,22 +5246,6 @@ "source-map-js": "^1.2.1" } }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/mdast-util-from-markdown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", @@ -6005,9 +5885,9 @@ } }, "node_modules/obug": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", - "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -6189,13 +6069,6 @@ "node": ">=14.0.0" } }, - "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/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6203,6 +6076,19 @@ "dev": true, "license": "ISC" }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -6313,24 +6199,24 @@ } }, "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", - "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^19.2.8" + "react": "^19.3.0" } }, "node_modules/react-is": { @@ -6594,9 +6480,9 @@ } }, "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, "node_modules/semver": { @@ -6806,19 +6692,6 @@ "inline-style-parser": "0.2.7" } }, - "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", - "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", @@ -6827,9 +6700,9 @@ "license": "MIT" }, "node_modules/tailwind-merge": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", - "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", + "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==", "license": "MIT", "funding": { "type": "github", @@ -6866,11 +6739,14 @@ } }, "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } }, "node_modules/tinyexec": { "version": "1.3.0", @@ -6917,19 +6793,6 @@ } } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinyrainbow": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", @@ -7058,9 +6921,9 @@ } }, "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "dev": true, "license": "MIT" }, @@ -7236,16 +7099,16 @@ } }, "node_modules/vite": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", - "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.26", - "rolldown": "~1.2.4", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "bin": { @@ -7262,7 +7125,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -7313,52 +7176,32 @@ } } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -7366,16 +7209,16 @@ "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -7416,17 +7259,14 @@ } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", - "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/w3c-keyname": { diff --git a/package.json b/package.json index a7517dfb..3e63e48b 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@radix-ui/react-slot": "^1.0.2", "@tauri-apps/api": "^2", "@tauri-apps/plugin-clipboard-manager": "^2", + "@tauri-apps/plugin-deep-link": "^2.4.10", "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-fs": "^2", "@tauri-apps/plugin-http": "^2.6.0", @@ -54,19 +55,19 @@ "fast-xml-parser": "^5.11.1", "hash-wasm": "^4.12.0", "jose": "^6.2.12", - "js-yaml": "^5.4.1", + "js-yaml": "^5.4.2", "jsqr": "^1.4.0", "jwt-decode": "^4.0.0", "lodash": "^4.18.1", - "lucide-react": "^1.41.0", + "lucide-react": "^1.45.0", "nanoid": "^6.0.1", "qrcode": "^1.5.3", - "react": "^19.2.8", - "react-dom": "^19.2.8", + "react": "^19.3.0", + "react-dom": "^19.3.0", "react-markdown": "^10.1.0", "react-router-dom": "^7.18.3", "smol-toml": "^1.8.0", - "tailwind-merge": "^3.6.0", + "tailwind-merge": "^3.7.0", "tailwindcss-animate": "^1.0.7", "uuid": "^14.0.2", "xml2js": "^0.6.2" @@ -79,19 +80,19 @@ "@types/crypto-js": "^4.2.2", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.25", - "@types/node": "^26.4.1", + "@types/node": "^26.5.1", "@types/qrcode": "^1.5.5", - "@types/react": "^19.2.18", - "@types/react-dom": "^19.2.7", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@types/xml2js": "^0.4.14", "@vitejs/plugin-react": "^6.1.1", - "@vitest/coverage-v8": "^4.1.11", + "@vitest/coverage-v8": "^5.0.0", "jsdom": "^30.0.1", "postcss": "^8.5.28", "sharp": "^0.35.3", "tailwindcss": "^4.3.3", "typescript": "^7.0.2", - "vite": "^8.2.2", - "vitest": "^4.1.11" + "vite": "^8.3.0", + "vitest": "^5.0.0" } } diff --git a/scripts/build-updater-manifest.mjs b/scripts/build-updater-manifest.mjs new file mode 100644 index 00000000..b39b52fb --- /dev/null +++ b/scripts/build-updater-manifest.mjs @@ -0,0 +1,185 @@ +// Builds latest.json (the Tauri updater manifest) after every platform's +// bundle has already been built, signed, downloaded, and uploaded as +// release assets — see .github/workflows/release.yml's `publish` job. +// +// tauri-action used to generate this file itself, incrementally, as each +// platform's job finished. Splitting build from publish means no single +// step still has that full picture handed to it, so this reconstructs the +// same file from what's on disk (downloaded/bundle-/*) and what +// GitHub Releases assigned each uploaded asset (asset id -> API url). +// +// Schema, key names, and the "which format is signed / which format also +// gets the short - alias" rules below are copied from a REAL +// tauri-action-generated latest.json (v0.9.0's — the last release built by +// the old combined build+publish step), not guessed. Getting this wrong +// breaks auto-update silently for every user, so: +// - dmg is NOT signed (no .sig sibling) and gets NO manifest entry at all. +// - app.tar.gz (macOS), AppImage (Linux), and nsis .exe (Windows) are each +// that platform's "primary" updater format: they get both the bare +// "-" key AND a "--" alias. +// - deb is signed but is NOT primary: it gets only "--deb", no +// bare "linux-x86_64" duplicate (AppImage already owns that key). +import { execFileSync } from 'node:child_process'; +import { accessSync, constants, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; +import { basename, delimiter, dirname, join } from 'node:path'; + +const RELEASE_TAG = process.env.RELEASE_TAG; +const RELEASE_NOTES = process.env.RELEASE_NOTES; +const REPO = process.env.GITHUB_REPOSITORY; +if (!RELEASE_TAG || !REPO) throw new Error('RELEASE_TAG and GITHUB_REPOSITORY env vars are required'); + +// Resolve to an absolute path ourselves instead of letting execFileSync hand +// a bare command name to the OS for PATH lookup (SonarCloud javascript:S4036 +// — "make sure the PATH variable only contains fixed, unwriteable +// directories"; a bare name trusts every directory on PATH equally, whereas +// this only trusts whichever one actually has an executable named `gh`). +function resolveExecutable(name) { + for (const dir of (process.env.PATH ?? '').split(delimiter)) { + if (!dir) continue; + const candidate = join(dir, name); + try { + accessSync(candidate, constants.X_OK); + return candidate; + } catch { + continue; + } + } + throw new Error(`\`${name}\` not found on PATH`); +} + +const GH = resolveExecutable('gh'); + +const version = RELEASE_TAG.replace(/^v/, ''); + +// Maps this workflow's `matrix.platform` (the directory each +// actions/upload-artifact name="bundle-${{ matrix.platform }}" download +// lands in) to the updater's platform-arch key. Keep in sync with +// release.yml's build job matrix. +const PLATFORM_TO_OS_ARCH = { + 'bundle-ubuntu-22.04': 'linux-x86_64', + 'bundle-macos-latest': 'darwin-aarch64', + 'bundle-windows-latest': 'windows-x86_64', +}; + +// extension -> { format, primary } — primary formats also get the bare +// "-" key; dmg is intentionally absent (unsigned, no entry). +const FORMAT_RULES = [ + { ext: '.app.tar.gz', format: 'app', primary: true }, + { ext: '.AppImage', format: 'appimage', primary: true }, + { ext: '.deb', format: 'deb', primary: false }, + { ext: '.exe', format: 'nsis', primary: true }, +]; + +function classify(filename) { + for (const rule of FORMAT_RULES) { + if (filename.endsWith(rule.ext)) return rule; + } + return null; +} + +// Tauri's own bundle output nests one subdirectory per format +// (bundle/dmg/*.dmg, bundle/macos/*.app.tar.gz, bundle/appimage/*.AppImage, +// bundle/deb/*.deb, bundle/nsis/*.exe) — confirmed the hard way when v0.9.1's +// test run found zero files with a flat readdirSync (it only saw the format +// subdirectory NAMES — "dmg", "macos", etc — which never match any +// extension). actions/upload-artifact preserves that nesting, so this has +// to walk it back down instead of assuming a flat directory. +function walkFiles(dir) { + const out = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) out.push(...walkFiles(full)); + else out.push(full); + } + return out; +} + +// Asset URLs come from the GitHub REST API response for this release (the +// exact `https://api.github.com/repos/OWNER/REPO/releases/assets/` form +// tauri-action itself used — confirmed against v0.9.0's real latest.json), +// not built by hand from the filename. +// +// GET /releases/tags/{tag} (the obvious-looking endpoint) 404s on a DRAFT +// release — confirmed the hard way in v0.9.1-test2's run: GitHub doesn't +// create the actual git tag ref until a release is PUBLISHED, and that +// endpoint requires the tag to already exist. release.yml creates this +// release as a draft on purpose (see its "Create GitHub Release (draft)" +// step) so a failure here doesn't leave a broken published release — so +// this has to find it a different way: GET /releases lists every release +// including drafts, filtered by tag_name. +// +// Filtered with --jq (server-side, before it ever reaches Node) rather than +// piping the whole `--paginate` array through JSON.parse — confirmed the +// hard way in v0.9.1-test3's run: this repo already has 90+ releases, each +// with a full asset array, and the unfiltered response blew past +// execFileSync's default 1MB maxBuffer (ENOBUFS) well before parsing ever +// started. --jq also runs per-page under --paginate, so this filters every +// page down to (at most) the one release that matches instead of buffering +// all of them. +// `gh api` doesn't expose jq's --arg (no user-supplied variables into the +// filter), so RELEASE_TAG has to go straight into the jq string literal — +// escaped for jq's own string syntax, not shell (execFileSync passes argv +// directly, no shell involved). +const tagForJq = RELEASE_TAG.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +const releaseJson = execFileSync( + GH, + ['api', `repos/${REPO}/releases`, '--paginate', '--jq', `.[] | select(.tag_name == "${tagForJq}")`], + { encoding: 'utf-8', maxBuffer: 1024 * 1024 * 20 }, +).trim(); +const release = releaseJson ? JSON.parse(releaseJson.split('\n')[0]) : undefined; +if (!release) throw new Error(`No release found with tag_name ${RELEASE_TAG} (checked drafts too)`); +const assetUrlByName = new Map(release.assets.map((a) => [a.name, a.url])); + +const platforms = {}; + +for (const platformDir of readdirSync('downloaded')) { + const osArch = PLATFORM_TO_OS_ARCH[platformDir]; + if (!osArch) continue; // unknown/extra directory — ignore rather than guess + + for (const filePath of walkFiles(join('downloaded', platformDir))) { + const filename = basename(filePath); + if (filename.endsWith('.sig')) continue; // handled as a sibling below + const rule = classify(filename); + if (!rule) continue; // e.g. dmg — unsigned, not part of the manifest + + // The manifest's "signature" field is the .sig file's raw text content + // VERBATIM, not re-encoded — Tauri's own updater signer already writes + // that file as base64(the minisign block), so a real tauri-action + // manifest's "signature" string decodes ONCE to the human-readable + // minisign block ("untrusted comment: ..."). Re-encoding it here (an + // earlier version of this script did `readFileSync(sigPath).toString + // ('base64')`) produces a signature field that needs TWO decodes — + // confirmed the hard way against v0.9.1-test4's real release: the + // in-app updater rejected it with "Invalid encoding in minisign data". + // v0.9.0's real, working, tauri-action-built latest.json needs exactly + // one decode — verified directly against its actual signature field. + const sigPath = join(dirname(filePath), `${filename}.sig`); + let signature; + try { + signature = readFileSync(sigPath, 'utf-8').trim(); + } catch { + continue; // no .sig sibling — not an updater-signed artifact (e.g. dmg) + } + + const url = assetUrlByName.get(filename); + if (!url) throw new Error(`No uploaded release asset found for ${filename} — was it actually uploaded?`); + + const entry = { signature, url }; + platforms[`${osArch}-${rule.format}`] = entry; + if (rule.primary) platforms[osArch] = entry; + } +} + +if (Object.keys(platforms).length === 0) { + throw new Error('No signed updater artifacts found in downloaded/ — refusing to publish an empty latest.json'); +} + +const manifest = { + version, + notes: RELEASE_NOTES ?? '', + pub_date: new Date().toISOString(), + platforms, +}; + +writeFileSync('latest.json', JSON.stringify(manifest, null, 2) + '\n'); +console.log(JSON.stringify(manifest, null, 2)); diff --git a/scripts/prepare-service-sidecars.mjs b/scripts/prepare-service-sidecars.mjs index f7966230..078762d1 100644 --- a/scripts/prepare-service-sidecars.mjs +++ b/scripts/prepare-service-sidecars.mjs @@ -23,7 +23,12 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { buildSidecar } from './sidecar.mjs'; -const SERVICE_SIDECARS = ['devtool-svc-echo', 'devtool-svc-redis', 'devtool-svc-container', 'devtool-svc-rabbit']; +// devtool-svc-redis/container/rabbit moved to developer-desktop-util-plugin +// (optional, install-from-URL sidecars) — see +// docs/decisions/architecture/optional-broker-plugins.md. Their names +// stay in `ALLOWED_SERVICES` (service_host.rs) so an install still works, +// but this repo no longer builds or bundles the binaries. +const SERVICE_SIDECARS = ['devtool-svc-echo']; const root = dirname(dirname(fileURLToPath(import.meta.url))); for (const bin of SERVICE_SIDECARS) buildSidecar(root, bin); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 699afb84..29438829 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -15,18 +15,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ "crypto-common 0.2.2", - "inout 0.2.2", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher 0.4.4", - "cpufeatures 0.2.17", + "inout", ] [[package]] @@ -35,7 +24,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ - "cipher 0.5.2", + "cipher", "cpubits", "cpufeatures 0.3.0", ] @@ -47,8 +36,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" dependencies = [ "aead", - "aes 0.9.3", - "cipher 0.5.2", + "aes", + "cipher", "ctr", "ctutils", "ghash", @@ -92,56 +81,6 @@ dependencies = [ "alloc-no-stdlib", ] -[[package]] -name = "amq-protocol" -version = "10.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63f30a81ad95c7a278080be6d993d5b3e971d93e1b00bd894067220fc756804" -dependencies = [ - "amq-protocol-tcp", - "amq-protocol-types", - "amq-protocol-uri", - "cookie-factory", - "nom 8.0.0", - "serde", -] - -[[package]] -name = "amq-protocol-tcp" -version = "10.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b1bcecdd743111f05c26294d26b887ba6b2e4789c27515aa48f0fe32066e96c" -dependencies = [ - "amq-protocol-uri", - "async-rs", - "cfg-if", - "tcp-stream", - "tracing", -] - -[[package]] -name = "amq-protocol-types" -version = "10.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a268a50e97bd5d52292a8b96636f6436215def6f3d128fb8218318f5feb4c33" -dependencies = [ - "cookie-factory", - "nom 8.0.0", - "serde", - "serde_json", -] - -[[package]] -name = "amq-protocol-uri" -version = "10.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a34581b010818ef3da6b5a853e94f6438b3903914baba77a15f7fb2c7ad8958" -dependencies = [ - "amq-protocol-types", - "percent-encoding", - "url", -] - [[package]] name = "android_system_properties" version = "0.1.6" @@ -198,51 +137,6 @@ dependencies = [ "x11rb", ] -[[package]] -name = "arcstr" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" - -[[package]] -name = "asn1-rs" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" -dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom 7.1.3", - "num-traits", - "rusticata-macros", - "thiserror 2.0.20", - "time", -] - -[[package]] -name = "asn1-rs-derive" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "asn1-rs-impl" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "async-broadcast" version = "0.7.2" @@ -267,19 +161,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-compat" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" -dependencies = [ - "futures-core", - "futures-io", - "once_cell", - "pin-project-lite", - "tokio", -] - [[package]] name = "async-executor" version = "1.14.0" @@ -294,20 +175,6 @@ dependencies = [ "slab", ] -[[package]] -name = "async-global-executor" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13f937e26114b93193065fd44f507aa2e9169ad0cdabbb996920b1fe1ddea7ba" -dependencies = [ - "async-channel", - "async-executor", - "async-lock", - "blocking", - "futures-lite", - "tokio", -] - [[package]] name = "async-io" version = "2.6.0" @@ -366,22 +233,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "async-rs" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f55b2bcef73a79a2feb5496478693077b60b7337896f0b72431cd5311d988a" -dependencies = [ - "async-compat", - "async-global-executor", - "async-trait", - "futures-core", - "futures-io", - "hickory-resolver", - "tokio", - "tokio-stream", -] - [[package]] name = "async-signal" version = "0.2.14" @@ -452,29 +303,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "aws-lc-rs" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - [[package]] name = "axum" version = "0.8.9" @@ -527,15 +355,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "backon" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" -dependencies = [ - "fastrand", -] - [[package]] name = "base64" version = "0.21.7" @@ -554,12 +373,6 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bindgen" version = "0.72.1" @@ -628,15 +441,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - [[package]] name = "block-padding" version = "0.4.2" @@ -668,49 +472,6 @@ dependencies = [ "piper", ] -[[package]] -name = "bollard" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbe8358268799ebb3e4df23cb9d47f4c72bbc4f5247e2fa6a1bf7b6c0baea220" -dependencies = [ - "base64 0.22.1", - "bollard-stubs", - "bytes", - "futures-core", - "futures-util", - "hex", - "http", - "http-body-util", - "hyper", - "hyper-named-pipe", - "hyper-util", - "hyperlocal", - "log", - "pin-project-lite", - "serde", - "serde_derive", - "serde_json", - "serde_urlencoded", - "thiserror 2.0.20", - "tokio", - "tokio-util", - "tower-service", - "url", - "winapi", -] - -[[package]] -name = "bollard-stubs" -version = "1.53.1-rc.29.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce412eb6f7096743011dc3cb5c674caeb24ced61d8c498fe07cf7998a4fea889" -dependencies = [ - "serde", - "serde_json", - "serde_repr", -] - [[package]] name = "brotli" version = "8.0.4" @@ -841,22 +602,13 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher 0.4.4", -] - [[package]] name = "cbc" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" dependencies = [ - "cipher 0.5.2", + "cipher", ] [[package]] @@ -866,8 +618,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex 2.0.1", ] @@ -927,7 +677,7 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -944,16 +694,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout 0.1.4", -] - [[package]] name = "cipher" version = "0.5.2" @@ -962,7 +702,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer 0.12.1", "crypto-common 0.2.2", - "inout 0.2.2", + "inout", ] [[package]] @@ -985,33 +725,12 @@ dependencies = [ "error-code", ] -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "cmov" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" -[[package]] -name = "cms" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" -dependencies = [ - "const-oid 0.9.6", - "der", - "spki", - "x509-cert", -] - [[package]] name = "combine" version = "4.6.8" @@ -1019,11 +738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", - "futures-core", "memchr", - "pin-project-lite", - "tokio", - "tokio-util", ] [[package]] @@ -1035,12 +750,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "const-oid" version = "0.10.2" @@ -1078,12 +787,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "cookie-factory" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" - [[package]] name = "cookie_store" version = "0.22.1" @@ -1176,15 +879,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc32c" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" -dependencies = [ - "rustc_version", -] - [[package]] name = "crc32fast" version = "1.5.1" @@ -1194,12 +888,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - [[package]] name = "crossbeam-channel" version = "0.5.16" @@ -1209,15 +897,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-epoch" -version = "0.9.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.22" @@ -1248,7 +927,7 @@ checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "getrandom 0.4.3", "hybrid-array", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -1296,7 +975,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher 0.5.2", + "cipher", ] [[package]] @@ -1328,6 +1007,16 @@ dependencies = [ "darling_macro 0.23.0", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -1355,6 +1044,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.4", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -1378,10 +1080,15 @@ dependencies = [ ] [[package]] -name = "data-encoding" -version = "2.11.1" +name = "darling_macro" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.4", +] [[package]] name = "data-url" @@ -1431,44 +1138,6 @@ dependencies = [ "thiserror 2.0.20", ] -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid 0.9.6", - "der_derive", - "flagset", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "der-parser" -version = "10.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom 7.1.3", - "num-bigint 0.4.8", - "num-traits", - "rusticata-macros", -] - -[[package]] -name = "der_derive" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "deranged" version = "0.5.8" @@ -1541,15 +1210,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "des" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" -dependencies = [ - "cipher 0.4.4", -] - [[package]] name = "devtool" version = "0.1.0" @@ -1557,42 +1217,34 @@ dependencies = [ "aes-gcm", "axum", "base64 0.23.1", - "bollard", - "bytes", "chrono", "digest 0.11.3", "futures-util", "getrandom 0.4.3", "hostname", - "http-body-util", - "hyper", - "hyper-util", - "hyperlocal", "keyring", - "lapin", "local-ip-address", "md-5", "netstat2", - "percent-encoding", - "redis", "regex", "reqwest 0.12.28", "rhai", "rmcp", - "rskafka", "serde", "serde_json", - "sha1 0.11.0", + "sha1", "sha2 0.11.0", "sysinfo", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-http", "tauri-plugin-opener", "tauri-plugin-process", + "tauri-plugin-single-instance", "tauri-plugin-store", "tauri-plugin-updater", "tauri-plugin-window-state", @@ -1608,7 +1260,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", - "subtle", ] [[package]] @@ -1618,7 +1269,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid 0.10.2", + "const-oid", "crypto-common 0.2.2", "ctutils", ] @@ -1690,6 +1341,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1939,12 +1599,6 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" -[[package]] -name = "flagset" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" - [[package]] name = "flate2" version = "1.1.9" @@ -1955,17 +1609,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "flume" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" -dependencies = [ - "futures-core", - "futures-sink", - "spin", -] - [[package]] name = "fnv" version = "1.0.7" @@ -2020,12 +1663,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - [[package]] name = "futures" version = "0.3.34" @@ -2098,17 +1735,6 @@ dependencies = [ "syn 3.0.4", ] -[[package]] -name = "futures-rustls" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb" -dependencies = [ - "futures-io", - "rustls", - "rustls-pki-types", -] - [[package]] name = "futures-sink" version = "0.3.34" @@ -2292,7 +1918,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", - "rand_core 0.10.1", + "rand_core", "wasm-bindgen", ] @@ -2500,6 +2126,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -2539,92 +2171,13 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hickory-net" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" -dependencies = [ - "async-trait", - "cfg-if", - "data-encoding", - "futures-channel", - "futures-io", - "futures-util", - "hickory-proto", - "idna", - "ipnet", - "jni 0.22.4", - "rand 0.10.2", - "thiserror 2.0.20", - "tinyvec", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "hickory-proto" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" -dependencies = [ - "data-encoding", - "idna", - "ipnet", - "jni 0.22.4", - "once_cell", - "prefix-trie", - "rand 0.10.2", - "ring", - "thiserror 2.0.20", - "tinyvec", - "tracing", - "url", -] - -[[package]] -name = "hickory-resolver" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" -dependencies = [ - "cfg-if", - "futures-util", - "hickory-net", - "hickory-proto", - "ipconfig", - "ipnet", - "jni 0.22.4", - "moka", - "ndk-context", - "once_cell", - "parking_lot", - "rand 0.10.2", - "resolv-conf", - "smallvec", - "system-configuration", - "thiserror 2.0.20", - "tokio", - "tracing", -] - [[package]] name = "hkdf" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac 0.13.0", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", + "hmac", ] [[package]] @@ -2733,20 +2286,6 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-named-pipe" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fab3637d6b04a8037af8a266fdf6cf92ea957e8c53981a2bf6136572531025bf" -dependencies = [ - "hex", - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - [[package]] name = "hyper-rustls" version = "0.27.9" @@ -2785,22 +2324,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry", -] - -[[package]] -name = "hyperlocal" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" -dependencies = [ - "hex", - "http-body-util", - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", + "windows-registry 0.6.1", ] [[package]] @@ -2993,53 +2517,21 @@ dependencies = [ "cfb", ] -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding 0.3.3", - "generic-array", -] - [[package]] name = "inout" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "block-padding 0.4.2", + "block-padding", "hybrid-array", ] -[[package]] -name = "integer-encoding" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c00403deb17c3221a1fe4fb571b9ed0370b3dcd116553c77fa294a3d918699" - -[[package]] -name = "ipconfig" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" -dependencies = [ - "socket2", - "widestring", - "windows-registry", - "windows-result 0.4.1", - "windows-sys 0.61.2", -] - [[package]] name = "ipnet" version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" -dependencies = [ - "serde", -] [[package]] name = "is-docker" @@ -3225,16 +2717,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - [[package]] name = "js-sys" version = "0.3.104" @@ -3300,30 +2782,6 @@ dependencies = [ "log", ] -[[package]] -name = "lapin" -version = "4.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fd20e01fd92597ca352ca7ceed3c589851ebad279dfcada48aa4d24fd3a7caa" -dependencies = [ - "amq-protocol", - "async-rs", - "async-trait", - "backon", - "cfg-if", - "event-listener", - "flume", - "futures-core", - "futures-io", - "tracing", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libappindicator" version = "0.9.0" @@ -3442,25 +2900,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lz4" -version = "1.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" -dependencies = [ - "lz4-sys", -] - -[[package]] -name = "lz4-sys" -version = "1.11.1+lz4-1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" -dependencies = [ - "cc", - "libc", -] - [[package]] name = "markup5ever" version = "0.38.0" @@ -3542,23 +2981,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "moka" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" -dependencies = [ - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "parking_lot", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", -] - [[package]] name = "moxcms" version = "0.8.1" @@ -3605,12 +3027,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -3722,6 +3138,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" +dependencies = [ + "spin", +] + [[package]] name = "nom" version = "7.1.3" @@ -3756,7 +3181,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ - "num-bigint 0.4.8", + "num-bigint", "num-complex", "num-integer", "num-iter", @@ -3774,16 +3199,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-bigint" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-complex" version = "0.4.6" @@ -3835,7 +3250,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "num-bigint 0.4.8", + "num-bigint", "num-integer", "num-traits", ] @@ -4101,22 +3516,12 @@ dependencies = [ "objc2-foundation", ] -[[package]] -name = "oid-registry" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" -dependencies = [ - "asn1-rs", -] - [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" dependencies = [ - "critical-section", "portable-atomic", ] @@ -4143,6 +3548,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -4177,29 +3592,6 @@ dependencies = [ "thiserror 2.0.20", ] -[[package]] -name = "p12-keystore" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffb9bf5222606eb712d3bb30e01bc9420545b00859970897e70c682353a034f2" -dependencies = [ - "base64 0.22.1", - "cbc 0.1.2", - "cms", - "der", - "des", - "hex", - "hmac 0.12.1", - "pkcs12", - "pkcs5", - "rand 0.10.2", - "rc2", - "sha1 0.10.7", - "sha2 0.10.9", - "thiserror 2.0.20", - "x509-parser", -] - [[package]] name = "pango" version = "0.18.3" @@ -4266,25 +3658,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest 0.10.7", - "hmac 0.12.1", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -4372,36 +3745,6 @@ dependencies = [ "futures-io", ] -[[package]] -name = "pkcs12" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "695b3df3d3cc1015f12d70235e35b6b79befc5fa7a9b95b951eab1dd07c9efc2" -dependencies = [ - "cms", - "const-oid 0.9.6", - "der", - "digest 0.10.7", - "spki", - "x509-cert", - "zeroize", -] - -[[package]] -name = "pkcs5" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e847e2c91a18bfa887dd028ec33f2fe6f25db77db3619024764914affe8b69a6" -dependencies = [ - "aes 0.8.4", - "cbc 0.1.2", - "der", - "pbkdf2", - "scrypt", - "sha2 0.10.9", - "spki", -] - [[package]] name = "pkg-config" version = "0.3.34" @@ -4502,32 +3845,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "precomputed-hash" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" -[[package]] -name = "prefix-trie" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" -dependencies = [ - "either", - "ipnet", - "num-traits", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -4666,7 +3989,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand", "rand_pcg", "ring", "rustc-hash", @@ -4714,17 +4037,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -dependencies = [ - "libc", - "rand_chacha", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.10.2" @@ -4733,26 +4045,7 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", + "rand_core", ] [[package]] @@ -4767,7 +4060,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -4776,43 +4069,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" -[[package]] -name = "rc2" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62c64daa8e9438b84aaae55010a93f396f8e60e3911590fcba770d04643fc1dd" -dependencies = [ - "cipher 0.4.4", -] - -[[package]] -name = "redis" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e37a4ca5c6ca42aa3e6df2fd32b987a65d32a4c2159a6f3fe0fd1df306a2658f" -dependencies = [ - "arcstr", - "async-lock", - "bytes", - "cfg-if", - "combine", - "futures-util", - "itoa", - "num-bigint 0.5.1", - "percent-encoding", - "pin-project-lite", - "rustls", - "rustls-native-certs", - "ryu", - "sha1_smol", - "socket2", - "tokio", - "tokio-rustls", - "tokio-util", - "url", - "xxhash-rust", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -4964,12 +4220,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "resolv-conf" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" - [[package]] name = "rfd" version = "0.16.0" @@ -4996,12 +4246,13 @@ dependencies = [ [[package]] name = "rhai" -version = "1.26.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6e1be9d697d537ce450766df42ad6adf1a93f25f21d73217354ad7e3d3dae1" +checksum = "0334639972c0ea5a3fd366aa36116754a11431b619fec3ed559b3f73bcbcebf5" dependencies = [ "ahash", "bitflags 2.13.1", + "no-std-compat", "num-traits", "once_cell", "rhai_codegen", @@ -5038,14 +4289,14 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.8.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" +checksum = "b23c62fe489ac1d401ab32688cfacac3737a8978dc3343e5361464c7724fd3cb" dependencies = [ - "async-trait", - "base64 0.22.1", + "base64 0.23.1", "chrono", "futures", + "indexmap 2.14.0", "pastey", "pin-project-lite", "rmcp-macros", @@ -5056,62 +4307,30 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "uuid", ] [[package]] name = "rmcp-macros" -version = "1.8.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5" +checksum = "cd740c45d66ceb87e5579082abc27bd771665e464e9660a17a048c721b2a6025" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "proc-macro2", "quote", "serde_json", - "syn 2.0.119", -] - -[[package]] -name = "rsasl" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed828a88913fd477c73bc3768b05d4b335ee775e29f2397bb59b9a4dd69ffb83" -dependencies = [ - "base64 0.22.1", - "digest 0.10.7", - "hmac 0.12.1", - "pbkdf2", - "rand 0.8.8", - "serde", - "serde_json", - "sha2 0.10.9", - "stringprep", - "thiserror 2.0.20", + "syn 3.0.4", ] [[package]] -name = "rskafka" -version = "0.6.0" +name = "rust-ini" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "849b87417a191e37e16b8893eba8928bbd10e1989e6b57c4f8531549eaa29bdc" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" dependencies = [ - "bytes", - "chrono", - "crc32c", - "flate2", - "futures", - "integer-encoding", - "lz4", - "parking_lot", - "rand 0.8.8", - "rsasl", - "rustls", - "snap", - "thiserror 1.0.69", - "tokio", - "tokio-rustls", - "tracing", - "zstd", + "cfg-if", + "ordered-multimap", ] [[package]] @@ -5129,15 +4348,6 @@ dependencies = [ "semver", ] -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom 7.1.3", -] - [[package]] name = "rustix" version = "1.1.4" @@ -5153,12 +4363,10 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ - "aws-lc-rs", - "log", "once_cell", "ring", "rustls-pki-types", @@ -5167,21 +4375,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-connector" -version = "0.23.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1babecfcc65b139b812e74bcc7f9ec7b4e00db659fd42d99567b7e77f0c714c6" -dependencies = [ - "futures-io", - "futures-rustls", - "log", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "rustls-webpki", -] - [[package]] name = "rustls-native-certs" version = "0.8.4" @@ -5237,7 +4430,6 @@ version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -5255,15 +4447,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "salsa20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" -dependencies = [ - "cipher 0.4.4", -] - [[package]] name = "same-file" version = "1.0.6" @@ -5353,25 +4536,14 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "scrypt" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" -dependencies = [ - "pbkdf2", - "salsa20", - "sha2 0.10.9", -] - [[package]] name = "secret-service" version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" dependencies = [ - "aes 0.9.3", - "cbc 0.2.1", + "aes", + "cbc", "futures-util", "getrandom 0.4.3", "hkdf", @@ -5628,17 +4800,6 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha1" version = "0.11.0" @@ -5650,12 +4811,6 @@ dependencies = [ "digest 0.11.3", ] -[[package]] -name = "sha1_smol" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" - [[package]] name = "sha2" version = "0.10.9" @@ -5751,12 +4906,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "snap" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" - [[package]] name = "socket2" version = "0.6.5" @@ -5817,22 +4966,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spki" -version = "0.7.3" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" [[package]] name = "stable_deref_trait" @@ -5870,17 +5006,6 @@ dependencies = [ "quote", ] -[[package]] -name = "stringprep" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" -dependencies = [ - "unicode-bidi", - "unicode-normalization", - "unicode-properties", -] - [[package]] name = "strsim" version = "0.11.1" @@ -6006,12 +5131,6 @@ dependencies = [ "version-compare", ] -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - [[package]] name = "tao" version = "0.35.3" @@ -6224,6 +5343,27 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d489b8ecceae1cd09f6e1f7606f2095ac721cc8d54cf2f0e6bb377cc52cff6" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.20", + "tracing", + "url", + "windows-registry 0.5.3", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.7.3" @@ -6322,6 +5462,23 @@ dependencies = [ "tauri-plugin", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0cb5c412a5071b69bab6a6df1583cbb89460d4a83b6a24769b08d15b6b1e1" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin-deep-link", + "thiserror 2.0.20", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-store" version = "2.4.4" @@ -6486,19 +5643,6 @@ dependencies = [ "toml 1.1.4+spec-1.1.0", ] -[[package]] -name = "tcp-stream" -version = "0.34.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300d0735de48a565461c2ea14cc75b80ee5b6be3b4f5aeabe3553e4c2df8d23d" -dependencies = [ - "async-rs", - "cfg-if", - "futures-io", - "p12-keystore", - "rustls-connector", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -6683,17 +5827,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-stream" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - [[package]] name = "tokio-util" version = "0.7.19" @@ -7009,33 +6142,12 @@ dependencies = [ "unic-common", ] -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -7091,9 +6203,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.26.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -7441,12 +6553,6 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - [[package]] name = "winapi" version = "0.3.9" @@ -7651,6 +6757,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -8102,34 +7219,6 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" -[[package]] -name = "x509-cert" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" -dependencies = [ - "const-oid 0.9.6", - "der", - "spki", -] - -[[package]] -name = "x509-parser" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" -dependencies = [ - "asn1-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom 7.1.3", - "oid-registry", - "rusticata-macros", - "thiserror 2.0.20", - "time", -] - [[package]] name = "xattr" version = "1.6.1" @@ -8140,12 +7229,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "xxhash-rust" -version = "0.8.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" - [[package]] name = "yoke" version = "0.8.3" @@ -8348,34 +7431,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "zune-core" version = "0.5.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ead789dd..b08c0e83 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,13 +23,20 @@ tauri-plugin-updater = "2" tauri-plugin-http = "2" tauri-plugin-store = "2" tauri-plugin-window-state = "2" +# desktop-devtool-app://install deep link — trang plugin (starlight-site) mở app rồi đưa +# thẳng tới màn xác nhận cài đặt của SettingsExtensionInstaller thay vì bắt +# người dùng tự copy/dán URL manifest. `single-instance` phải đứng TRƯỚC +# `deep-link` trong .plugin() chain bên main.rs (yêu cầu của chính plugin +# single-instance) — feature "deep-link" của nó tự chuyển tiếp URL sang app +# đã mở sẵn, để một cú bấm thứ hai không mở thêm cửa sổ mới. +tauri-plugin-deep-link = "2" +tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.11" sha1 = "0.11" md-5 = "0.11" digest = "0.11" -rskafka = { version = "0.6", features = ["transport-tls"] } tokio = { version = "1", features = ["full"] } uuid = { version = "1", features = ["v4"] } chrono = "0.4" @@ -39,22 +46,13 @@ hostname = "0.4" netstat2 = "0.11" sysinfo = "0.39" axum = "0.8" -rhai = "1" +# "sync" — Engine dùng Arc nội bộ thay vì Rc, đổi lại Send + +# Sync: bắt buộc để build MỘT Engine dùng chung qua mọi request (mockserver.rs's +# `ENGINE`, thay vì dựng lại — tốn cấu hình 6 giới hạn (max_operations, +# max_expr_depths…) — cho MỖI request script-mode). +rhai = { version = "1", features = ["sync"] } regex = "1" -# lapin 4 bundles its own async runtime (via async-rs) and is selected by feature -# (default-runtime = tokio), so the old external tokio-executor-trait / -# tokio-reactor-trait glue is gone — which also removes the reactor-trait version -# skew that broke the Windows build. Default features (rustls + tokio) cover our -# TLS + tokio usage. -lapin = "4" -futures-util = "0.3" -percent-encoding = "2" base64 = "0.23" -redis = { version = "1.6.0", features = ["tokio-comp", "tls-rustls", "tokio-rustls-comp"] } -# Docker Engine API client (async, over Unix socket / Windows named pipe). Talks -# to any daemon exposing a docker-compatible socket: Docker Desktop, Colima -# (docker runtime), Rancher Desktop (moby), OrbStack, Podman (docker-compat). -bollard = "0.21" # Official Rust MCP SDK — only used by the devtool-mcp-server bin target # (src/bin/devtool-mcp-server.rs), never by the main app itself. "transport-io" # adds the stdio transport on top of the default feature set. Pinned >=1.4: @@ -62,7 +60,7 @@ bollard = "0.21" # rmcp's Streamable HTTP server transport) — unreachable here since this bin # only ever uses the stdio transport, but GitHub's dependency review flags # the crate version regardless of which transport feature is enabled. -rmcp = { version = "1.4", features = ["transport-io"] } +rmcp = { version = "3.3", features = ["transport-io"] } # Kho bí mật (src/secrets_vault.rs). keyring giữ KHOÁ mã hoá trong keychain của # OS; mặc định của crate đã bật cửa hàng của Windows và secret-service của # Linux, còn macOS phải bật tay — thiếu nó, bản macOS sẽ âm thầm rơi về đường @@ -82,19 +80,15 @@ getrandom = { version = "0.4", features = ["std"] } # kết quả (đường dẫn cục bộ, hash). reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } -[target.'cfg(unix)'.dependencies] -# Direct handles onto crates bollard already pulls in transitively (its own -# "pipe" feature depends on exactly these, at these versions — see -# container_tool.rs's `volume_sizes`) so we can issue one raw request over the -# same Unix socket `bollard::Docker` connects with. Needed because bollard's -# typed `SystemDataUsageResponse` model (this version) only exposes the -# aggregate df counters, not the per-volume `UsageData.Size` the raw JSON -# response actually carries. -hyperlocal = "0.9" -hyper = "1" -hyper-util = { version = "0.1", features = ["client-legacy", "tokio"] } -http-body-util = "0.1" -bytes = "1" +[dev-dependencies] +# Chỉ dùng trong test (artifact_installer.rs) để dựng một body chunked KHÔNG +# khai Content-Length trước — mô phỏng đúng "server nói dối/bỏ qua header đó" +# mà `download_capped` phải tự đếm byte để bắt. Khác `axum`/`uuid` ở +# [dependencies] bên trên (chúng phục vụ cả module production thật — +# mockserver.rs/service_host.rs/mcp_bridge.rs chạy server axum thật, không chỉ +# test), `futures-util` không có nơi nào khác dùng ngoài test module này nên +# thuộc đúng [dev-dependencies]. +futures-util = { version = "0.3", default-features = false, features = ["std"] } [features] default = ["custom-protocol"] @@ -103,7 +97,7 @@ custom-protocol = ["tauri/custom-protocol"] # Thin LTO + parallel codegen units trade a little binary size/runtime perf # for much faster release builds (fat LTO + codegen-units=1 serializes all # codegen onto a single thread, which is very slow with heavy async deps -# like tokio/rskafka/axum/lapin in the dependency tree). +# like tokio/axum/rmcp in the dependency tree). [profile.release] opt-level = "z" lto = "thin" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 88707328..9a5b00b1 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -19,6 +19,7 @@ "fs:allow-write-text-file", "fs:scope-appdata-recursive", "process:allow-restart", + "deep-link:default", "updater:allow-check", "updater:allow-download-and-install", "store:default", diff --git a/src-tauri/src/artifact_installer.rs b/src-tauri/src/artifact_installer.rs index ec4fbaf3..4ab35bbe 100644 --- a/src-tauri/src/artifact_installer.rs +++ b/src-tauri/src/artifact_installer.rs @@ -48,6 +48,46 @@ use tauri::{AppHandle, Manager}; use crate::checksum::to_hex; +// --------------------------------------------------------------------------- +// Tải xuống có giới hạn kích thước +// --------------------------------------------------------------------------- + +/// Trần cho MỘT lần tải artifact (bundle JS hay binary service). Rộng rãi cho +/// mọi sidecar thật (vài chục MB), nhưng vẫn hữu hạn: không có trần, một host +/// bị chiếm/MITM (đúng rủi ro RANH GIỚI TIN CẬY ở đầu file) có thể trả về một +/// body tuỳ ý lớn và làm app hết bộ nhớ TRƯỚC KHI checksum kịp từ chối nó — +/// `files.rs`'s `read_file_data_url` đã áp cùng nguyên tắc (`MAX_BYTES`) cho +/// input do người dùng chọn; artifact tải qua mạng xứng đáng cùng mức cảnh +/// giác, có phần hơn vì nguồn ở xa chứ không phải đĩa cục bộ của chính máy. +const MAX_DOWNLOAD_BYTES: u64 = 512 * 1024 * 1024; + +/// Tải `url`, từ chối sớm nếu `Content-Length` đã vượt `max_bytes`, và ĐẾM +/// DẦN khi đọc từng chunk (một server có thể nói dối hoặc bỏ qua header đó) — +/// không đợi buffer hết toàn bộ body rồi mới kiểm tra kích thước. Nhận +/// `max_bytes` làm tham số (thay vì đọc thẳng hằng số) để test được với một +/// trần nhỏ, không phải tải thật hàng trăm MB chỉ để kiểm logic từ chối. +async fn download_capped(url: &str, max_bytes: u64) -> Result, String> { + let res = reqwest::get(url).await.map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("Server trả về {}", res.status())); + } + if let Some(len) = res.content_length() { + if len > max_bytes { + return Err(format!("File quá lớn ({len} byte; trần {max_bytes} byte) — từ chối tải.")); + } + } + + let mut res = res; + let mut buf: Vec = Vec::new(); + while let Some(chunk) = res.chunk().await.map_err(|e| e.to_string())? { + if buf.len() as u64 + chunk.len() as u64 > max_bytes { + return Err(format!("File vượt trần {max_bytes} byte giữa lúc tải — từ chối, dừng tải tiếp.")); + } + buf.extend_from_slice(&chunk); + } + Ok(buf) +} + // --------------------------------------------------------------------------- // Manifest thô lấy từ URL người dùng cung cấp // --------------------------------------------------------------------------- @@ -79,6 +119,24 @@ pub struct RemotePluginManifest { pub commands: Vec, #[serde(default)] pub hosts: Vec, + /// Tier B — sidecar plugin này gọi tới, khi `permissions` khai `'service'`. + /// Bổ sung so với Phase 1 (nhánh plugin external-install từng chỉ hỗ trợ + /// Tier A) — thiếu trường này, phía TS (`validateManifest()`) từ chối mọi + /// plugin cài từ URL có khai `'service'` vì mismatch giữa `permissions` và + /// `service`. `bin` ở đây vẫn phải nằm trong `ALLOWED_SERVICES` + /// (service_host.rs) mới chạy được — trường này chỉ đi kèm allowlist + /// METHOD, không tự cấp quyền chạy một bin mới. + #[serde(default)] + pub service: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RemoteServiceDescriptor { + pub bin: String, + pub methods: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, } /// Một target khả dụng của một service — một địa chỉ tải kèm checksum riêng @@ -292,9 +350,18 @@ fn read_index(dir: &Path) -> Result, String> { } } +/// Ghi ATOMIC (`.tmp` cùng thư mục đích rồi `rename`) — cùng khuôn với binary +/// service bên dưới (`stage_install_service`). Trước đây dùng `std::fs::write` +/// thẳng vào `index.json`: một crash/mất điện giữa lúc ghi (dữ liệu KHÔNG +/// nhỏ — mọi plugin/service đã cài đều nằm chung một file) để lại file nửa +/// vời mà `read_index` chỉ có thể báo "index.json hỏng", buộc người dùng tự +/// sửa tay hoặc mất sạch danh sách đã cài. fn write_index(dir: &Path, records: &[InstalledArtifactRecord]) -> Result<(), String> { let json = serde_json::to_string_pretty(records).map_err(|e| e.to_string())?; - std::fs::write(index_path(dir), json).map_err(|e| e.to_string()) + let final_path = index_path(dir); + let tmp_path = dir.join("index.json.tmp"); + std::fs::write(&tmp_path, json).map_err(|e| e.to_string())?; + std::fs::rename(&tmp_path, &final_path).map_err(|e| e.to_string()) } fn sha256_hex(bytes: &[u8]) -> String { @@ -376,9 +443,19 @@ fn stage_install_plugin( let plugin_dir = plugins_dir.join(&manifest.id).join(&manifest.version); std::fs::create_dir_all(&plugin_dir).map_err(|e| e.to_string())?; let bundle_path = plugin_dir.join("bundle.mjs"); - std::fs::write(&bundle_path, bytes).map_err(|e| e.to_string())?; + // Atomic (tmp + rename) — cùng lý do khuôn này đã áp cho binary service: + // `artifact_installer_read_bundle` không giữ khoá `InstalledIndex` khi đọc + // (chỉ đọc, không sửa index.json), nên một lần cài đè (cùng id, khác + // version trung gian) đang ghi `bundle.mjs` có thể trùng lúc frontend gọi + // đọc lại bundle cũ. Checksum ở `stage_read_bundle` đã bắt được trường hợp + // đọc trúng file dở dang (trả lỗi thay vì nạp JS hỏng), nhưng ghi atomic + // loại bỏ hẳn khả năng đó thay vì chỉ phát hiện sau. + let tmp_path = plugin_dir.join("bundle.mjs.tmp"); + std::fs::write(&tmp_path, bytes).map_err(|e| e.to_string())?; + std::fs::rename(&tmp_path, &bundle_path).map_err(|e| e.to_string())?; let id = manifest.id.clone(); + let new_version = manifest.version.clone(); let record = InstalledArtifactRecord::Plugin(InstalledPluginRecord { manifest, source_url, @@ -389,11 +466,29 @@ fn stage_install_plugin( // Cài đè lên chính plugin đó (cùng id) thì thay hẳn bản ghi cũ — không giữ // lại hai bản ghi cho cùng một id, kể cả khác version. Chỉ lọc trong các // bản ghi PLUGIN — không đụng bản ghi service nào dù trùng chuỗi khoá. + let mut old_version: Option = None; let mut records = read_index(index_dir)?; - records.retain(|r| !matches!(r, InstalledArtifactRecord::Plugin(p) if p.manifest.id == id)); + records.retain(|r| match r { + InstalledArtifactRecord::Plugin(p) if p.manifest.id == id => { + old_version = Some(p.manifest.version.clone()); + false + } + _ => true, + }); records.push(record.clone()); write_index(index_dir, &records)?; + // Dọn thư mục version CŨ sau khi index đã ghi thành công — một update + // (khác version) để lại `plugins///` không còn bản ghi + // nào trỏ tới, mồ côi vĩnh viễn nếu không dọn (khác uninstall, vốn đã xoá + // cả `plugins//` — ở đây `//` vừa ghi phải giữ lại). + // Lỗi xoá (Windows khoá file, quyền đĩa…) không chặn việc cài — best-effort. + if let Some(old_version) = old_version { + if old_version != new_version { + let _ = std::fs::remove_dir_all(plugins_dir.join(&id).join(&old_version)); + } + } + Ok(record) } @@ -466,6 +561,7 @@ fn stage_install_service( } let bin = manifest.bin.clone(); + let new_version = manifest.version.clone(); let record = InstalledArtifactRecord::Service(InstalledServiceRecord { manifest, source_url, @@ -475,11 +571,27 @@ fn stage_install_service( // Cài đè cùng bin thì thay hẳn bản ghi cũ — theo quyết định đã chốt trong // plan (không giữ song song hai version; rollback = xoá cài lại thủ công). + let mut old_version: Option = None; let mut records = read_index(index_dir)?; - records.retain(|r| !matches!(r, InstalledArtifactRecord::Service(s) if s.manifest.bin == bin)); + records.retain(|r| match r { + InstalledArtifactRecord::Service(s) if s.manifest.bin == bin => { + old_version = Some(s.manifest.version.clone()); + false + } + _ => true, + }); records.push(record.clone()); write_index(index_dir, &records)?; + // Dọn thư mục version CŨ — cùng lý do nhánh plugin ở trên. Sidecar cũ đã bị + // `service_stop` ở lớp gọi (`artifact_installer_install`) trước khi hàm + // này chạy xong, nên tới đây binary cũ không còn tiến trình nào giữ file. + if let Some(old_version) = old_version { + if old_version != new_version { + let _ = std::fs::remove_dir_all(services_dir.join(&bin).join(&old_version)); + } + } + Ok(record) } @@ -655,13 +767,9 @@ pub async fn artifact_installer_install( let record = match manifest { RemoteArtifactManifest::Plugin(manifest) => { - let res = reqwest::get(&manifest.entry) + let bytes = download_capped(&manifest.entry, MAX_DOWNLOAD_BYTES) .await .map_err(|e| format!("Không tải được bundle: {e}"))?; - if !res.status().is_success() { - return Err(format!("Server trả về {} khi tải bundle", res.status())); - } - let bytes = res.bytes().await.map_err(|e| e.to_string())?; stage_install_plugin(&index_dir, &plugins_dir(&app)?, manifest, source_url, &bytes)? } RemoteArtifactManifest::Service(manifest) => { @@ -674,28 +782,25 @@ pub async fn artifact_installer_install( available.join(", ") ) })?; - let res = reqwest::get(&target.url) + let bytes = download_capped(&target.url, MAX_DOWNLOAD_BYTES) .await .map_err(|e| format!("Không tải được binary: {e}"))?; - if !res.status().is_success() { - return Err(format!("Server trả về {} khi tải binary", res.status())); - } - let bytes = res.bytes().await.map_err(|e| e.to_string())?; let bin = manifest.bin.clone(); - let record = stage_install_service( + // Dừng sidecar ĐANG CHẠY (nếu có) TRƯỚC khi ghi/dọn đĩa — hai lý + // do: (1) self-healing, lần `service_call` kế tiếp tự spawn lại + // đúng version mới; (2) trên Windows, xoá thư mục version cũ + // (bên trong `stage_install_service`) trong khi tiến trình cũ vẫn + // giữ file binary sẽ thất bại lặng lẽ (best-effort) — dừng trước + // đảm bảo cleanup thật sự dọn được, không chỉ udpate index. + let _ = crate::service_host::service_stop(services.clone(), bin).await; + stage_install_service( &index_dir, &services_dir(&app)?, manifest, source_url, &triple, &bytes, - )?; - // Cài đè một sidecar ĐANG CHẠY: dừng ngay để lần `service_call` kế - // tiếp tự spawn lại đúng version mới (hành vi self-healing sẵn có - // của `get_or_spawn`) — không làm việc này thì tiến trình cũ vẫn - // sống và phục vụ version cũ cho tới khi app restart. - let _ = crate::service_host::service_stop(services.clone(), bin).await; - record + )? } }; @@ -703,7 +808,17 @@ pub async fn artifact_installer_install( } #[tauri::command] -pub fn artifact_installer_list(app: AppHandle) -> Result, String> { +pub async fn artifact_installer_list( + app: AppHandle, + index_state: tauri::State<'_, InstalledIndex>, +) -> Result, String> { + // Cùng khoá install/uninstall giữ — `write_index` giờ atomic (tmp+rename) + // nên `read_index` không bao giờ thấy file NỬA VỜI nữa, nhưng không khoá ở + // đây thì một `list` chạy giữa lúc install/uninstall đang xử lý (nhiều + // bước ghi đĩa khác ngoài index.json — copy bundle, dừng sidecar…) vẫn có + // thể đọc trúng index.json của bước TRUNG GIAN thay vì trạng thái cuối + // cùng nhất quán. + let _guard = index_state.0.lock().await; read_index(&extensions_dir(&app)?) } @@ -756,6 +871,7 @@ mod tests { permissions: vec![], commands: vec![], hosts: vec![], + service: None, } } @@ -965,6 +1081,14 @@ mod tests { InstalledArtifactRecord::Plugin(p) => assert_eq!(p.manifest.version, "2.0.0"), other => panic!("{other:?}"), } + assert!( + !plugins_dir.join("demo").join("1.0.0").exists(), + "thư mục version CŨ phải bị dọn sau khi update, không mồ côi trên đĩa" + ); + assert!( + plugins_dir.join("demo").join("2.0.0").join("bundle.mjs").exists(), + "thư mục version MỚI phải còn nguyên" + ); } #[test] @@ -1100,6 +1224,14 @@ mod tests { InstalledArtifactRecord::Service(s) => assert_eq!(s.manifest.version, "2.0.0"), other => panic!("{other:?}"), } + assert!( + !services_dir.join("devtool-svc-demo").join("1.0.0").exists(), + "thư mục version CŨ phải bị dọn sau khi update, không mồ côi trên đĩa" + ); + assert!( + services_dir.join("devtool-svc-demo").join("2.0.0").exists(), + "thư mục version MỚI phải còn nguyên" + ); } #[test] @@ -1202,4 +1334,66 @@ mod tests { || triple.ends_with("unknown-linux-gnu"); assert!(os_ok, "{triple}"); } + + // -- write_index: ghi atomic, không để lại .tmp mồ côi ---------------------- + + #[test] + fn write_index_khong_de_lai_tmp_mo_coi_sau_khi_ghi_xong() { + let dir = temp_dir("write-index-atomic"); + let plugin = InstalledArtifactRecord::Plugin(InstalledPluginRecord { + manifest: plugin_manifest("https://x/a.mjs", &sha256_hex(b"a")), + source_url: "https://x/a.json".into(), + bundle_path: "/app-data/plugins/demo/1.0.0/bundle.mjs".into(), + installed_at: 1, + }); + write_index(&dir, &[plugin.clone()]).unwrap(); + + assert!(index_path(&dir).exists()); + assert!(!dir.join("index.json.tmp").exists()); + assert_eq!(read_index(&dir).unwrap(), vec![plugin]); + } + + // -- download_capped: trần kích thước ---------------------------------------- + + /// Server trả `Content-Length` lớn hơn trần → từ chối NGAY, không tải một + /// byte thân bài nào. + #[tokio::test] + async fn download_capped_tu_choi_ngay_khi_content_length_vuot_tran() { + let url = serve_once("x".repeat(1000), "text/plain").await; + let err = download_capped(&url, 100).await.unwrap_err(); + assert!(err.contains("quá lớn"), "{err}"); + } + + /// Server không khai `Content-Length` đúng (hoặc client bỏ qua nó) — vẫn + /// phải bị chặn khi TỔNG byte đã đọc vượt trần, không đợi tải hết mới kiểm. + #[tokio::test] + async fn download_capped_tu_choi_giua_chung_khi_khong_co_content_length_dang_tin() { + let app = Router::new().route( + "/", + get(|| async { + // `StreamBody` để axum không tự set Content-Length từ độ dài + // biết trước — mô phỏng đúng "server nói dối/không khai" mà + // comment của `download_capped` nhắc tới. + let stream = futures_util::stream::iter(vec![Ok::<_, std::io::Error>( + axum::body::Bytes::from("x".repeat(1000)), + )]); + axum::body::Body::from_stream(stream) + }), + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let err = download_capped(&format!("http://{addr}/"), 100).await.unwrap_err(); + assert!(err.contains("vượt trần"), "{err}"); + } + + #[tokio::test] + async fn download_capped_nhan_body_trong_tran() { + let url = serve_once("hello".into(), "text/plain").await; + let bytes = download_capped(&url, 100).await.unwrap(); + assert_eq!(bytes, b"hello"); + } } diff --git a/src-tauri/src/bin/devtool-mcp-server.rs b/src-tauri/src/bin/devtool-mcp-server.rs index e2ac965e..796ca6d9 100644 --- a/src-tauri/src/bin/devtool-mcp-server.rs +++ b/src-tauri/src/bin/devtool-mcp-server.rs @@ -6,18 +6,25 @@ // It never runs inside the app itself. The app's Tauri backend // (mcp_bridge.rs) starts a small loopback HTTP control server on launch and // writes its port + auth token to `/mcp-bridge.json` for this -// process to read. Every MCP tool call this process receives over stdio is -// forwarded there as a `POST /call`, which the app hands to the running -// webview to answer — so an API Client tool call (list_collections, -// get_request, etc.) only succeeds while DevTool is open, and, by default, -// with the API Client tool on screen (a `mock_*` call needs the Mock Server -// tool on screen the same way) — unless the user has turned on Settings → -// MCP → Background MCP bridge, which drops that "on screen" requirement -// entirely (get_scripting_reference is the one exception either way — -// answered locally, see its own comment below). See -// src-tauri/src/mcp_bridge.rs for the full design, and -// src/components/tools/apiclient/mcpBridge.ts / -// src/components/tools/mockserver/mcpBridge.ts for what each tool actually does. +// process to read. +// +// This process has NO compiled-in tool catalogue — every tool it advertises +// is fetched at query time from `GET /tools` on that bridge (see +// `fetch_registered_tools()` below), which is whatever tools' own frontend +// bridges have registered themselves (`mcp_register_tools`) since the app +// last launched: API Client and Mock Server register from inside the main +// app, Kafka Explorer from its own plugin, and any Tier-B plugin installed +// from developer-desktop-util-plugin (Redis/RabbitMQ/Container Manager, or +// a future one) registers the same way once its own bridge mounts. A tool +// call is forwarded as a `POST /call`, which the app hands to the running +// webview to answer — so it only succeeds while DevTool is open and, by +// default, with that specific tool's screen open, unless the user has +// turned on Settings → MCP → Background MCP bridge, which drops that +// "on screen" requirement entirely (get_scripting_reference is the one +// exception either way — answered locally, see its own comment below). See +// src-tauri/src/mcp_bridge.rs for the full design, and any tool's own +// `mcpBridge.ts` for what it actually does and which tool schemas it +// registers. // // Built on rmcp (the official Rust MCP SDK) for the protocol itself — // JSON-RPC framing, capability negotiation, tool routing all come from the @@ -29,14 +36,11 @@ use std::future::Future; use std::path::PathBuf; -use std::pin::Pin; use std::time::Duration; -use rmcp::handler::server::router::tool::{ToolRoute, ToolRouter}; -use rmcp::handler::server::tool::ToolCallContext; use rmcp::model::{ - CallToolRequestParams, CallToolResult, Content, ErrorData as McpError, Implementation, - ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, + CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, ErrorData as McpError, + Implementation, ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerConfig, Tool, }; use rmcp::service::RequestContext; use rmcp::transport::io::stdio; @@ -125,17 +129,38 @@ fn dechunk(input: &[u8]) -> Vec { async fn http_post_json(port: u16, token: &str, body: &Value) -> Result { let payload = serde_json::to_vec(body).map_err(|e| e.to_string())?; + let request = format!( + "POST /call HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nContent-Type: application/json\r\nAuthorization: Bearer {token}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + payload.len(), + ); + let json = http_request(port, request.as_bytes(), Some(&payload)).await?; + if let Some(err) = json.get("error").and_then(|e| e.as_str()) { + return Err(err.to_string()); + } + Ok(json.get("result").cloned().unwrap_or(Value::Null)) +} + +// GET counterpart of `http_post_json` — no request body, and the response is +// the plain JSON value itself rather than a `{result}`/`{error}` envelope +// (used by `fetch_registered_tools()` to read the dynamic tool catalogue from +// `GET /tools`, which returns a bare JSON array). +async fn http_get_json(port: u16, token: &str, path: &str) -> Result { + let request = format!( + "GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Bearer {token}\r\nConnection: close\r\n\r\n", + ); + http_request(port, request.as_bytes(), None).await +} + +async fn http_request(port: u16, head: &[u8], body: Option<&[u8]>) -> Result { let mut stream = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(("127.0.0.1", port))) .await .map_err(|_| format!("Timed out connecting to DevTool on 127.0.0.1:{port}."))? .map_err(|e| format!("Could not reach DevTool on 127.0.0.1:{port} ({e}). Is the app open?"))?; - let request = format!( - "POST /call HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nContent-Type: application/json\r\nAuthorization: Bearer {token}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - payload.len(), - ); - stream.write_all(request.as_bytes()).await.map_err(|e| e.to_string())?; - stream.write_all(&payload).await.map_err(|e| e.to_string())?; + stream.write_all(head).await.map_err(|e| e.to_string())?; + if let Some(payload) = body { + stream.write_all(payload).await.map_err(|e| e.to_string())?; + } let mut raw = Vec::new(); tokio::time::timeout(CALL_TIMEOUT, stream.read_to_end(&mut raw)) @@ -177,10 +202,7 @@ async fn http_post_json(port: u16, token: &str, body: &Value) -> Result Result { @@ -397,6 +419,7 @@ notFoundStatus, notFoundBody, notFoundContentType (the fallback response for when no stub matches — mock_set_fallback). "#; + async fn call_tool(name: &str, args: Value) -> CallToolResult { match call_bridge(name, args).await { // Compact, not pretty-printed: indentation whitespace is pure token @@ -404,967 +427,81 @@ async fn call_tool(name: &str, args: Value) -> CallToolResult { // fine compact for a model. Ok(v) => { let text = serde_json::to_string(&v).unwrap_or_else(|_| v.to_string()); - CallToolResult::success(vec![Content::text(text)]) + CallToolResult::success(vec![ContentBlock::text(text)]) } - Err(e) => CallToolResult::error(vec![Content::text(e)]), + Err(e) => CallToolResult::error(vec![ContentBlock::text(e)]), } } -// ── tool catalogue ─────────────────────────────────────────────────────── -// Kept in sync by hand with the actual handlers in -// src/components/tools/apiclient/mcpBridge.ts and -// src/components/tools/mockserver/mcpBridge.ts. - -fn kv_array_schema() -> Value { - json!({ - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { "type": "string" }, - "key": { "type": "string" }, - "value": { "type": "string" }, - "enabled": { "type": "boolean" } - }, - "required": ["id", "key", "value", "enabled"] - } - }) +// ── dynamic tool catalogue ─────────────────────────────────────────────── +// This process has NO compiled-in knowledge of what tools exist. Every tool +// other than `get_scripting_reference` (below, answered locally since it's +// static reference content) is contributed at RUNTIME by whichever plugin +// bundles it: each tool's own frontend bridge (e.g. +// src/components/tools/apiclient/mcpBridge.ts, +// src/components/tools/kafka/mcpBridge.ts, or a plugin installed from +// developer-desktop-util-plugin) registers its own name/description/ +// inputSchema with the running app's `mcp_bridge.rs` registry +// (`mcp_register_tools`) once on mount. `list_tools()` fetches the CURRENT +// registered set on every call — not once at process startup — so a plugin +// installed (or a tool whose bridge just mounted) after this process +// started still shows up without restarting it. +async fn fetch_registered_tools() -> Result, String> { + let info = read_bridge_info()?; + let json = http_get_json(info.port, &info.token, "/tools").await?; + let entries = json.as_array().cloned().unwrap_or_default(); + Ok(entries + .into_iter() + .filter_map(|def| { + let name = def.get("name")?.as_str()?.to_string(); + let description = def.get("description").and_then(Value::as_str).unwrap_or("").to_string(); + let schema = match def.get("inputSchema").cloned().unwrap_or_else(|| json!({})) { + Value::Object(m) => m, + _ => Default::default(), + }; + Some(Tool::new(name, description, schema)) + }) + .collect()) } -fn nullable_string() -> Value { - json!({ "type": ["string", "null"] }) +// `get_scripting_reference`'s own catalogue entry — the one tool this +// process still answers locally (see `SCRIPTING_REFERENCE` above for why), +// so it must still show up in `list_tools()` even when the bridge is +// unreachable (app closed). +fn scripting_reference_tool() -> Tool { + Tool::new( + "get_scripting_reference", + "API Client pre/post-request scripting API (dt/req/res/pm/expect/assert) and Mock \ + Server response-script API (Rhai), plus the field shapes every patch-style tool in \ + both takes (KeyValue, RequestBody, Auth, RequestSettings, Stub, Matcher, ...). \ + Static reference — always answers, no running app or open tool required.", + json!({ "type": "object", "properties": {} }).as_object().cloned().unwrap_or_default(), + ) } -fn tool_definitions() -> Vec { - let where_schema = json!({ "type": "string", "enum": ["before", "after", "inside"], "default": "inside" }); - - vec![ - json!({ - "name": "list_collections", - "description": "List every collection open in DevTool's API Client, with their folder/request tree (id, name, method, url — no bodies/scripts/secrets).", - "inputSchema": { "type": "object", "properties": {} } - }), - json!({ - "name": "get_collection", - "description": "Get one collection by id, including its own script/auth/headers/variables (not its requests' bodies — use get_request for those).", - "inputSchema": { "type": "object", "properties": { "collectionId": { "type": "string" } }, "required": ["collectionId"] } - }), - json!({ - "name": "get_request", - "description": "Get the full definition of one request by id: method, url, params, headers, body, auth, pre/post-request script, tests, assertions, settings.", - "inputSchema": { "type": "object", "properties": { "requestId": { "type": "string" } }, "required": ["requestId"] } - }), - json!({ - "name": "update_request", - "description": "Patch a request in place. `patch` is a partial ApiRequest — only included top-level fields change (e.g. { \"url\", \"method\" }). For the nested object fields — script ({req, res}), auth, body, settings — you may pass either the full object or just the part you're changing: patch.script = { \"req\": \"...\" } alone updates only the pre-request script and leaves the post-response script (res) untouched, same for auth.apiKey/.oauth2 and body.graphql. Array fields (params/headers/pathParams/assertions) still fully replace — pass every row you want kept.", - "inputSchema": { - "type": "object", - "properties": { "requestId": { "type": "string" }, "patch": { "type": "object" } }, - "required": ["requestId", "patch"] - } - }), - json!({ - "name": "create_request", - "description": "Create a new request in a collection (optionally inside a folder) and return it. `request` is a partial ApiRequest used as the initial values.", - "inputSchema": { - "type": "object", - "properties": { "collectionId": { "type": "string" }, "folderId": { "type": "string" }, "request": { "type": "object" } }, - "required": ["collectionId"] - } - }), - json!({ - "name": "run_request", - "description": "Send a request through DevTool (same engine as Send): pre-request script → send → post-response script → tests/assertions → History. Returns response (body capped), tests, logs, and any error. Pass environmentId to override the active environment (null = \"No Environment\").", - "inputSchema": { - "type": "object", - "properties": { "requestId": { "type": "string" }, "environmentId": nullable_string() }, - "required": ["requestId"] - } - }), - json!({ - "name": "add_folder", - "description": "Create a folder in a collection (optionally nested inside another folder) and return its id.", - "inputSchema": { - "type": "object", - "properties": { "collectionId": { "type": "string" }, "name": { "type": "string" }, "parentId": { "type": "string" } }, - "required": ["collectionId"] - } - }), - json!({ - "name": "rename_item", - "description": "Rename a request or folder by id.", - "inputSchema": { "type": "object", "properties": { "itemId": { "type": "string" }, "name": { "type": "string" } }, "required": ["itemId", "name"] } - }), - json!({ - "name": "delete_item", - "description": "Delete a request or a folder (and everything inside it) by id.", - "inputSchema": { "type": "object", "properties": { "itemId": { "type": "string" } }, "required": ["itemId"] } - }), - json!({ - "name": "clone_item", - "description": "Duplicate a request or folder as a new sibling right after it.", - "inputSchema": { "type": "object", "properties": { "itemId": { "type": "string" } }, "required": ["itemId"] } - }), - json!({ - "name": "move_item", - "description": "Move (cut) a request or folder to a new spot. `targetId` may be a collection id (moves to its root), or a request/folder id combined with `where`: \"before\"/\"after\" that sibling, or \"inside\" it (folders only).", - "inputSchema": { - "type": "object", - "properties": { "sourceId": { "type": "string" }, "targetId": { "type": "string" }, "where": where_schema.clone() }, - "required": ["sourceId", "targetId"] - } - }), - json!({ - "name": "copy_item", - "description": "Copy (not cut) a request or folder to a new spot — same targeting as move_item, but the source is left in place.", - "inputSchema": { - "type": "object", - "properties": { "sourceId": { "type": "string" }, "targetId": { "type": "string" }, "where": where_schema }, - "required": ["sourceId", "targetId"] - } - }), - json!({ - "name": "add_collection", - "description": "Create a new, empty collection and return its id.", - "inputSchema": { "type": "object", "properties": { "name": { "type": "string" } } } - }), - json!({ - "name": "rename_collection", - "description": "Rename a collection by id.", - "inputSchema": { "type": "object", "properties": { "collectionId": { "type": "string" }, "name": { "type": "string" } }, "required": ["collectionId", "name"] } - }), - json!({ - "name": "delete_collection", - "description": "Delete a collection (and everything inside it) by id. Also drops any environments scoped to it.", - "inputSchema": { "type": "object", "properties": { "collectionId": { "type": "string" } }, "required": ["collectionId"] } - }), - json!({ - "name": "clone_collection", - "description": "Duplicate a whole collection (deep copy, fresh ids for everything inside) right after the original.", - "inputSchema": { "type": "object", "properties": { "collectionId": { "type": "string" } }, "required": ["collectionId"] } - }), - json!({ - "name": "set_collection_variables", - "description": "Replace a collection's Collection Variables (shared defaults available to every request in it, regardless of active environment). Pass the full array you want it to end up with.", - "inputSchema": { - "type": "object", - "properties": { "collectionId": { "type": "string" }, "variables": kv_array_schema() }, - "required": ["collectionId", "variables"] - } - }), - json!({ - "name": "set_node_script", - "description": "Set the pre/post-request script inherited by every request under a collection/folder. nodeId=null (or omitted) = collection root; a folder id = that folder. `script` may be the full { req, res } object or just one of them — e.g. { \"req\": \"...\" } alone updates only the pre-request script and leaves the existing post-response script (res) untouched. A request's own script is set via update_request's patch.script instead.", - "inputSchema": { - "type": "object", - "properties": { - "collectionId": { "type": "string" }, - "nodeId": nullable_string(), - "script": { - "type": "object", - "properties": { "req": { "type": "string" }, "res": { "type": "string" } } - } - }, - "required": ["collectionId", "script"] - } - }), - json!({ - "name": "set_node_auth", - "description": "Set the auth inherited by requests with auth.type=\"inherit\" under a collection/folder. nodeId=null (or omitted) = collection root; a folder id = that folder. `auth` may be the full Auth object or just the fields you're changing (including a nested partial apiKey/oauth2) — merges onto the node's existing auth rather than replacing it outright. A request's own auth is set via update_request's patch.auth instead.", - "inputSchema": { - "type": "object", - "properties": { "collectionId": { "type": "string" }, "nodeId": nullable_string(), "auth": { "type": "object" } }, - "required": ["collectionId", "auth"] - } - }), - json!({ - "name": "set_node_headers", - "description": "Set the headers added to every request under a collection/folder (a request's own header of the same name overrides it). nodeId=null (or omitted) = collection root; a folder id = that folder. A request's own headers are set via update_request's patch.headers instead.", - "inputSchema": { - "type": "object", - "properties": { "collectionId": { "type": "string" }, "nodeId": nullable_string(), "headers": kv_array_schema() }, - "required": ["collectionId", "headers"] - } - }), - json!({ - "name": "list_environments", - "description": "List every environment (global and collection-scoped) with id, name, and owning collectionId (null = global).", - "inputSchema": { "type": "object", "properties": {} } - }), - json!({ - "name": "get_environment", - "description": "Get one environment by id, including its variables (values are returned as stored — a variable marked secret is not masked here, unlike the UI's quick-view).", - "inputSchema": { "type": "object", "properties": { "environmentId": { "type": "string" } }, "required": ["environmentId"] } - }), - json!({ - "name": "update_environment", - "description": "Patch an environment. `patch` is a partial Environment object — { \"variables\": [...] } replaces the ENTIRE variables array (pass every row you want kept, not just changed ones), { \"name\": ... } renames it, { \"collectionId\": ... } moves it between global (null) and a collection's scope. To add/edit/remove one or a few variables without resending the rest, use set_environment_variable / delete_environment_variable instead.", - "inputSchema": { - "type": "object", - "properties": { "environmentId": { "type": "string" }, "patch": { "type": "object" } }, - "required": ["environmentId", "patch"] - } - }), - json!({ - "name": "set_environment_variable", - "description": "Add or update ONE variable in an environment by key, leaving every other variable untouched — cheaper and safer than update_environment when only a few variables need to change. Creates the variable (enabled by default) if no row with that key exists yet; otherwise patches only the fields you pass (value/enabled/secret). Returns the resulting variable row.", - "inputSchema": { - "type": "object", - "properties": { - "environmentId": { "type": "string" }, - "key": { "type": "string" }, - "value": { "type": "string" }, - "enabled": { "type": "boolean" }, - "secret": { "type": "boolean", "description": "Masks the value in the editor and excludes it from generated code/cURL export/history, like the Vault." } - }, - "required": ["environmentId", "key"] - } - }), - json!({ - "name": "delete_environment_variable", - "description": "Remove one variable from an environment by key, leaving every other variable untouched. No-op (deleted:false) if the key isn't present.", - "inputSchema": { - "type": "object", - "properties": { "environmentId": { "type": "string" }, "key": { "type": "string" } }, - "required": ["environmentId", "key"] - } - }), - json!({ - "name": "set_active_environment", - "description": "Activate an environment. scope=\"global\" sets the active Global env; scope=\"collection\" (default) sets it for one collection (pass collectionId). environmentId=null clears it (\"No Environment\").", - "inputSchema": { - "type": "object", - "properties": { - "scope": { "type": "string", "enum": ["global", "collection"], "default": "collection" }, - "collectionId": { "type": "string" }, - "environmentId": nullable_string() - }, - "required": ["environmentId"] - } - }), - json!({ - "name": "add_environment", - "description": "Create a new environment and return its id. Omit collectionId for a global environment (available everywhere); pass one to scope it to that collection (Bruno-style — only available while working inside that collection). A collection can have any number of scoped environments (e.g. \"Local\"/\"Staging\"/\"Prod\"), same as the global list.", - "inputSchema": { - "type": "object", - "properties": { "collectionId": { "type": "string" }, "name": { "type": "string" }, "variables": kv_array_schema() } - } - }), - json!({ - "name": "duplicate_environment", - "description": "Clone an environment (same scope, \" copy\", fresh ids for every variable row) and return the new id.", - "inputSchema": { "type": "object", "properties": { "environmentId": { "type": "string" } }, "required": ["environmentId"] } - }), - json!({ - "name": "delete_environment", - "description": "Delete an environment by id. Clears it from wherever it was the active choice.", - "inputSchema": { "type": "object", "properties": { "environmentId": { "type": "string" } }, "required": ["environmentId"] } - }), - json!({ - "name": "get_scripting_reference", - "description": "Read-only reference for both tools' scripting APIs and field shapes: API Client's dt/req/res/pm JS engine (variable precedence, assertions, Auth/RequestBody/KeyValue shapes) and Mock Server's Rhai response-script engine (a separate language — req shape, return shape, Stub/Matcher shapes). Call before writing/editing a script, auth, assertions, or a stub. Answered locally — works even if DevTool isn't open.", - "inputSchema": { "type": "object", "properties": {} } - }), - json!({ - "name": "import_environment", - "description": "Create an environment with a name, scope, and full variable set in one call (e.g. importing one from another tool). Returns the new id.", - "inputSchema": { - "type": "object", - "properties": { "name": { "type": "string" }, "collectionId": nullable_string(), "variables": kv_array_schema() }, - "required": ["name"] - } - }), - - // ── Mock Server ────────────────────────────────────────────────── - // Only answers while DevTool is open with the Mock Server tool on - // screen — same contract as the API Client tools above, for the same - // reason (the stub list and bind config live in that mounted - // component's state, not anywhere the sidecar can reach on its own). - json!({ - "name": "mock_get_config", - "description": "Get the mock server's bind (host/port), fallback response, running status, and a summarized stub list (id/enabled/name/method/path/mode/status — not matchers/headers/body/script). Use mock_get_stub for one stub's full definition.", - "inputSchema": { "type": "object", "properties": {} } - }), - json!({ - "name": "mock_get_stub", - "description": "Get one stub's full definition: matchers, response mode, status, headers, body (or script for scripted responses), delay.", - "inputSchema": { "type": "object", "properties": { "stubId": { "type": "string" } }, "required": ["stubId"] } - }), - json!({ - "name": "mock_add_stub", - "description": "Add a new stub. `stub` is a partial Stub object for the initial values (defaults: enabled=true, method=GET, mode=static — see get_scripting_reference for field shapes). Returns the created stub, appended to the end; reorder with mock_move_stub since stub order matters (first match wins).", - "inputSchema": { "type": "object", "properties": { "stub": { "type": "object" } } } - }), - json!({ - "name": "mock_update_stub", - "description": "Patch a stub in place. `patch` is a partial Stub object — only the fields you include are changed.", - "inputSchema": { - "type": "object", - "properties": { "stubId": { "type": "string" }, "patch": { "type": "object" } }, - "required": ["stubId", "patch"] - } - }), - json!({ - "name": "mock_duplicate_stub", - "description": "Duplicate a stub (fresh id, \" copy\") as the next stub right after the original. Returns the new stub.", - "inputSchema": { "type": "object", "properties": { "stubId": { "type": "string" } }, "required": ["stubId"] } - }), - json!({ - "name": "mock_delete_stub", - "description": "Delete a stub by id.", - "inputSchema": { "type": "object", "properties": { "stubId": { "type": "string" } }, "required": ["stubId"] } - }), - json!({ - "name": "mock_move_stub", - "description": "Move a stub up or down one position relative to its siblings. Stub order matters — the first enabled stub whose matchers all pass wins — so this is how to reprioritize overlapping stubs.", - "inputSchema": { - "type": "object", - "properties": { "stubId": { "type": "string" }, "direction": { "type": "string", "enum": ["up", "down"] } }, - "required": ["stubId", "direction"] - } - }), - json!({ - "name": "mock_set_fallback", - "description": "Patch the \"no stub matched\" response. `patch` may include any of notFoundStatus (number), notFoundBody (string), notFoundContentType (string).", - "inputSchema": { "type": "object", "properties": { "patch": { "type": "object" } }, "required": ["patch"] } - }), - json!({ - "name": "mock_set_bind", - "description": "Set the host and/or port the server binds to on the next mock_start. Does NOT hot-swap an already-running server (unlike stubs/fallback, which apply live) — call mock_stop then mock_start to rebind.", - "inputSchema": { "type": "object", "properties": { "host": { "type": "string" }, "port": { "type": "number" } } } - }), - json!({ - "name": "mock_start", - "description": "Start the mock server with the current config (stubs + bind address). Returns the resulting status (running/host/port). Errors if the port is already in use.", - "inputSchema": { "type": "object", "properties": {} } - }), - json!({ - "name": "mock_stop", - "description": "Stop the mock server.", - "inputSchema": { "type": "object", "properties": {} } - }), - json!({ - "name": "mock_status", - "description": "Get the mock server's current running status (running/host/port), without the config/stub list mock_get_config also returns.", - "inputSchema": { "type": "object", "properties": {} } - }), - json!({ - "name": "mock_test_script", - "description": "Run a Rhai response script against a synthetic request, without saving it to a stub — for iterating before mock_add_stub/mock_update_stub. `sample` is { method, path, query, headers, params, body } (all optional, defaults to GET /); see get_scripting_reference for the req/return shape.", - "inputSchema": { - "type": "object", - "properties": { "script": { "type": "string" }, "sample": { "type": "object" } }, - "required": ["script"] - } - }), - json!({ - "name": "mock_get_request_log", - "description": "Get the most recent requests the mock server handled (newest first, capped at `limit`, default 50), each with which stub matched (or null for the fallback), status, timing, and truncated request/response bodies.", - "inputSchema": { "type": "object", "properties": { "limit": { "type": "number" } } } - }), - json!({ - "name": "mock_clear_request_log", - "description": "Clear the mock server's request log.", - "inputSchema": { "type": "object", "properties": {} } - }), - - // ── Redis Client — connection management only ─────────────────── - // No key/value/pub-sub/admin tools — this surface is deliberately - // scoped to saved connection profiles: list/add/update/delete/test, - // plus connect/disconnect. Only answers while DevTool is open with - // the Redis Client tool on screen, unless Settings → MCP → - // Background MCP bridge is on (same contract as every other tool - // here — see mcp_bridge.rs). - json!({ - "name": "redis_list_connections", - "description": "List every saved Redis connection profile (id, name, host, port, username, password, useTls). Values are returned as stored, unmasked, same as the app's own connection form.", - "inputSchema": { "type": "object", "properties": {} } - }), - json!({ - "name": "redis_get_connection", - "description": "Get one saved Redis connection profile by id.", - "inputSchema": { "type": "object", "properties": { "connectionId": { "type": "string" } }, "required": ["connectionId"] } - }), - json!({ - "name": "redis_add_connection", - "description": "Save a new Redis connection profile and return it (with its generated id). `name`/`host` required; `port` defaults to 6379, `useTls` to false.", - "inputSchema": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "host": { "type": "string" }, - "port": { "type": "number" }, - "username": { "type": "string" }, - "password": { "type": "string" }, - "useTls": { "type": "boolean" } - }, - "required": ["name"] - } - }), - json!({ - "name": "redis_update_connection", - "description": "Patch a saved Redis connection profile. `patch` is a partial object — only included fields change.", - "inputSchema": { - "type": "object", - "properties": { "connectionId": { "type": "string" }, "patch": { "type": "object" } }, - "required": ["connectionId", "patch"] - } - }), - json!({ - "name": "redis_delete_connection", - "description": "Delete a saved Redis connection profile by id. The server itself is unaffected — only the local saved profile.", - "inputSchema": { "type": "object", "properties": { "connectionId": { "type": "string" } }, "required": ["connectionId"] } - }), - json!({ - "name": "redis_test_connection", - "description": "Verify a saved connection is reachable, without marking it as the connected one.", - "inputSchema": { "type": "object", "properties": { "connectionId": { "type": "string" } }, "required": ["connectionId"] } - }), - json!({ - "name": "redis_connect", - "description": "Test and mark a saved connection as the active one in the Redis Client UI (same as pressing Connect). Optionally also switch the active logical db (0-15) via `db`.", - "inputSchema": { - "type": "object", - "properties": { "connectionId": { "type": "string" }, "db": { "type": "number" } }, - "required": ["connectionId"] - } - }), - json!({ - "name": "redis_disconnect", - "description": "Clear the active Redis connection (same as pressing Disconnect).", - "inputSchema": { "type": "object", "properties": {} } - }), - json!({ - "name": "redis_connection_status", - "description": "Get the currently selected/connected Redis connection id and active db.", - "inputSchema": { "type": "object", "properties": {} } - }), - - // ── Kafka Explorer — connection management only ───────────────── - // No topic/consumer-group/produce/consume tools — same scoping as - // Redis above: saved broker profiles and connect/disconnect only. - json!({ - "name": "kafka_list_connections", - "description": "List every saved Kafka broker profile (id, name, bootstrapServers, saslMechanism, saslUsername, saslPassword, sslEnabled). Values are returned as stored, unmasked, same as the app's own connection form.", - "inputSchema": { "type": "object", "properties": {} } - }), - json!({ - "name": "kafka_get_connection", - "description": "Get one saved Kafka broker profile by id.", - "inputSchema": { "type": "object", "properties": { "connectionId": { "type": "string" } }, "required": ["connectionId"] } - }), - json!({ - "name": "kafka_add_connection", - "description": "Save a new Kafka broker profile and return it (with its generated id). `name`/`bootstrapServers` required; `sslEnabled` defaults to false.", - "inputSchema": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "bootstrapServers": { "type": "string", "description": "Comma-separated host:port list." }, - "saslMechanism": { "type": "string" }, - "saslUsername": { "type": "string" }, - "saslPassword": { "type": "string" }, - "sslEnabled": { "type": "boolean" } - }, - "required": ["name", "bootstrapServers"] - } - }), - json!({ - "name": "kafka_update_connection", - "description": "Patch a saved Kafka broker profile. `patch` is a partial object — only included fields change.", - "inputSchema": { - "type": "object", - "properties": { "connectionId": { "type": "string" }, "patch": { "type": "object" } }, - "required": ["connectionId", "patch"] - } - }), - json!({ - "name": "kafka_delete_connection", - "description": "Delete a saved Kafka broker profile by id. The broker itself is unaffected — only the local saved profile.", - "inputSchema": { "type": "object", "properties": { "connectionId": { "type": "string" } }, "required": ["connectionId"] } - }), - json!({ - "name": "kafka_test_connection", - "description": "Verify a saved broker is reachable, without marking it as the connected one.", - "inputSchema": { "type": "object", "properties": { "connectionId": { "type": "string" } }, "required": ["connectionId"] } - }), - json!({ - "name": "kafka_connect", - "description": "Test and mark a saved broker as the active one in the Kafka Explorer UI (same as pressing Connect). Stops any realtime consumers still running against a previously-connected broker, since only one broker is live at a time.", - "inputSchema": { "type": "object", "properties": { "connectionId": { "type": "string" } }, "required": ["connectionId"] } - }), - json!({ - "name": "kafka_disconnect", - "description": "Clear the active Kafka broker (same as pressing Disconnect) and stop any realtime consumers running against it.", - "inputSchema": { "type": "object", "properties": {} } - }), - json!({ - "name": "kafka_connection_status", - "description": "Get the currently selected/connected Kafka broker id.", - "inputSchema": { "type": "object", "properties": {} } - }), - - // ── RabbitMQ Client — connection management only ──────────────── - // No queue/exchange/publish/consume/RPC tools — same scoping as - // Redis/Kafka above: saved connection profiles and connect/ - // disconnect only. Only answers while DevTool is open with the - // RabbitMQ Client tool on screen, unless Settings → MCP → - // Background MCP bridge is on (same contract as every other tool - // here — see mcp_bridge.rs). - json!({ - "name": "rabbit_list_connections", - "description": "List every saved RabbitMQ connection profile (id, name, host, port, vhost, username, password, useTls, amqpPort, amqpOnly, and optional TLS/heartbeat/extraHosts fields). Values are returned as stored, unmasked, same as the app's own connection form.", - "inputSchema": { "type": "object", "properties": {} } - }), - json!({ - "name": "rabbit_get_connection", - "description": "Get one saved RabbitMQ connection profile by id.", - "inputSchema": { "type": "object", "properties": { "connectionId": { "type": "string" } }, "required": ["connectionId"] } - }), - json!({ - "name": "rabbit_add_connection", - "description": "Save a new RabbitMQ connection profile and return it (with its generated id). `name`/`host` required; `port` defaults to 15672 (management), `vhost` to \"/\", `username`/`password` to \"guest\", `amqpPort` to 5672, `amqpOnly` to true (no management HTTP API — AMQP-only topology probes).", - "inputSchema": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "host": { "type": "string" }, - "port": { "type": "number", "description": "Management API port." }, - "vhost": { "type": "string" }, - "username": { "type": "string" }, - "password": { "type": "string" }, - "useTls": { "type": "boolean" }, - "amqpPort": { "type": "number" }, - "amqpOnly": { "type": "boolean" } - }, - "required": ["name"] - } - }), - json!({ - "name": "rabbit_update_connection", - "description": "Patch a saved RabbitMQ connection profile. `patch` is a partial object — only included fields change.", - "inputSchema": { - "type": "object", - "properties": { "connectionId": { "type": "string" }, "patch": { "type": "object" } }, - "required": ["connectionId", "patch"] - } - }), - json!({ - "name": "rabbit_delete_connection", - "description": "Delete a saved RabbitMQ connection profile by id. The broker itself is unaffected — only the local saved profile.", - "inputSchema": { "type": "object", "properties": { "connectionId": { "type": "string" } }, "required": ["connectionId"] } - }), - json!({ - "name": "rabbit_test_connection", - "description": "Verify a saved connection is reachable over AMQP (and the management API too, unless amqpOnly), without marking it as the connected one.", - "inputSchema": { "type": "object", "properties": { "connectionId": { "type": "string" } }, "required": ["connectionId"] } - }), - json!({ - "name": "rabbit_connect", - "description": "Test and mark a saved connection as the active one in the RabbitMQ Client UI (same as pressing Connect). Stops any live consumers still running against a previously-connected connection, since only one connection is live at a time.", - "inputSchema": { "type": "object", "properties": { "connectionId": { "type": "string" } }, "required": ["connectionId"] } - }), - json!({ - "name": "rabbit_disconnect", - "description": "Clear the active RabbitMQ connection (same as pressing Disconnect) and stop any live consumers running against it.", - "inputSchema": { "type": "object", "properties": {} } - }), - json!({ - "name": "rabbit_connection_status", - "description": "Get the currently selected/connected RabbitMQ connection id.", - "inputSchema": { "type": "object", "properties": {} } - }), - - // ── Encode·Hash·Encrypt — stateless, answered by McpUtilityBridge.tsx - // (mounted unconditionally at the app root, not gated by the - // Background MCP bridge or "tool on screen" — only by Settings → - // MCP → Per-tool MCP access for the `base64` tool id). Every call - // is a pure function of its own arguments; nothing here is ever - // read from or written to app state. - json!({ - "name": "codec_encode", - "description": "Encode text with a codec. `algorithm` is one of: base64, base62, rot13, url, html, quoted-printable, huffman, rle, morse, punycode, hex, octal, binary, decimal.", - "inputSchema": { - "type": "object", - "properties": { - "text": { "type": "string" }, - "algorithm": { "type": "string", "enum": ["base64", "base62", "rot13", "url", "html", "quoted-printable", "huffman", "rle", "morse", "punycode", "hex", "octal", "binary", "decimal"] } - }, - "required": ["text", "algorithm"] - } - }), - json!({ - "name": "codec_decode", - "description": "Decode text with a codec — same `algorithm` list as codec_encode.", - "inputSchema": { - "type": "object", - "properties": { - "text": { "type": "string" }, - "algorithm": { "type": "string", "enum": ["base64", "base62", "rot13", "url", "html", "quoted-printable", "huffman", "rle", "morse", "punycode", "hex", "octal", "binary", "decimal"] } - }, - "required": ["text", "algorithm"] - } - }), - json!({ - "name": "hash_compute", - "description": "Hash text. Omit `algorithm` to get every algorithm at once (md5, ripemd160, sha1, sha224, sha256, sha384, sha512, sha3-256, sha3-512), as { algorithm: hash }; pass one to get just that hash as { algorithm, hash }.", - "inputSchema": { - "type": "object", - "properties": { - "text": { "type": "string" }, - "algorithm": { "type": "string", "enum": ["md5", "ripemd160", "sha1", "sha224", "sha256", "sha384", "sha512", "sha3-256", "sha3-512"] }, - "upperHex": { "type": "boolean" } - }, - "required": ["text"] - } - }), - json!({ - "name": "hash_hmac", - "description": "Compute an HMAC. `algorithm` is one of: md5, ripemd160, sha1, sha224, sha256, sha384, sha512 (SHA-3 has no dedicated per-length HMAC function, so it's excluded here, same as the UI).", - "inputSchema": { - "type": "object", - "properties": { - "text": { "type": "string" }, - "key": { "type": "string" }, - "algorithm": { "type": "string", "enum": ["md5", "ripemd160", "sha1", "sha224", "sha256", "sha384", "sha512"] }, - "upperHex": { "type": "boolean" } - }, - "required": ["text", "key", "algorithm"] - } - }), - json!({ - "name": "encrypt_text", - "description": "Encrypt text with a passphrase. `algorithm`: \"aes-gcm\" is recommended (PBKDF2-SHA256 600k-round key stretching, authenticated, random salt+IV) — the rest (aes-cbc/ctr/ecb/cfb/ofb, tripledes, rabbit) exist for crypto-js interop only (weak key stretching, unauthenticated; aes-ecb also leaks plaintext structure). `key` is supplied by the caller — never read from the app's own saved passphrase.", - "inputSchema": { - "type": "object", - "properties": { - "text": { "type": "string" }, - "key": { "type": "string" }, - "algorithm": { "type": "string", "enum": ["aes-gcm", "aes-cbc", "aes-ctr", "aes-ecb", "aes-cfb", "aes-ofb", "tripledes", "rabbit"] } - }, - "required": ["text", "key", "algorithm"] - } - }), - json!({ - "name": "decrypt_text", - "description": "Decrypt text encrypted with encrypt_text (or, for the crypto-js algorithms, anything crypto-js itself produced). `algorithm` must match what encrypted it.", - "inputSchema": { - "type": "object", - "properties": { - "ciphertext": { "type": "string" }, - "key": { "type": "string" }, - "algorithm": { "type": "string", "enum": ["aes-gcm", "aes-cbc", "aes-ctr", "aes-ecb", "aes-cfb", "aes-ofb", "tripledes", "rabbit"] } - }, - "required": ["ciphertext", "key", "algorithm"] - } - }), - - // ── JWT Debugger — stateless, same McpUtilityBridge.tsx as above, - // gated by the `jwt` tool id. - json!({ - "name": "jwt_decode", - "description": "Decode a JWT's header and payload. Decode-only — does NOT verify the signature (no verification key available).", - "inputSchema": { "type": "object", "properties": { "token": { "type": "string" } }, "required": ["token"] } - }), - - // ── JSON Formatter — stateless, same McpUtilityBridge.tsx as above, - // gated by the `json` tool id. All four accept lenient input: single - // quotes, unquoted object keys, trailing commas, // and /* */ - // comments, and a JSON-string-literal wrapper — same parser the UI - // itself uses. - json!({ - "name": "json_format", - "description": "Pretty-print JSON (or JSON-ish input — see tool description). `indent` is \"2\" (default), \"4\", or \"tab\"; `quote` is `\"` (default) or `'`.", - "inputSchema": { - "type": "object", - "properties": { - "text": { "type": "string" }, - "indent": { "type": "string", "enum": ["2", "4", "tab"] }, - "quote": { "type": "string", "enum": ["\"", "'"] } - }, - "required": ["text"] - } - }), - json!({ - "name": "json_minify", - "description": "Minify JSON (or JSON-ish input) to a single line, no whitespace.", - "inputSchema": { - "type": "object", - "properties": { "text": { "type": "string" }, "quote": { "type": "string", "enum": ["\"", "'"] } }, - "required": ["text"] - } - }), - json!({ - "name": "json_to_string", - "description": "Minify JSON, then re-encode the result as a single escaped string literal — for embedding a JSON payload inside another string (a shell command, a source file, …).", - "inputSchema": { - "type": "object", - "properties": { "text": { "type": "string" }, "quote": { "type": "string", "enum": ["\"", "'"] } }, - "required": ["text"] - } - }), - json!({ - "name": "json_validate", - "description": "Check whether input parses (leniently — see tool description). Returns { valid: true } or { valid: false, error }.", - "inputSchema": { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] } - }), - - // ── Containers — connection management AND full lifecycle, unlike - // Redis/Kafka/RabbitMQ above (connection-only by design). Lifecycle/ - // image tools operate on whichever connection is currently ACTIVE - // (set via container_connect) — there is no per-call connection - // argument, same as mock_* operating on "the" mock server. Only - // answers while DevTool is open with the Containers tool on screen, - // unless Settings → MCP → Background MCP bridge is on (same - // contract as every other connection-based tool here). - json!({ - "name": "container_list_connections", - "description": "List every saved container-runtime connection profile (id, name, socketPath — a Unix socket or Windows named pipe path for a Docker-compatible daemon: Docker Desktop, colima, Rancher Desktop, OrbStack, Podman, …).", - "inputSchema": { "type": "object", "properties": {} } - }), - json!({ - "name": "container_get_connection", - "description": "Get one saved container connection profile by id.", - "inputSchema": { "type": "object", "properties": { "connectionId": { "type": "string" } }, "required": ["connectionId"] } - }), - json!({ - "name": "container_add_connection", - "description": "Save a new container connection profile and return it (with its generated id). Both `name` and `socketPath` are required — there's no default socket path since it varies by runtime/OS.", - "inputSchema": { - "type": "object", - "properties": { "name": { "type": "string" }, "socketPath": { "type": "string" } }, - "required": ["name", "socketPath"] - } - }), - json!({ - "name": "container_update_connection", - "description": "Patch a saved container connection profile. `patch` is a partial object — only included fields change.", - "inputSchema": { - "type": "object", - "properties": { "connectionId": { "type": "string" }, "patch": { "type": "object" } }, - "required": ["connectionId", "patch"] - } - }), - json!({ - "name": "container_delete_connection", - "description": "Delete a saved container connection profile by id. The daemon itself is unaffected — only the local saved profile.", - "inputSchema": { "type": "object", "properties": { "connectionId": { "type": "string" } }, "required": ["connectionId"] } - }), - json!({ - "name": "container_test_connection", - "description": "Verify a saved connection can reach the daemon, without marking it as the connected one.", - "inputSchema": { "type": "object", "properties": { "connectionId": { "type": "string" } }, "required": ["connectionId"] } - }), - json!({ - "name": "container_connect", - "description": "Test and mark a saved connection as the active one (same as pressing Connect in the Containers UI). Every container_list/inspect/start/stop/…/image_* tool operates on this active connection.", - "inputSchema": { "type": "object", "properties": { "connectionId": { "type": "string" } }, "required": ["connectionId"] } - }), - json!({ - "name": "container_disconnect", - "description": "Clear the active container connection (same as pressing Disconnect).", - "inputSchema": { "type": "object", "properties": {} } - }), - json!({ - "name": "container_connection_status", - "description": "Get the currently selected/connected container connection id.", - "inputSchema": { "type": "object", "properties": {} } - }), - json!({ - "name": "container_list", - "description": "List containers on the active connection. `all=false` (default) shows running containers only; `all=true` includes stopped ones too.", - "inputSchema": { "type": "object", "properties": { "all": { "type": "boolean" } } } - }), - json!({ - "name": "container_inspect", - "description": "Get one container's curated details: image, status, health, command, entrypoint, restart policy, env, labels, mounts, ports, networks, and cgroup resource limits.", - "inputSchema": { "type": "object", "properties": { "containerId": { "type": "string" } }, "required": ["containerId"] } - }), - json!({ - "name": "container_start", - "description": "Start a stopped container.", - "inputSchema": { "type": "object", "properties": { "containerId": { "type": "string" } }, "required": ["containerId"] } - }), - json!({ - "name": "container_stop", - "description": "Stop a running container.", - "inputSchema": { "type": "object", "properties": { "containerId": { "type": "string" } }, "required": ["containerId"] } - }), - json!({ - "name": "container_restart", - "description": "Restart a container.", - "inputSchema": { "type": "object", "properties": { "containerId": { "type": "string" } }, "required": ["containerId"] } - }), - json!({ - "name": "container_pause", - "description": "Pause a running container's processes (SIGSTOP-equivalent, freezes without stopping).", - "inputSchema": { "type": "object", "properties": { "containerId": { "type": "string" } }, "required": ["containerId"] } - }), - json!({ - "name": "container_unpause", - "description": "Resume a paused container.", - "inputSchema": { "type": "object", "properties": { "containerId": { "type": "string" } }, "required": ["containerId"] } - }), - json!({ - "name": "container_remove", - "description": "Remove a container. `force=true` removes it even if running (same as `docker rm -f`).", - "inputSchema": { - "type": "object", - "properties": { "containerId": { "type": "string" }, "force": { "type": "boolean" } }, - "required": ["containerId"] - } - }), - json!({ - "name": "container_logs", - "description": "Get a container's recent log output. This collects whatever the daemon streams back within about 1.5s, not a live tail — call again for newer output. `tail` (default \"100\") is a line count or \"all\"; `since`/`until` are Unix seconds (0 = no bound); `timestamps` prefixes each line with when the daemon logged it.", - "inputSchema": { - "type": "object", - "properties": { - "containerId": { "type": "string" }, - "tail": { "type": "string" }, - "since": { "type": "number" }, - "until": { "type": "number" }, - "timestamps": { "type": "boolean" } - }, - "required": ["containerId"] - } - }), - json!({ - "name": "container_stats", - "description": "Get one CPU/memory/network usage sample for a running container (not a live stream — call again for a fresh sample).", - "inputSchema": { "type": "object", "properties": { "containerId": { "type": "string" } }, "required": ["containerId"] } - }), - json!({ - "name": "container_list_images", - "description": "List images on the active connection (id, repo tags, created, size).", - "inputSchema": { "type": "object", "properties": {} } - }), - json!({ - "name": "container_image_details", - "description": "Get one image's curated details: repo tags/digests, size, architecture/os, cmd, entrypoint, env, working dir, exposed ports, labels, layer count.", - "inputSchema": { "type": "object", "properties": { "imageId": { "type": "string" } }, "required": ["imageId"] } - }), - json!({ - "name": "container_remove_image", - "description": "Remove an image. `force=true` removes it even if a stopped container still references it.", - "inputSchema": { - "type": "object", - "properties": { "imageId": { "type": "string" }, "force": { "type": "boolean" } }, - "required": ["imageId"] - } - }), - - // ── DevTool MCP management ────────────────────────────────────── - // Unlike every tool above, these three are answered by an always-on - // listener (McpManageBridge.tsx) that is never gated by the - // Background MCP bridge setting OR the per-tool toggles — that's the - // whole point: they let a caller check and flip either itself, so - // any tool can be driven (or re-enabled after being switched off) - // with neither it nor Settings open, without anyone touching - // Settings → MCP by hand first. Still requires DevTool to be - // running (there is no way to reach a fully closed app — see - // mcp_bridge.rs). - json!({ - "name": "devtool_mcp_status", - "description": "Get DevTool's current MCP integration state: whether the Background MCP bridge is on, the per-tool enabled/disabled map (toolsEnabled: api-client/mock-server/redis-client/kafka-explorer/rabbit-client/container-manager/base64/jwt/json), and the mock server's running status. Call this first if a tool's calls unexpectedly time out — a disabled tool times out exactly like \"wrong tool on screen\" does, since it never registers a listener either (except base64/jwt/json, which have no \"on screen\" requirement at all — a disabled one there just never answers).", - "inputSchema": { "type": "object", "properties": {} } - }), - json!({ - "name": "devtool_mcp_set_background", - "description": "Turn DevTool's Background MCP bridge on or off (mirrors the Settings → MCP toggle). When on, every enabled tool's MCP tools answer regardless of which tool is on screen or whether the app window is focused — call this with enabled:true instead of asking the user to click it manually.", - "inputSchema": { - "type": "object", - "properties": { "enabled": { "type": "boolean" } }, - "required": ["enabled"] - } - }), - json!({ - "name": "devtool_mcp_set_tool_enabled", - "description": "Turn one tool's MCP access on or off (mirrors Settings → MCP → Per-tool MCP access). A disabled tool never answers any of its MCP tool calls — for the three stateless utility tools (base64/jwt/json) that means at all; for the rest, on screen or in the background. This is a separate, stricter switch than devtool_mcp_set_background. `tool` is one of: api-client, mock-server, redis-client, kafka-explorer, rabbit-client, container-manager, base64, jwt, json.", - "inputSchema": { - "type": "object", - "properties": { - "tool": { "type": "string", "enum": ["api-client", "mock-server", "redis-client", "kafka-explorer", "rabbit-client", "container-manager", "base64", "jwt", "json"] }, - "enabled": { "type": "boolean" } - }, - "required": ["tool", "enabled"] - } - }), - ] -} - -// ── rmcp server wiring ─────────────────────────────────────────────────── -// Converts each raw `tool_definitions()` entry into a rmcp `Tool` + a -// dynamic route that forwards straight to `call_tool` — one generic -// dispatcher rather than a hand-written method per tool, since every tool -// here has the same shape (take a JSON args object, forward it to the -// bridge, return its result as text). - #[derive(Clone)] -struct DevToolServer { - tool_router: ToolRouter, -} - -fn build_router() -> ToolRouter { - let mut router = ToolRouter::new(); - for def in tool_definitions() { - let name = def.get("name").and_then(Value::as_str).unwrap_or("").to_string(); - let description = def.get("description").and_then(Value::as_str).unwrap_or("").to_string(); - let schema = match def.get("inputSchema").cloned().unwrap_or_else(|| json!({})) { - Value::Object(m) => m, - _ => Default::default(), - }; - let tool = Tool::new(name.clone(), description, schema); - // get_scripting_reference is static content, not live app state — answer - // it locally instead of round-tripping through the bridge, so it works - // even while DevTool is closed or on a different tool. - if name == "get_scripting_reference" { - router.add_route(ToolRoute::new_dyn(tool, |_context: ToolCallContext<'_, DevToolServer>| { - Box::pin(async move { Ok(CallToolResult::success(vec![Content::text(SCRIPTING_REFERENCE)])) }) - as Pin> + Send>> - })); - continue; - } - router.add_route(ToolRoute::new_dyn(tool, move |context: ToolCallContext<'_, DevToolServer>| { - let name = name.clone(); - let args = context.arguments.clone().map(Value::Object).unwrap_or_else(|| json!({})); - Box::pin(async move { Ok(call_tool(&name, args).await) }) - as Pin> + Send>> - })); - } - router -} +struct DevToolServer; impl ServerHandler for DevToolServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().build()) .with_instructions( - "Drives nine DevTool tools. API Client: collections, requests, scripts, \ - environments — and can actually send a request. Mock Server: stubs, \ - matchers, the fallback response, and can start/stop the server and test a \ - response script. Redis Client, Kafka Explorer, and RabbitMQ Client \ - (redis_*/kafka_*/rabbit_* tools): saved connection profiles only — \ - list/add/update/delete/test, plus connect/disconnect — no key/topic/queue/\ - exchange/produce/consume/publish/RPC operations for any of the three. \ - Containers (container_* tools): connection profiles PLUS full lifecycle — \ - list/inspect/start/stop/restart/pause/remove, logs, one-shot stats, and \ - image list/inspect/remove — all against whichever connection \ - container_connect last activated. Encode·Hash·Encrypt (codec_*/hash_*/\ - encrypt_text/decrypt_text), JWT Debugger (jwt_decode), and JSON Formatter \ - (json_*) are stateless — pure functions of their own arguments, no saved \ - connection or open tool required, always answer regardless of the settings \ - below. By default every OTHER tool's calls only answer while the DevTool \ - desktop app is open with THAT tool on screen (mock_* needs Mock Server open, \ - redis_* needs Redis Client open, kafka_* needs Kafka Explorer open, rabbit_* \ - needs RabbitMQ Client open, container_* needs Containers open, everything \ - else needs API Client open) — if a call times out, that is almost always why; \ - call devtool_mcp_set_background with enabled:true to lift that requirement \ - yourself (mirrors Settings → MCP → Background MCP bridge) instead of asking \ - the user to switch tools or click it manually. A tool can also be switched \ - off entirely (Settings → MCP → Per-tool MCP access) — devtool_mcp_status's \ - toolsEnabled reports which; devtool_mcp_set_tool_enabled flips one, but only \ - the user should ever choose to disable a tool. Call get_scripting_reference for the API \ - Client/Mock Server scripting API and field shapes shared by those two.", + "Drives every DevTool tool that has registered itself with the running app's \ + MCP bridge — call list_tools for the current set, since it can grow (a plugin \ + installed from Settings → Extensions) or shrink (a tool switched off in \ + Settings → MCP → Per-tool MCP access) between calls. A handful of stateless \ + utility tools (codec_*/hash_*/encrypt_text/decrypt_text, jwt_decode, json_*) \ + are pure functions of their own arguments and always answer. Every other \ + tool's calls only answer while the DevTool desktop app is open with THAT \ + tool's own screen open (its bridge only registers/answers while mounted) — if \ + a call times out, that is almost always why; call devtool_mcp_set_background \ + with enabled:true to lift that requirement yourself (mirrors Settings → MCP → \ + Background MCP bridge) instead of asking the user to switch tools or click it \ + manually. A tool can also be switched off entirely (Settings → MCP → Per-tool \ + MCP access) — devtool_mcp_status's toolsEnabled reports which; \ + devtool_mcp_set_tool_enabled flips one, but only the user should ever choose \ + to disable a tool. Call get_scripting_reference for the API Client/Mock \ + Server scripting API and field shapes shared by those two.", ) .with_server_info(Implementation::new("devtool-api-client", env!("CARGO_PKG_VERSION"))) } @@ -1374,21 +511,36 @@ impl ServerHandler for DevToolServer { _request: Option, _context: RequestContext, ) -> impl Future> + Send + '_ { - std::future::ready(Ok(ListToolsResult { tools: self.tool_router.list_all(), ..Default::default() })) + async move { + // A registration fetch failure (app not open) means "no tools + // registered right now" — get_scripting_reference alone, not an + // error surfaced to the MCP client. `call_tool` on a stale/missing + // tool name still gets a clear per-call error from `call_bridge`. + let mut tools = fetch_registered_tools().await.unwrap_or_default(); + tools.push(scripting_reference_tool()); + Ok(ListToolsResult { tools, ..Default::default() }) + } } fn call_tool( &self, request: CallToolRequestParams, - context: RequestContext, - ) -> impl Future> + Send + '_ { - self.tool_router.call(ToolCallContext::new(self, request, context)) + _context: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + if request.name.as_ref() == "get_scripting_reference" { + return Ok(CallToolResult::success(vec![ContentBlock::text(SCRIPTING_REFERENCE)]) + .into()); + } + let args = request.arguments.clone().map(Value::Object).unwrap_or_else(|| json!({})); + Ok(call_tool(request.name.as_ref(), args).await.into()) + } } } #[tokio::main] async fn main() { - let server = DevToolServer { tool_router: build_router() }; + let server = DevToolServer; match server.serve(stdio()).await { Ok(service) => { if let Err(e) = service.waiting().await { diff --git a/src-tauri/src/bin/devtool-svc-container.rs b/src-tauri/src/bin/devtool-svc-container.rs deleted file mode 100644 index 53e2773d..00000000 --- a/src-tauri/src/bin/devtool-svc-container.rs +++ /dev/null @@ -1,2032 +0,0 @@ -// Plugin dịch vụ (tier B) cho Container Manager — Bước 1 của Phase 2 (xem -// docs/decisions/architecture/platform-plugin-architecture.md, mục "Ba tier" và "Cách ly -// dữ liệu"). Port từ src-tauri/src/container_tool.rs, giữ nguyên HÌNH DẠNG dữ -// liệu (JSON camelCase, struct `ContainerConnection`) nhưng KHÔNG đụng file -// đó — nó vẫn chạy production (Tier A) song song cho tới Bước 4/5 cắt hẳn, -// đúng mẫu `devtool-svc-redis.rs` đã làm cho Redis. -// -// Khác với bản Tier A (`container_tool.rs`) đúng những chỗ là hệ quả của việc -// chạy như tiến trình riêng, không có `AppHandle`: -// - Config CRUD đọc/ghi `/connections.json` (biến -// môi trường `service_host.rs::get_or_spawn` set khi spawn) thay vì -// `plugin_data::plugin_data_dir(app, "container-manager")` — thư mục này -// ĐÃ được cách ly riêng cho sidecar này -// (`/service-data/devtool-svc-container/`), KHÁC -// `plugin-data/container-manager/connections.json` mà Tier A dùng, có -// chủ ý — xem "Cách ly dữ liệu" trong ADR. KHÔNG di trú dữ liệu cũ: chỉ -// là cấu hình kết nối (socket path), người dùng tự nhập lại được. -// - main() spawn một task async cho mỗi dòng stdin vào, một task riêng sở -// hữu stdout qua kênh mpsc — y hệt cấu trúc `devtool-svc-redis.rs`, vì -// nhiều lời gọi có thể chồng lên nhau trên CÙNG một sidecar (host demux -// theo `id`, xem `service_host.rs`). -// -// Bước 1 (xong): config CRUD, detect-sockets, test-connection, list/inspect, -// lifecycle (start/stop/restart/pause/unpause/remove) — mọi method ở đây là -// MỘT-LẦN (đi qua `handle()`), không có method stream nào trong bước này. -// Bước 2 (xong): container details/resources, image/volume/network CRUD + -// details, prune, system info/df — vẫn toàn method MỘT-LẦN, port nguyên vẹn -// logic từ `container_tool.rs` (không đoán lại). -// Bước 3 (xong): log/stats streaming, port nguyên vẹn từ -// `container_tool::container_logs_start`/`container_stats_start`/ -// `container_stats_snapshot` (xem đó, dòng ~627-833). `logs-start` và -// `stats-start` là method STREAM (giao thức giống hệt `pubsub-subscribe` của -// devtool-svc-redis.rs: sự kiện đầu `{"type":"subscribed","subscriptionId":…}`, -// lỗi giữa chừng đi qua như một SỰ KIỆN STREAM bình thường KHÔNG BAO GIỜ dùng -// `Response::err`, xem comment `send_pubsub_error` ở đó để hiểu đầy đủ lý do -// — sidecar này port lại thành `send_stream_error`). `logs-unsubscribe`/ -// `stats-unsubscribe` là method một-lần mirror `unsubscribe` của Redis. -// `stats-snapshot` là method một-lần (KHÔNG phải stream) trả một lần -// `HashMap`. Registry nội bộ DÙNG CHUNG cho cả logs -// và stats (một `HashMap` duy nhất) — xem comment đầy -// đủ ở mục "Logs/Stats streaming" bên dưới cho lý do không tách hai registry. -// -// Bước 4 (đây): `image-pull` đổi từ method MỘT-LẦN (Bước 2, nuốt hết -// progress) sang method STREAM kiểu TỰ KẾT THÚC — mẫu `tick-stream` của -// `devtool-svc-echo.rs`, KHÔNG PHẢI mẫu `pubsub-subscribe`/`logs-start`: -// không cần registry, không ai cần dừng tay nó. Xem `handle_image_pull` cho -// lý do đầy đủ (đặc biệt vì sao vẫn cần một sự kiện app-level `"done"` thay -// vì trông cậy vào bit `done: true` ở tầng khung — `service_host.rs:: -// dispatch()` không forward payload của dòng mang `done: true` cho -// `Waiter::Stream`). - -use bollard::Docker; -use futures_util::StreamExt; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::{Arc, Mutex, OnceLock}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::sync::mpsc; -use uuid::Uuid; - -const SERVICE_PROTOCOL: u32 = 1; - -// ── Khung tin nhắn (giống devtool-svc-redis, lặp lại có chủ ý — xem lý do ở đó) ─ - -#[derive(Deserialize)] -struct Request { - protocol: u32, - id: String, - #[serde(default)] - method: String, - #[serde(default)] - params: serde_json::Value, -} - -fn is_false(b: &bool) -> bool { - !*b -} - -#[derive(Serialize)] -struct Response { - protocol: u32, - id: String, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, - #[serde(default, skip_serializing_if = "is_false")] - stream: bool, - #[serde(default, skip_serializing_if = "is_false")] - done: bool, -} - -impl Response { - fn once(id: String, result: serde_json::Value) -> Self { - Self { protocol: SERVICE_PROTOCOL, id, result: Some(result), error: None, stream: false, done: false } - } - fn ok(id: String) -> Self { - Self::once(id, serde_json::Value::Null) - } - fn err(id: String, message: impl Into) -> Self { - Self { protocol: SERVICE_PROTOCOL, id, result: None, error: Some(message.into()), stream: false, done: false } - } - /// Một-trong-nhiều sự kiện của cùng một lời gọi stream (`logs-start`/ - /// `stats-start`) — không bao giờ đi kèm `done: true` ở hai method này - /// (một tail log/stats sống tới khi bị dừng tay bằng `*-unsubscribe`), - /// giống hệt `Response::event` của devtool-svc-redis.rs. - fn event(id: String, result: serde_json::Value) -> Self { - Self { protocol: SERVICE_PROTOCOL, id, result: Some(result), error: None, stream: true, done: false } - } - /// Dòng CUỐI của một stream tự kết thúc (`image-pull`) — mirror - /// `Response::done` của `devtool-svc-echo.rs`. Bị host bỏ qua khi định - /// tuyến tới `EventSink` (xem `dispatch()`/comment ở `handle_image_pull`), - /// chỉ giữ đúng hình thức giao thức "một method stream kết bằng done:true". - fn done(id: String) -> Self { - Self { protocol: SERVICE_PROTOCOL, id, result: None, error: None, stream: true, done: true } - } -} - -// ── Connection profile — giữ nguyên hình dạng camelCase của container_tool.rs ─ - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ContainerConnection { - pub id: String, - pub name: String, - /// Raw filesystem path to the daemon socket (unix) or the pipe name - /// (windows) — no `unix://`/`npipe://` scheme prefix, giống hệt - /// container_tool.rs. - pub socket_path: String, -} - -// ── Config persistence ─────────────────────────────────────────────────────── -// -// Lưu trong thư mục RIÊNG của sidecar này (`DEVTOOL_SERVICE_DATA_DIR` — -// `/service-data/devtool-svc-container/`), KHÁC -// `plugin-data/container-manager/connections.json` mà `container_tool.rs` -// (Tier A, vẫn đang chạy song song) đang dùng. Cách ly bằng thư mục, không -// phải bằng tên file — xem module doc comment ở trên và ADR mục "Cách ly dữ -// liệu". - -fn app_data_dir() -> Result { - std::env::var("DEVTOOL_SERVICE_DATA_DIR") - .map(PathBuf::from) - .map_err(|_| "DEVTOOL_SERVICE_DATA_DIR không được set — sidecar phải chạy qua service_host.rs".to_string()) -} - -fn configs_path() -> Result { - Ok(app_data_dir()?.join("connections.json")) -} - -fn load_configs() -> Vec { - let Ok(path) = configs_path() else { return Vec::new() }; - std::fs::read_to_string(&path).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default() -} - -fn save_configs(configs: &[ContainerConnection]) -> Result<(), String> { - let path = configs_path()?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - let json = serde_json::to_string_pretty(configs).map_err(|e| e.to_string())?; - std::fs::write(&path, json).map_err(|e| e.to_string()) -} - -fn find_config(config_id: &str) -> Result { - load_configs().into_iter().find(|c| c.id == config_id).ok_or_else(|| format!("Connection '{}' not found", config_id)) -} - -// ── Socket auto-detect — port nguyên vẹn từ container_tool.rs ────────────── - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DetectedSocket { - pub label: String, - pub socket_path: String, -} - -/// Best-effort probe of the well-known socket locations used by colima, -/// Docker Desktop, Rancher Desktop, OrbStack and Podman machine — port -/// nguyên vẹn từ `container_tool::container_detect_sockets`, xem đó cho chú -/// thích đầy đủ. -fn detect_sockets() -> Vec { - let mut found = Vec::new(); - - if let Ok(host) = std::env::var("DOCKER_HOST") { - let path = host - .strip_prefix("unix://") - .or_else(|| host.strip_prefix("npipe://")) - .unwrap_or(&host); - if !path.is_empty() { - found.push(DetectedSocket { label: "DOCKER_HOST env".to_string(), socket_path: path.to_string() }); - } - } - - #[cfg(unix)] - { - let home = dirs_home(); - let candidates: Vec<(String, std::path::PathBuf)> = { - let mut v = vec![ - ("Docker Desktop".to_string(), home.join(".docker/run/docker.sock")), - ("System default".to_string(), std::path::PathBuf::from("/var/run/docker.sock")), - ("Rancher Desktop".to_string(), home.join(".rd/docker.sock")), - ("OrbStack".to_string(), home.join(".orbstack/run/docker.sock")), - ( - "Podman machine".to_string(), - home.join(".local/share/containers/podman/machine/podman.sock"), - ), - ]; - // Colima profiles live under ~/.colima//docker.sock — glob them. - if let Ok(entries) = std::fs::read_dir(home.join(".colima")) { - for entry in entries.flatten() { - let profile_sock = entry.path().join("docker.sock"); - if profile_sock.exists() { - let profile = entry.file_name().to_string_lossy().to_string(); - v.push((format!("Colima ({profile})"), profile_sock)); - } - } - } - v - }; - for (label, path) in candidates { - if path.exists() { - found.push(DetectedSocket { label, socket_path: path.to_string_lossy().to_string() }); - } - } - } - - #[cfg(windows)] - { - // Named pipes can't be cheaply `exists()`-checked without opening them, - // so just offer the well-known pipe names as candidates. - found.push(DetectedSocket { - label: "Docker Desktop".to_string(), - socket_path: r"\\.\pipe\docker_engine".to_string(), - }); - found.push(DetectedSocket { - label: "Rancher Desktop".to_string(), - socket_path: r"\\.\pipe\rancher_desktop_engine".to_string(), - }); - } - - found -} - -#[cfg(unix)] -fn dirs_home() -> std::path::PathBuf { - std::env::var_os("HOME") - .map(std::path::PathBuf::from) - .unwrap_or_default() -} - -/// Expands a leading `~` (or `~/...`) to the user's home directory — port -/// nguyên vẹn từ `container_tool::expand_socket_path`. -#[cfg(unix)] -fn expand_socket_path(path: &str) -> String { - if let Some(rest) = path.strip_prefix("~/") { - let home = dirs_home(); - if !home.as_os_str().is_empty() { - return home.join(rest).to_string_lossy().to_string(); - } - } else if path == "~" { - let home = dirs_home(); - if !home.as_os_str().is_empty() { - return home.to_string_lossy().to_string(); - } - } - path.to_string() -} - -#[cfg(windows)] -fn expand_socket_path(path: &str) -> String { - path.to_string() -} - -// ── Connect — port nguyên vẹn từ container_tool.rs ────────────────────────── - -const CONNECT_TIMEOUT_SECS: u64 = 6; - -#[cfg(unix)] -fn connect(c: &ContainerConnection) -> Result { - let path = expand_socket_path(&c.socket_path); - Docker::connect_with_unix(&path, CONNECT_TIMEOUT_SECS, bollard::API_DEFAULT_VERSION) - .map_err(|e| e.to_string()) -} - -#[cfg(windows)] -fn connect(c: &ContainerConnection) -> Result { - Docker::connect_with_named_pipe(&c.socket_path, CONNECT_TIMEOUT_SECS, bollard::API_DEFAULT_VERSION) - .map_err(|e| e.to_string()) -} - -// ── Params helpers — giống devtool-svc-redis.rs ───────────────────────────── - -fn param(params: &serde_json::Value, field: &str) -> Result { - params - .get(field) - .cloned() - .ok_or_else(|| format!("thiếu tham số \"{field}\"")) - .and_then(|v| serde_json::from_value(v).map_err(|e| format!("tham số \"{field}\" sai kiểu: {e}"))) -} - -fn opt_param(params: &serde_json::Value, field: &str) -> Option { - params.get(field).cloned().and_then(|v| serde_json::from_value(v).ok()) -} - -// ── Container details (curated projection) — port nguyên vẹn từ -// container_tool.rs, xem đó cho giải thích lý do curate thay vì passthrough ─ - -#[derive(Debug, Clone, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ContainerMountInfo { - #[serde(rename = "type")] - pub typ: Option, - pub name: Option, - pub source: Option, - pub destination: Option, - pub mode: Option, - pub rw: Option, -} - -#[derive(Debug, Clone, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ContainerPortBinding { - pub container_port: String, - pub host_ip: Option, - pub host_port: Option, -} - -#[derive(Debug, Clone, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ContainerNetworkInfo { - pub name: String, - pub ip_address: Option, - pub gateway: Option, - pub mac_address: Option, -} - -#[derive(Debug, Clone, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ContainerResources { - /// CPU quota in units of 1e-9 CPUs — `--cpus` as the daemon stores it. - pub nano_cpus: Option, - pub cpu_shares: Option, - pub cpu_period: Option, - pub cpu_quota: Option, - pub cpuset_cpus: Option, - pub memory_bytes: Option, - pub memory_reservation_bytes: Option, - /// Total memory + swap. `-1` is docker's "unlimited swap" sentinel and is - /// passed through as-is rather than normalised away. - pub memory_swap_bytes: Option, - pub blkio_weight: Option, - pub pids_limit: Option, - pub restart_policy: Option, - pub restart_max_retry: Option, -} - -/// `0` is what the daemon reports for an unset limit; treat it as absent so -/// the form renders an empty field instead of a literal zero limit. -fn nonzero(v: Option) -> Option { - v.filter(|&n| n != 0) -} - -fn resources_from_host_config(hc: &bollard::models::HostConfig) -> ContainerResources { - let restart_policy = hc.restart_policy.clone().unwrap_or_default(); - ContainerResources { - nano_cpus: nonzero(hc.nano_cpus), - cpu_shares: nonzero(hc.cpu_shares), - cpu_period: nonzero(hc.cpu_period), - cpu_quota: nonzero(hc.cpu_quota), - cpuset_cpus: hc.cpuset_cpus.clone().filter(|s| !s.is_empty()), - memory_bytes: nonzero(hc.memory), - memory_reservation_bytes: nonzero(hc.memory_reservation), - memory_swap_bytes: nonzero(hc.memory_swap), - blkio_weight: hc.blkio_weight.filter(|&w| w != 0), - pids_limit: nonzero(hc.pids_limit), - restart_policy: restart_policy.name.map(|n| n.to_string()), - restart_max_retry: restart_policy.maximum_retry_count, - } -} - -#[derive(Debug, Clone, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ContainerDetails { - pub id: String, - pub name: String, - pub image: String, - pub platform: Option, - pub created: Option, - pub status: Option, - pub running: bool, - pub paused: bool, - pub restarting: bool, - pub exit_code: Option, - pub started_at: Option, - pub finished_at: Option, - pub health_status: Option, - pub command: Option, - pub entrypoint: Vec, - pub restart_policy: Option, - pub restart_max_retry: Option, - pub env: Vec, - pub labels: HashMap, - pub mounts: Vec, - pub ports: Vec, - pub networks: Vec, - pub resources: ContainerResources, -} - -async fn container_details(docker: &Docker, container_id: &str) -> Result { - let insp = docker.inspect_container(container_id, None).await.map_err(|e| e.to_string())?; - - let state = insp.state.unwrap_or_default(); - let cfg = insp.config.unwrap_or_default(); - let host_config = insp.host_config.unwrap_or_default(); - let net = insp.network_settings.unwrap_or_default(); - let resources = resources_from_host_config(&host_config); - let restart_policy = host_config.restart_policy.unwrap_or_default(); - - let mounts = insp - .mounts - .unwrap_or_default() - .into_iter() - .map(|m| ContainerMountInfo { - typ: m.typ, - name: m.name, - source: m.source, - destination: m.destination, - mode: m.mode, - rw: m.rw, - }) - .collect(); - - let ports = net - .ports - .unwrap_or_default() - .into_iter() - .flat_map(|(container_port, bindings)| { - bindings.unwrap_or_default().into_iter().map(move |b| ContainerPortBinding { - container_port: container_port.clone(), - host_ip: b.host_ip, - host_port: b.host_port, - }) - }) - .collect(); - - let mut networks: Vec = net - .networks - .unwrap_or_default() - .into_iter() - .map(|(name, ep)| ContainerNetworkInfo { - name, - ip_address: ep.ip_address, - gateway: ep.gateway, - mac_address: ep.mac_address, - }) - .collect(); - networks.sort_by(|a, b| a.name.cmp(&b.name)); - - Ok(ContainerDetails { - id: insp.id.unwrap_or_default(), - name: insp.name.unwrap_or_default().trim_start_matches('/').to_string(), - image: insp.image.unwrap_or_default(), - platform: insp.platform, - created: insp.created, - status: state.status.map(|s| s.to_string()), - running: state.running.unwrap_or(false), - paused: state.paused.unwrap_or(false), - restarting: state.restarting.unwrap_or(false), - exit_code: state.exit_code, - started_at: state.started_at, - finished_at: state.finished_at, - health_status: state.health.and_then(|h| h.status).map(|s| s.to_string()), - command: cfg.cmd.map(|c| c.join(" ")), - entrypoint: cfg.entrypoint.unwrap_or_default(), - restart_policy: restart_policy.name.map(|n| n.to_string()), - restart_max_retry: restart_policy.maximum_retry_count, - env: cfg.env.unwrap_or_default(), - labels: cfg.labels.unwrap_or_default(), - mounts, - ports, - networks, - resources, - }) -} - -// ── Resource management (docker update) — port nguyên vẹn từ container_tool.rs ─ - -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ContainerResourceUpdate { - pub nano_cpus: Option, - pub cpu_shares: Option, - pub cpu_period: Option, - pub cpu_quota: Option, - pub cpuset_cpus: Option, - pub memory_bytes: Option, - pub memory_reservation_bytes: Option, - pub memory_swap_bytes: Option, - pub blkio_weight: Option, - pub pids_limit: Option, - pub restart_policy: Option, - pub restart_max_retry: Option, -} - -fn restart_policy_from_name(name: &str, max_retry: Option) -> Result { - use bollard::models::RestartPolicyNameEnum as E; - let variant = match name { - "" => E::EMPTY, - "no" => E::NO, - "always" => E::ALWAYS, - "unless-stopped" => E::UNLESS_STOPPED, - "on-failure" => E::ON_FAILURE, - other => return Err(format!("Unknown restart policy \"{other}\"")), - }; - Ok(bollard::models::RestartPolicy { - name: Some(variant), - // Docker rejects a non-zero retry count on anything but on-failure. - maximum_retry_count: if matches!(variant, E::ON_FAILURE) { max_retry.or(Some(0)) } else { Some(0) }, - }) -} - -// ── Images — port nguyên vẹn từ container_tool.rs ─────────────────────────── - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PullProgress { - pub status: String, - pub id: Option, - pub progress_current: Option, - pub progress_total: Option, -} - -#[derive(Debug, Clone, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ImageDetails { - pub id: String, - pub repo_tags: Vec, - pub repo_digests: Vec, - pub created: Option, - pub size: i64, - pub architecture: Option, - pub os: Option, - pub author: Option, - pub cmd: Vec, - pub entrypoint: Vec, - pub env: Vec, - pub working_dir: Option, - pub exposed_ports: Vec, - pub labels: HashMap, - pub layer_count: usize, -} - -// ── Network / volume details (curated projections) — port nguyên vẹn ─────── - -#[derive(Debug, Clone, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct NetworkIpamConfig { - pub subnet: Option, - pub ip_range: Option, - pub gateway: Option, -} - -#[derive(Debug, Clone, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct NetworkDetails { - pub id: String, - pub name: String, - pub driver: Option, - pub scope: Option, - pub created: Option, - pub internal: bool, - pub attachable: bool, - pub ingress: bool, - pub ipv6: bool, - pub ipam_driver: Option, - pub ipam_config: Vec, - pub options: HashMap, - pub labels: HashMap, -} - -#[derive(Debug, Clone, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct VolumeDetails { - pub name: String, - pub driver: String, - pub mountpoint: String, - pub created_at: Option, - pub scope: Option, - pub labels: HashMap, - pub options: HashMap, - /// From the daemon's UsageData, which most drivers leave unset — `-1` is - /// docker's own "not available" marker and is passed through as such. - pub size_bytes: Option, - pub ref_count: Option, -} - -// ── Prune (reclaiming disk) — port nguyên vẹn. Một shape chung cho cả bốn -// endpoint, xem container_tool.rs cho lý do gộp. ───────────────────────── - -#[derive(Debug, Clone, Default, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PruneResult { - pub deleted: usize, - pub space_reclaimed: i64, -} - -/// Per-volume disk usage (bytes), keyed by volume name — port nguyên vẹn từ -/// `container_tool::volume_sizes` (bản Unix). Raw request thủ công qua -/// `hyperlocal`/`hyper-util` trên CÙNG Unix socket `connect()` dùng, vì -/// bollard's typed `df()` response (`SystemDataUsageResponse`) chỉ mang -/// counter tổng hợp, KHÔNG có mảng `Volumes[]` per-item mà `/system/df` JSON -/// thô thực sự trả về — xem comment đầy đủ ở container_tool.rs. -#[cfg(unix)] -async fn volume_sizes(config: &ContainerConnection) -> Result, String> { - use http_body_util::{BodyExt, Full}; - use hyper_util::{client::legacy::Client, rt::TokioExecutor}; - - let path = expand_socket_path(&config.socket_path); - let client: Client> = - Client::builder(TokioExecutor::new()).build(hyperlocal::UnixConnector); - let uri: hyper::Uri = hyperlocal::Uri::new(&path, "/system/df?type=volume").into(); - - let resp = client.get(uri).await.map_err(|e| e.to_string())?; - let body = resp.into_body().collect().await.map_err(|e| e.to_string())?.to_bytes(); - let json: serde_json::Value = serde_json::from_slice(&body).map_err(|e| e.to_string())?; - - let mut sizes = HashMap::new(); - if let Some(volumes) = json.get("Volumes").and_then(|v| v.as_array()) { - for v in volumes { - let name = v.get("Name").and_then(|n| n.as_str()); - let size = v.get("UsageData").and_then(|u| u.get("Size")).and_then(|s| s.as_i64()); - if let (Some(name), Some(size)) = (name, size) { - sizes.insert(name.to_string(), size); - } - } - } - Ok(sizes) -} - -/// Windows named-pipe transport isn't wired up for this raw request (only the -/// Unix socket path is) — port nguyên vẹn từ container_tool.rs: the Size -/// column just shows unknown there. -#[cfg(windows)] -async fn volume_sizes(_config: &ContainerConnection) -> Result, String> { - Ok(HashMap::new()) -} - -// ── Logs/Stats streaming — Bước 3 ──────────────────────────────────────────── -// -// QUYẾT ĐỊNH THIẾT KẾ: MỘT registry nội bộ dùng chung cho cả logs-start VÀ -// stats-start (khác với việc có thể tách hai registry riêng). Lý do: registry -// chỉ cần biết "notify để dừng task nền của subscriptionId này" — nó không -// cần biết subscriptionId đó là một tail log hay một stats stream để làm việc -// đó. subscriptionId do chính sidecar sinh (`Uuid::new_v4()`) nên không có -// khả năng đụng độ giữa hai loại dù dùng chung một map. Tách hai registry chỉ -// thêm một tham số "loại nào" phải xuyên suốt mọi lời gọi mà không mua thêm -// tính đúng đắn nào — `unsubscribe`/`unregister` không bao giờ cần phân biệt. -// `logs-unsubscribe` và `stats-unsubscribe` vẫn là HAI method riêng ở tầng -// giao thức (mirror rõ ràng phía client — một tool có thể dừng đúng loại nó -// đang giữ mà không cần biết registry dùng chung phía dưới), nhưng cả hai gọi -// xuống CÙNG một hàm `unregister_stream`. - -#[derive(Default)] -struct StreamRegistry { - inner: Mutex>>, -} - -static STREAM_REGISTRY: OnceLock = OnceLock::new(); - -fn stream_registry() -> &'static StreamRegistry { - STREAM_REGISTRY.get_or_init(StreamRegistry::default) -} - -fn register_stream(id: &str) -> Arc { - let notify = Arc::new(tokio::sync::Notify::new()); - stream_registry().inner.lock().unwrap().insert(id.to_string(), notify.clone()); - notify -} - -fn unregister_stream(id: &str) { - stream_registry().inner.lock().unwrap().remove(id); -} - -/// Dừng một subscription đang chạy (logs hoặc stats, registry dùng chung) — -/// gọi từ cả `logs-unsubscribe` và `stats-unsubscribe` trong `handle()`. No-op -/// (vẫn `Ok`) nếu `subscriptionId` không tồn tại, giống `unsubscribe` của -/// devtool-svc-redis.rs — không có gì để dừng không phải là lỗi. -fn unsubscribe_stream(subscription_id: &str) { - if let Some(notify) = stream_registry().inner.lock().unwrap().remove(subscription_id) { - notify.notify_one(); - } -} - -/// Gửi lỗi giữa chừng của một method STREAM như một SỰ KIỆN STREAM bình -/// thường (`Response::event`, `error: None` ở tầng khung), KHÔNG BAO GIỜ dùng -/// `Response::err` — `service_host.rs::dispatch()` âm thầm bỏ qua một -/// `ServiceResponse` mang `error: Some(...)` cho một `Waiter::Stream` (xem -/// comment đầy đủ ở `send_pubsub_error`, devtool-svc-redis.rs). Payload -/// `{"type":"error","message":...}` đi qua đúng con đường event mà phía client -/// đang đợi (`"subscribed"` hoặc `"error"`). -fn send_stream_error(tx: &mpsc::UnboundedSender, id: &str, message: impl Into) { - let _ = tx.send(Response::event(id.to_string(), serde_json::json!({ "type": "error", "message": message.into() }))); -} - -/// `image-pull` là method STREAM kiểu "tự kết thúc" (mẫu `tick-stream` của -/// `devtool-svc-echo`, KHÔNG PHẢI mẫu `pubsub-subscribe`/`logs-start` — không -/// cần registry, không ai cần "dừng tay" nó): phát mỗi `PullProgress` như một -/// sự kiện `{"type":"progress",...}` khi `docker.create_image(...)`'s stream -/// nhận được item, rồi khi stream đó tự hết (`None`) gửi một sự kiện -/// `{"type":"done"}` (để `onMessage` phía client biết việc pull đã xong — -/// KHÔNG thể dựa vào bit `done: true` ở tầng khung `Response`, vì -/// `service_host.rs::dispatch()` gỡ waiter và trả về ngay khi thấy -/// `response.done`, không bao giờ gọi `sink.send()` cho chính dòng đó — xem -/// comment ở đó. Method này VẪN gửi thêm một `Response::done` cuối cùng sau -/// sự kiện app-level `"done"`, đúng hợp đồng "một method sidecar coi là -/// stream phải kết bằng `done: true`" — dòng đó bị host lặng lẽ bỏ, vô hại, -/// chỉ để đúng hình thức giao thức, không phải nơi client học được việc đã -/// xong). Lỗi giữa chừng (bao gồm lỗi tham số/config không tồn tại) đi qua -/// đúng con đường `send_stream_error` đã dùng cho logs/stats — sự kiện -/// `{"type":"error","message":...}`. -async fn handle_image_pull(id: String, params: serde_json::Value, tx: mpsc::UnboundedSender) { - let config_id: String = match param(¶ms, "configId") { - Ok(v) => v, - Err(e) => return send_stream_error(&tx, &id, e), - }; - let image: String = match param(¶ms, "image") { - Ok(v) => v, - Err(e) => return send_stream_error(&tx, &id, e), - }; - let tag: String = match param(¶ms, "tag") { - Ok(v) => v, - Err(e) => return send_stream_error(&tx, &id, e), - }; - let config = match find_config(&config_id) { - Ok(c) => c, - Err(e) => return send_stream_error(&tx, &id, e), - }; - let docker = match connect(&config) { - Ok(d) => d, - Err(e) => return send_stream_error(&tx, &id, e), - }; - - let opts = bollard::query_parameters::CreateImageOptions { - from_image: Some(image), - tag: Some(tag), - ..Default::default() - }; - - let mut stream = docker.create_image(Some(opts), None, None); - loop { - match stream.next().await { - Some(Ok(info)) => { - let progress = PullProgress { - status: info.status.unwrap_or_default(), - id: info.id, - progress_current: info.progress_detail.as_ref().and_then(|d| d.current), - progress_total: info.progress_detail.as_ref().and_then(|d| d.total), - }; - let mut event = serde_json::to_value(&progress).unwrap(); - event.as_object_mut().unwrap().insert("type".to_string(), serde_json::json!("progress")); - if tx.send(Response::event(id.clone(), event)).is_err() { - return; // writer task đã thoát — không còn ai đọc - } - } - Some(Err(e)) => { - send_stream_error(&tx, &id, e.to_string()); - return; - } - None => break, - } - } - let _ = tx.send(Response::event(id.clone(), serde_json::json!({ "type": "done" }))); - let _ = tx.send(Response::done(id)); -} - -/// Port nguyên vẹn từ `container_tool::LogLine` — xem đó cho giải thích đầy -/// đủ. `stream`/`message`/`timestamp` giữ nguyên tên field. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct LogLine { - pub stream: String, // "stdout" | "stderr" - pub message: String, - pub timestamp: Option, -} - -/// Port nguyên vẹn từ `container_tool::split_timestamp` — xem đó cho chú -/// thích đầy đủ. -fn split_timestamp(line: &str) -> (Option, &str) { - let Some((head, rest)) = line.split_once(' ') else { return (None, line) }; - let looks_like_ts = head.len() >= 20 - && head.as_bytes()[4] == b'-' - && head.contains('T') - && head[..4].chars().all(|c| c.is_ascii_digit()); - if looks_like_ts { - (Some(head.to_string()), rest) - } else { - (None, line) - } -} - -/// Port nguyên vẹn từ `container_tool::StatsFrame`/`calc_cpu_percent`/ -/// `stats_to_frame` — xem đó cho chú thích đầy đủ. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct StatsFrame { - pub cpu_percent: f64, - pub mem_usage_bytes: u64, - pub mem_limit_bytes: u64, - pub net_rx_bytes: u64, - pub net_tx_bytes: u64, -} - -fn calc_cpu_percent(stats: &bollard::models::ContainerStatsResponse) -> f64 { - let cpu = stats.cpu_stats.as_ref(); - let precpu = stats.precpu_stats.as_ref(); - let (Some(cpu), Some(precpu)) = (cpu, precpu) else { return 0.0 }; - let cpu_total = cpu.cpu_usage.as_ref().and_then(|u| u.total_usage).unwrap_or(0) as f64; - let precpu_total = precpu.cpu_usage.as_ref().and_then(|u| u.total_usage).unwrap_or(0) as f64; - let system = cpu.system_cpu_usage.unwrap_or(0) as f64; - let presystem = precpu.system_cpu_usage.unwrap_or(0) as f64; - let cpu_delta = cpu_total - precpu_total; - let system_delta = system - presystem; - let online_cpus = cpu.online_cpus.filter(|&n| n > 0).unwrap_or(1) as f64; - if system_delta > 0.0 && cpu_delta > 0.0 { - (cpu_delta / system_delta) * online_cpus * 100.0 - } else { - 0.0 - } -} - -fn stats_to_frame(stats: &bollard::models::ContainerStatsResponse) -> StatsFrame { - let mem_usage = stats.memory_stats.as_ref().and_then(|m| m.usage).unwrap_or(0); - let mem_limit = stats.memory_stats.as_ref().and_then(|m| m.limit).unwrap_or(0); - let (rx, tx) = stats - .networks - .as_ref() - .map(|nets| { - nets.values().fold((0u64, 0u64), |(rx, tx), n| { - (rx + n.rx_bytes.unwrap_or(0), tx + n.tx_bytes.unwrap_or(0)) - }) - }) - .unwrap_or((0, 0)); - StatsFrame { - cpu_percent: calc_cpu_percent(stats), - mem_usage_bytes: mem_usage, - mem_limit_bytes: mem_limit, - net_rx_bytes: rx, - net_tx_bytes: tx, - } -} - -const SNAPSHOT_TIMEOUT_SECS: u64 = 10; - -/// `logs-start` là method STREAM — mirror `handle_pubsub_subscribe` của -/// devtool-svc-redis.rs: mở `docker.logs(..., follow: true)` thật, đăng ký -/// vào registry nội bộ, gửi sự kiện đầu `{"type":"subscribed", -/// "subscriptionId":...}` rồi spawn một task nền phát mỗi dòng log nhận được -/// như `{"type":"line", stream, message, timestamp}` — task tự dừng khi -/// `notify` báo (từ `logs-unsubscribe`) hoặc khi `tx.send` bắt đầu lỗi (writer -/// task đã thoát vì host đóng ống). -async fn handle_logs_start(id: String, params: serde_json::Value, tx: mpsc::UnboundedSender) { - let config_id: String = match param(¶ms, "configId") { - Ok(v) => v, - Err(e) => return send_stream_error(&tx, &id, e), - }; - let container_id: String = match param(¶ms, "containerId") { - Ok(v) => v, - Err(e) => return send_stream_error(&tx, &id, e), - }; - let tail: String = opt_param(¶ms, "tail").unwrap_or_else(|| "all".to_string()); - let since: i32 = opt_param(¶ms, "since").unwrap_or(0); - let until: i32 = opt_param(¶ms, "until").unwrap_or(0); - let timestamps: bool = opt_param(¶ms, "timestamps").unwrap_or(false); - - let config = match find_config(&config_id) { - Ok(c) => c, - Err(e) => return send_stream_error(&tx, &id, e), - }; - let docker = match connect(&config) { - Ok(d) => d, - Err(e) => return send_stream_error(&tx, &id, e), - }; - - let opts = bollard::query_parameters::LogsOptions { - follow: true, - stdout: true, - stderr: true, - tail, - since, - until, - timestamps, - }; - - let subscription_id = Uuid::new_v4().to_string(); - let notify = register_stream(&subscription_id); - - if tx - .send(Response::event(id.clone(), serde_json::json!({ "type": "subscribed", "subscriptionId": subscription_id }))) - .is_err() - { - unregister_stream(&subscription_id); - return; - } - - tokio::spawn(async move { - let mut stream = docker.logs(&container_id, Some(opts)); - loop { - tokio::select! { - _ = notify.notified() => break, - next = stream.next() => match next { - Some(Ok(out)) => { - let (stream_name, bytes) = match out { - bollard::container::LogOutput::StdOut { message } => ("stdout", message), - bollard::container::LogOutput::StdErr { message } => ("stderr", message), - bollard::container::LogOutput::Console { message } => ("stdout", message), - bollard::container::LogOutput::StdIn { message } => ("stdin", message), - }; - let raw = String::from_utf8_lossy(&bytes).to_string(); - let raw = raw.strip_suffix('\n').unwrap_or(&raw); - let (ts, message) = if timestamps { split_timestamp(raw) } else { (None, raw) }; - let line = LogLine { stream: stream_name.to_string(), message: message.to_string(), timestamp: ts }; - let mut event = serde_json::to_value(&line).unwrap(); - event.as_object_mut().unwrap().insert("type".to_string(), serde_json::json!("line")); - if tx.send(Response::event(id.clone(), event)).is_err() { - break; // writer task đã thoát — không còn ai đọc - } - } - Some(Err(e)) => { - send_stream_error(&tx, &id, e.to_string()); - break; - } - None => break, - } - } - } - unregister_stream(&subscription_id); - }); -} - -/// `stats-start` là method STREAM — cùng cấu trúc `handle_logs_start`, mở -/// `docker.stats(..., stream: true)` và phát mỗi frame như `{"type":"frame", -/// ...StatsFrame}`. -async fn handle_stats_start(id: String, params: serde_json::Value, tx: mpsc::UnboundedSender) { - let config_id: String = match param(¶ms, "configId") { - Ok(v) => v, - Err(e) => return send_stream_error(&tx, &id, e), - }; - let container_id: String = match param(¶ms, "containerId") { - Ok(v) => v, - Err(e) => return send_stream_error(&tx, &id, e), - }; - let config = match find_config(&config_id) { - Ok(c) => c, - Err(e) => return send_stream_error(&tx, &id, e), - }; - let docker = match connect(&config) { - Ok(d) => d, - Err(e) => return send_stream_error(&tx, &id, e), - }; - - let opts = bollard::query_parameters::StatsOptions { stream: true, ..Default::default() }; - - let subscription_id = Uuid::new_v4().to_string(); - let notify = register_stream(&subscription_id); - - if tx - .send(Response::event(id.clone(), serde_json::json!({ "type": "subscribed", "subscriptionId": subscription_id }))) - .is_err() - { - unregister_stream(&subscription_id); - return; - } - - tokio::spawn(async move { - let mut stream = docker.stats(&container_id, Some(opts)); - loop { - tokio::select! { - _ = notify.notified() => break, - next = stream.next() => match next { - Some(Ok(stats)) => { - let mut event = serde_json::to_value(stats_to_frame(&stats)).unwrap(); - event.as_object_mut().unwrap().insert("type".to_string(), serde_json::json!("frame")); - if tx.send(Response::event(id.clone(), event)).is_err() { - break; - } - } - Some(Err(e)) => { - send_stream_error(&tx, &id, e.to_string()); - break; - } - None => break, - } - } - } - unregister_stream(&subscription_id); - }); -} - -// ── Method dispatch ─────────────────────────────────────────────────────────── - -async fn handle(method: &str, params: serde_json::Value) -> Result { - match method { - "list-configs" => Ok(serde_json::to_value(load_configs()).unwrap()), - - "save-config" => { - let mut config: ContainerConnection = param(¶ms, "config")?; - if config.id.is_empty() { - config.id = Uuid::new_v4().to_string(); - } - let mut configs = load_configs(); - if let Some(pos) = configs.iter().position(|c| c.id == config.id) { - configs[pos] = config.clone(); - } else { - configs.push(config.clone()); - } - save_configs(&configs)?; - Ok(serde_json::to_value(config).unwrap()) - } - - "delete-config" => { - let config_id: String = param(¶ms, "configId")?; - let mut configs = load_configs(); - configs.retain(|c| c.id != config_id); - save_configs(&configs)?; - Ok(serde_json::Value::Null) - } - - "detect-sockets" => Ok(serde_json::to_value(detect_sockets()).unwrap()), - - "test-connection" => { - let config: ContainerConnection = param(¶ms, "config")?; - let docker = connect(&config)?; - docker.ping().await.map_err(|e| e.to_string())?; - Ok(serde_json::Value::Null) - } - - "list" => { - let config_id: String = param(¶ms, "configId")?; - let all: bool = opt_param(¶ms, "all").unwrap_or(false); - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let opts = bollard::query_parameters::ListContainersOptions { all, ..Default::default() }; - let containers = docker.list_containers(Some(opts)).await.map_err(|e| e.to_string())?; - Ok(serde_json::to_value(containers).unwrap()) - } - - "inspect" => { - let config_id: String = param(¶ms, "configId")?; - let container_id: String = param(¶ms, "containerId")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let insp = docker.inspect_container(&container_id, None).await.map_err(|e| e.to_string())?; - Ok(serde_json::to_value(insp).unwrap()) - } - - "start" => { - let config_id: String = param(¶ms, "configId")?; - let container_id: String = param(¶ms, "containerId")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - docker.start_container(&container_id, None).await.map_err(|e| e.to_string())?; - Ok(serde_json::Value::Null) - } - - "stop" => { - let config_id: String = param(¶ms, "configId")?; - let container_id: String = param(¶ms, "containerId")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - docker.stop_container(&container_id, None).await.map_err(|e| e.to_string())?; - Ok(serde_json::Value::Null) - } - - "restart" => { - let config_id: String = param(¶ms, "configId")?; - let container_id: String = param(¶ms, "containerId")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - docker.restart_container(&container_id, None).await.map_err(|e| e.to_string())?; - Ok(serde_json::Value::Null) - } - - "pause" => { - let config_id: String = param(¶ms, "configId")?; - let container_id: String = param(¶ms, "containerId")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - docker.pause_container(&container_id).await.map_err(|e| e.to_string())?; - Ok(serde_json::Value::Null) - } - - "unpause" => { - let config_id: String = param(¶ms, "configId")?; - let container_id: String = param(¶ms, "containerId")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - docker.unpause_container(&container_id).await.map_err(|e| e.to_string())?; - Ok(serde_json::Value::Null) - } - - "remove" => { - let config_id: String = param(¶ms, "configId")?; - let container_id: String = param(¶ms, "containerId")?; - let force: bool = opt_param(¶ms, "force").unwrap_or(false); - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let opts = bollard::query_parameters::RemoveContainerOptions { force, ..Default::default() }; - docker.remove_container(&container_id, Some(opts)).await.map_err(|e| e.to_string())?; - Ok(serde_json::Value::Null) - } - - "details" => { - let config_id: String = param(¶ms, "configId")?; - let container_id: String = param(¶ms, "containerId")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let details = container_details(&docker, &container_id).await?; - Ok(serde_json::to_value(details).unwrap()) - } - - "resources" => { - let config_id: String = param(¶ms, "configId")?; - let container_id: String = param(¶ms, "containerId")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let insp = docker.inspect_container(&container_id, None).await.map_err(|e| e.to_string())?; - let resources = resources_from_host_config(&insp.host_config.unwrap_or_default()); - Ok(serde_json::to_value(resources).unwrap()) - } - - "update-resources" => { - let config_id: String = param(¶ms, "configId")?; - let container_id: String = param(¶ms, "containerId")?; - let resources: ContainerResourceUpdate = param(¶ms, "resources")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let restart_policy = match &resources.restart_policy { - Some(name) => Some(restart_policy_from_name(name, resources.restart_max_retry)?), - None => None, - }; - let body = bollard::models::ContainerUpdateBody { - nano_cpus: resources.nano_cpus, - cpu_shares: resources.cpu_shares, - cpu_period: resources.cpu_period, - cpu_quota: resources.cpu_quota, - cpuset_cpus: resources.cpuset_cpus.clone(), - memory: resources.memory_bytes, - memory_reservation: resources.memory_reservation_bytes, - memory_swap: resources.memory_swap_bytes, - blkio_weight: resources.blkio_weight, - pids_limit: resources.pids_limit, - restart_policy, - ..Default::default() - }; - docker.update_container(&container_id, body).await.map_err(|e| e.to_string())?; - Ok(serde_json::Value::Null) - } - - // ── Images ─────────────────────────────────────────────────────────── - - "image-list" => { - let config_id: String = param(¶ms, "configId")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let opts = bollard::query_parameters::ListImagesOptions { all: false, ..Default::default() }; - let images = docker.list_images(Some(opts)).await.map_err(|e| e.to_string())?; - Ok(serde_json::to_value(images).unwrap()) - } - - "image-inspect" => { - let config_id: String = param(¶ms, "configId")?; - let image_id: String = param(¶ms, "imageId")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let insp = docker.inspect_image(&image_id).await.map_err(|e| e.to_string())?; - Ok(serde_json::to_value(insp).unwrap()) - } - - "image-remove" => { - let config_id: String = param(¶ms, "configId")?; - let image_id: String = param(¶ms, "imageId")?; - let force: bool = opt_param(¶ms, "force").unwrap_or(false); - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let opts = bollard::query_parameters::RemoveImageOptions { force, ..Default::default() }; - docker.remove_image(&image_id, Some(opts), None).await.map_err(|e| e.to_string())?; - Ok(serde_json::Value::Null) - } - - // `image-pull` KHÔNG đi qua đây — nó là method STREAM (Bước 4), xem - // `handle_image_pull` và nhánh dispatch riêng trong `handle_line`. - - "image-details" => { - let config_id: String = param(¶ms, "configId")?; - let image_id: String = param(¶ms, "imageId")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let insp = docker.inspect_image(&image_id).await.map_err(|e| e.to_string())?; - let cfg = insp.config.unwrap_or_default(); - let details = ImageDetails { - id: insp.id.unwrap_or_default(), - repo_tags: insp.repo_tags.unwrap_or_default(), - repo_digests: insp.repo_digests.unwrap_or_default(), - created: insp.created, - size: insp.size.unwrap_or(0), - architecture: insp.architecture, - os: insp.os, - author: insp.author, - cmd: cfg.cmd.unwrap_or_default(), - entrypoint: cfg.entrypoint.unwrap_or_default(), - env: cfg.env.unwrap_or_default(), - working_dir: cfg.working_dir, - exposed_ports: cfg.exposed_ports.unwrap_or_default(), - labels: cfg.labels.unwrap_or_default(), - layer_count: insp.root_fs.and_then(|r| r.layers).map(|l| l.len()).unwrap_or(0), - }; - Ok(serde_json::to_value(details).unwrap()) - } - - "image-tag" => { - let config_id: String = param(¶ms, "configId")?; - let image_id: String = param(¶ms, "imageId")?; - let repo: String = param(¶ms, "repo")?; - let tag: String = param(¶ms, "tag")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let opts = bollard::query_parameters::TagImageOptions { repo: Some(repo), tag: Some(tag) }; - docker.tag_image(&image_id, Some(opts)).await.map_err(|e| e.to_string())?; - Ok(serde_json::Value::Null) - } - - // ── Volumes ────────────────────────────────────────────────────────── - - "volume-list" => { - let config_id: String = param(¶ms, "configId")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let resp = docker - .list_volumes(None::) - .await - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(resp.volumes.unwrap_or_default()).unwrap()) - } - - "volume-remove" => { - let config_id: String = param(¶ms, "configId")?; - let name: String = param(¶ms, "name")?; - let force: bool = opt_param(¶ms, "force").unwrap_or(false); - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let opts = bollard::query_parameters::RemoveVolumeOptions { force, ..Default::default() }; - docker.remove_volume(&name, Some(opts)).await.map_err(|e| e.to_string())?; - Ok(serde_json::Value::Null) - } - - "volume-create" => { - let config_id: String = param(¶ms, "configId")?; - let name: String = param(¶ms, "name")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let req = bollard::models::VolumeCreateRequest { name: Some(name), ..Default::default() }; - let vol = docker.create_volume(req).await.map_err(|e| e.to_string())?; - Ok(serde_json::to_value(vol).unwrap()) - } - - "volume-sizes" => { - let config_id: String = param(¶ms, "configId")?; - let config = find_config(&config_id)?; - let sizes = volume_sizes(&config).await?; - Ok(serde_json::to_value(sizes).unwrap()) - } - - "volume-details" => { - let config_id: String = param(¶ms, "configId")?; - let name: String = param(¶ms, "name")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let vol = docker.inspect_volume(&name).await.map_err(|e| e.to_string())?; - let usage = vol.usage_data; - let details = VolumeDetails { - name: vol.name, - driver: vol.driver, - mountpoint: vol.mountpoint, - created_at: vol.created_at.map(|d| d.to_string()), - scope: vol.scope.map(|s| s.to_string()), - labels: vol.labels, - options: vol.options, - size_bytes: usage.as_ref().map(|u| u.size), - ref_count: usage.as_ref().map(|u| u.ref_count), - }; - Ok(serde_json::to_value(details).unwrap()) - } - - // ── Networks ───────────────────────────────────────────────────────── - - "network-list" => { - let config_id: String = param(¶ms, "configId")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let networks = docker - .list_networks(None::) - .await - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(networks).unwrap()) - } - - "network-remove" => { - let config_id: String = param(¶ms, "configId")?; - let name: String = param(¶ms, "name")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - docker.remove_network(&name).await.map_err(|e| e.to_string())?; - Ok(serde_json::Value::Null) - } - - "network-create" => { - let config_id: String = param(¶ms, "configId")?; - let name: String = param(¶ms, "name")?; - let driver: String = param(¶ms, "driver")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let req = bollard::models::NetworkCreateRequest { name, driver: Some(driver), ..Default::default() }; - docker.create_network(req).await.map_err(|e| e.to_string())?; - Ok(serde_json::Value::Null) - } - - "network-details" => { - let config_id: String = param(¶ms, "configId")?; - let network_id: String = param(¶ms, "networkId")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let net = docker - .inspect_network(&network_id, None::) - .await - .map_err(|e| e.to_string())?; - let ipam = net.ipam.unwrap_or_default(); - let details = NetworkDetails { - id: net.id.unwrap_or_default(), - name: net.name.unwrap_or_default(), - driver: net.driver, - scope: net.scope, - created: net.created.map(|d| d.to_string()), - internal: net.internal.unwrap_or(false), - attachable: net.attachable.unwrap_or(false), - ingress: net.ingress.unwrap_or(false), - ipv6: net.enable_ipv6.unwrap_or(false), - ipam_driver: ipam.driver, - ipam_config: ipam - .config - .unwrap_or_default() - .into_iter() - .map(|c| NetworkIpamConfig { subnet: c.subnet, ip_range: c.ip_range, gateway: c.gateway }) - .collect(), - options: net.options.unwrap_or_default(), - labels: net.labels.unwrap_or_default(), - }; - Ok(serde_json::to_value(details).unwrap()) - } - - // ── Prune ──────────────────────────────────────────────────────────── - - "container-prune" => { - let config_id: String = param(¶ms, "configId")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let resp = docker - .prune_containers(None::) - .await - .map_err(|e| e.to_string())?; - let result = PruneResult { - deleted: resp.containers_deleted.map(|v| v.len()).unwrap_or(0), - space_reclaimed: resp.space_reclaimed.unwrap_or(0), - }; - Ok(serde_json::to_value(result).unwrap()) - } - - "image-prune" => { - let config_id: String = param(¶ms, "configId")?; - let dangling_only: bool = param(¶ms, "danglingOnly")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let mut filters: HashMap> = HashMap::new(); - filters.insert("dangling".to_string(), vec![if dangling_only { "true" } else { "false" }.to_string()]); - let opts = bollard::query_parameters::PruneImagesOptions { filters: Some(filters) }; - let resp = docker.prune_images(Some(opts)).await.map_err(|e| e.to_string())?; - let result = PruneResult { - deleted: resp.images_deleted.map(|v| v.len()).unwrap_or(0), - space_reclaimed: resp.space_reclaimed.unwrap_or(0), - }; - Ok(serde_json::to_value(result).unwrap()) - } - - "volume-prune" => { - let config_id: String = param(¶ms, "configId")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let resp = docker - .prune_volumes(None::) - .await - .map_err(|e| e.to_string())?; - let result = PruneResult { - deleted: resp.volumes_deleted.map(|v| v.len()).unwrap_or(0), - space_reclaimed: resp.space_reclaimed.unwrap_or(0), - }; - Ok(serde_json::to_value(result).unwrap()) - } - - "network-prune" => { - let config_id: String = param(¶ms, "configId")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let resp = docker - .prune_networks(None::) - .await - .map_err(|e| e.to_string())?; - let result = PruneResult { - deleted: resp.networks_deleted.map(|v| v.len()).unwrap_or(0), - space_reclaimed: 0, - }; - Ok(serde_json::to_value(result).unwrap()) - } - - // ── System ─────────────────────────────────────────────────────────── - - "system-info" => { - let config_id: String = param(¶ms, "configId")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let info = docker.info().await.map_err(|e| e.to_string())?; - Ok(serde_json::to_value(info).unwrap()) - } - - "system-df" => { - let config_id: String = param(¶ms, "configId")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let df = docker.df(None).await.map_err(|e| e.to_string())?; - Ok(serde_json::to_value(df).unwrap()) - } - - // ── Bước 3: logs/stats — phần một-lần của method dispatch ──────────── - // `logs-start`/`stats-start` (stream) KHÔNG đi qua đây — xem - // `handle_logs_start`/`handle_stats_start`. - - "logs-unsubscribe" => { - let subscription_id: String = param(¶ms, "subscriptionId")?; - unsubscribe_stream(&subscription_id); - Ok(serde_json::Value::Null) - } - - "stats-unsubscribe" => { - let subscription_id: String = param(¶ms, "subscriptionId")?; - unsubscribe_stream(&subscription_id); - Ok(serde_json::Value::Null) - } - - // Port nguyên vẹn từ `container_tool::container_stats_snapshot` — MỘT - // lần lấy mẫu CPU/memory/network cho một danh sách container (bảng - // containers cần một giá trị mỗi vài giây, không phải một stream sống - // riêng cho mỗi hàng). Container lỗi/timeout đơn giản vắng mặt khỏi map. - "stats-snapshot" => { - let config_id: String = param(¶ms, "configId")?; - let container_ids: Vec = param(¶ms, "containerIds")?; - let config = find_config(&config_id)?; - let docker = connect(&config)?; - let futures = container_ids.into_iter().map(|cid| { - let docker = docker.clone(); - async move { - let opts = bollard::query_parameters::StatsOptions { stream: false, one_shot: false }; - let frame = tokio::time::timeout( - std::time::Duration::from_secs(SNAPSHOT_TIMEOUT_SECS), - docker.stats(&cid, Some(opts)).next(), - ) - .await - .ok() - .flatten() - .and_then(|r| r.ok()) - .map(|stats| stats_to_frame(&stats)); - frame.map(|f| (cid, f)) - } - }); - let frames = futures_util::future::join_all(futures).await; - let map: HashMap = frames.into_iter().flatten().collect(); - Ok(serde_json::to_value(map).unwrap()) - } - - other => Err(format!("method không hỗ trợ: \"{other}\"")), - } -} - -// ── main: đọc stdin, spawn một task async cho mỗi dòng, một task khác sở hữu -// stdout để mọi phản hồi ghi ra không bị xen lẫn giữa hai task — y hệt -// cấu trúc devtool-svc-redis.rs. ───────────────────────────────────────── - -#[tokio::main] -async fn main() { - let (tx, mut rx) = mpsc::unbounded_channel::(); - - let writer = tokio::spawn(async move { - let mut stdout = tokio::io::stdout(); - while let Some(response) = rx.recv().await { - let Ok(json) = serde_json::to_string(&response) else { continue }; - if stdout.write_all(json.as_bytes()).await.is_err() { break; } - if stdout.write_all(b"\n").await.is_err() { break; } - if stdout.flush().await.is_err() { break; } - } - }); - - let stdin = tokio::io::stdin(); - let mut lines = BufReader::new(stdin).lines(); - while let Ok(Some(line)) = lines.next_line().await { - if line.trim().is_empty() { - continue; - } - let tx = tx.clone(); - tokio::spawn(async move { - handle_line(&line, &tx).await; - }); - } - - drop(tx); - let _ = writer.await; -} - -async fn handle_line(line: &str, tx: &mpsc::UnboundedSender) { - let req: Request = match serde_json::from_str(line) { - Ok(r) => r, - Err(e) => { - let _ = tx.send(Response::err(String::new(), format!("JSON không hợp lệ: {e}"))); - return; - } - }; - if req.protocol != SERVICE_PROTOCOL { - let _ = tx.send(Response::err(req.id, format!("lệch protocol: client {}, sidecar {SERVICE_PROTOCOL}", req.protocol))); - return; - } - // `logs-start`/`stats-start` KHÔNG đi qua `handle()` (một-lần) — chúng cần - // giữ `tx` để phát nhiều sự kiện theo thời gian, xem - // `handle_logs_start`/`handle_stats_start`. Mọi method khác vẫn đi qua - // đường một-lần cũ, không đổi hành vi. - if req.method == "logs-start" { - handle_logs_start(req.id, req.params, tx.clone()).await; - return; - } - if req.method == "stats-start" { - handle_stats_start(req.id, req.params, tx.clone()).await; - return; - } - if req.method == "image-pull" { - handle_image_pull(req.id, req.params, tx.clone()).await; - return; - } - let response = match handle(&req.method, req.params).await { - Ok(value) if value.is_null() => Response::ok(req.id), - Ok(value) => Response::once(req.id, value), - Err(e) => Response::err(req.id, e), - }; - let _ = tx.send(response); -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── Test riêng cho phần hạ tầng chỉ có ở sidecar (không có trong container_tool.rs) ─ - - #[test] - fn app_data_dir_bao_loi_ro_rang_khi_thieu_bien_moi_truong() { - // Không set biến môi trường trong test này — nhưng test khác trong - // cùng binary CÓ THỂ chạy song song và set nó, nên chỉ assert khi - // biến thực sự vắng mặt để tránh test chập chờn. - if std::env::var("DEVTOOL_SERVICE_DATA_DIR").is_err() { - assert!(app_data_dir().unwrap_err().contains("DEVTOOL_SERVICE_DATA_DIR")); - } - } - - #[cfg(unix)] - #[test] - fn expand_socket_path_mo_rong_tilde() { - if let Some(home) = std::env::var_os("HOME") { - let home = std::path::PathBuf::from(home); - assert_eq!(expand_socket_path("~/docker.sock"), home.join("docker.sock").to_string_lossy()); - assert_eq!(expand_socket_path("~"), home.to_string_lossy()); - } - } - - #[cfg(unix)] - #[test] - fn expand_socket_path_khong_dung_giu_nguyen() { - assert_eq!(expand_socket_path("/var/run/docker.sock"), "/var/run/docker.sock"); - } - - #[tokio::test] - async fn method_khong_ho_tro_tra_ve_loi_khong_phai_panic() { - let res = handle("khong-ton-tai", serde_json::Value::Null).await; - assert!(res.unwrap_err().contains("khong-ton-tai")); - } - - #[tokio::test] - async fn thieu_tham_so_bat_buoc_tra_ve_loi_ro_rang() { - let res = handle("inspect", serde_json::json!({})).await; - assert!(res.unwrap_err().contains("configId")); - } - - #[tokio::test] - async fn config_id_khong_ton_tai_tra_ve_loi_ro_rang() { - // configs_path() phụ thuộc DEVTOOL_SERVICE_DATA_DIR; nếu thiếu, load_configs() - // rơi về rỗng và find_config vẫn phải trả lỗi rõ ràng, không panic. - let res = handle("inspect", serde_json::json!({ "configId": "khong-ton-tai", "containerId": "x" })).await; - assert!(res.is_err()); - } - - #[tokio::test] - async fn list_thieu_config_id_tra_ve_loi_ro_rang() { - let res = handle("list", serde_json::json!({})).await; - assert!(res.unwrap_err().contains("configId")); - } - - #[tokio::test] - async fn start_thieu_container_id_tra_ve_loi_ro_rang() { - let res = handle("start", serde_json::json!({ "configId": "x" })).await; - assert!(res.unwrap_err().contains("containerId")); - } - - // ── Bước 2: đường lỗi cho container details/resources, image/volume/ - // network CRUD+details, prune, system-info/df — mọi test ở đây chỉ cần - // xác nhận thiếu tham số/config không tồn tại trả lỗi rõ ràng, KHÔNG - // panic; xác nhận thật với daemon thật (image-list/volume-list/ - // network-list/system-info/system-df/details/resources) làm riêng qua - // script JSONL tạm, không phải trong test suite này. - - #[tokio::test] - async fn details_thieu_config_id_tra_ve_loi_ro_rang() { - let res = handle("details", serde_json::json!({ "containerId": "x" })).await; - assert!(res.unwrap_err().contains("configId")); - } - - #[tokio::test] - async fn details_config_khong_ton_tai_tra_ve_loi_ro_rang() { - let res = handle("details", serde_json::json!({ "configId": "khong-ton-tai", "containerId": "x" })).await; - assert!(res.is_err()); - } - - #[tokio::test] - async fn resources_thieu_container_id_tra_ve_loi_ro_rang() { - let res = handle("resources", serde_json::json!({ "configId": "x" })).await; - assert!(res.unwrap_err().contains("containerId")); - } - - #[tokio::test] - async fn update_resources_thieu_resources_tra_ve_loi_ro_rang() { - let res = - handle("update-resources", serde_json::json!({ "configId": "x", "containerId": "y" })).await; - assert!(res.unwrap_err().contains("resources")); - } - - #[tokio::test] - async fn image_list_thieu_config_id_tra_ve_loi_ro_rang() { - let res = handle("image-list", serde_json::json!({})).await; - assert!(res.unwrap_err().contains("configId")); - } - - #[tokio::test] - async fn image_inspect_thieu_image_id_tra_ve_loi_ro_rang() { - let res = handle("image-inspect", serde_json::json!({ "configId": "x" })).await; - assert!(res.unwrap_err().contains("imageId")); - } - - #[tokio::test] - async fn image_remove_config_khong_ton_tai_tra_ve_loi_ro_rang() { - let res = - handle("image-remove", serde_json::json!({ "configId": "khong-ton-tai", "imageId": "x" })).await; - assert!(res.is_err()); - } - - // `image-pull` là method STREAM (Bước 4, không còn đi qua `handle()`) — - // test đường lỗi mirror `logs_start_*`/`stats_start_*`: lỗi phải đi qua - // như một SỰ KIỆN STREAM (`{"type":"error",...}`), không phải - // `response.error`. - - #[tokio::test] - async fn image_pull_thieu_tag_bao_loi_qua_stream_event() { - let (tx, mut rx) = mpsc::unbounded_channel::(); - handle_image_pull("req-7".to_string(), serde_json::json!({ "configId": "x", "image": "y" }), tx).await; - let response = rx.recv().await.expect("phải gửi đúng một sự kiện lỗi"); - assert!(response.error.is_none(), "lỗi phải đi qua stream event, không phải response.error"); - assert!(response.stream); - let event = response.result.expect("sự kiện lỗi phải mang result"); - assert_eq!(event["type"], "error"); - assert!(event["message"].as_str().unwrap().contains("tag")); - } - - #[tokio::test] - async fn image_pull_config_khong_ton_tai_bao_loi_ro_rang() { - let (tx, mut rx) = mpsc::unbounded_channel::(); - handle_image_pull( - "req-8".to_string(), - serde_json::json!({ "configId": "khong-ton-tai-chac-chan", "image": "alpine", "tag": "latest" }), - tx, - ) - .await; - let response = rx.recv().await.expect("phải gửi đúng một sự kiện lỗi"); - assert!(response.error.is_none()); - assert_eq!(response.result.unwrap()["type"], "error"); - } - - #[tokio::test] - async fn image_details_thieu_image_id_tra_ve_loi_ro_rang() { - let res = handle("image-details", serde_json::json!({ "configId": "x" })).await; - assert!(res.unwrap_err().contains("imageId")); - } - - #[tokio::test] - async fn image_tag_thieu_repo_tra_ve_loi_ro_rang() { - let res = handle("image-tag", serde_json::json!({ "configId": "x", "imageId": "y", "tag": "z" })).await; - assert!(res.unwrap_err().contains("repo")); - } - - #[tokio::test] - async fn volume_list_thieu_config_id_tra_ve_loi_ro_rang() { - let res = handle("volume-list", serde_json::json!({})).await; - assert!(res.unwrap_err().contains("configId")); - } - - #[tokio::test] - async fn volume_remove_thieu_name_tra_ve_loi_ro_rang() { - let res = handle("volume-remove", serde_json::json!({ "configId": "x" })).await; - assert!(res.unwrap_err().contains("name")); - } - - #[tokio::test] - async fn volume_create_thieu_name_tra_ve_loi_ro_rang() { - let res = handle("volume-create", serde_json::json!({ "configId": "x" })).await; - assert!(res.unwrap_err().contains("name")); - } - - #[tokio::test] - async fn volume_sizes_thieu_config_id_tra_ve_loi_ro_rang() { - let res = handle("volume-sizes", serde_json::json!({})).await; - assert!(res.unwrap_err().contains("configId")); - } - - #[tokio::test] - async fn volume_details_config_khong_ton_tai_tra_ve_loi_ro_rang() { - let res = - handle("volume-details", serde_json::json!({ "configId": "khong-ton-tai", "name": "x" })).await; - assert!(res.is_err()); - } - - #[tokio::test] - async fn network_list_thieu_config_id_tra_ve_loi_ro_rang() { - let res = handle("network-list", serde_json::json!({})).await; - assert!(res.unwrap_err().contains("configId")); - } - - #[tokio::test] - async fn network_remove_thieu_name_tra_ve_loi_ro_rang() { - let res = handle("network-remove", serde_json::json!({ "configId": "x" })).await; - assert!(res.unwrap_err().contains("name")); - } - - #[tokio::test] - async fn network_create_thieu_driver_tra_ve_loi_ro_rang() { - let res = handle("network-create", serde_json::json!({ "configId": "x", "name": "y" })).await; - assert!(res.unwrap_err().contains("driver")); - } - - #[tokio::test] - async fn network_details_thieu_network_id_tra_ve_loi_ro_rang() { - let res = handle("network-details", serde_json::json!({ "configId": "x" })).await; - assert!(res.unwrap_err().contains("networkId")); - } - - #[tokio::test] - async fn container_prune_thieu_config_id_tra_ve_loi_ro_rang() { - let res = handle("container-prune", serde_json::json!({})).await; - assert!(res.unwrap_err().contains("configId")); - } - - #[tokio::test] - async fn image_prune_thieu_dangling_only_tra_ve_loi_ro_rang() { - let res = handle("image-prune", serde_json::json!({ "configId": "x" })).await; - assert!(res.unwrap_err().contains("danglingOnly")); - } - - #[tokio::test] - async fn volume_prune_config_khong_ton_tai_tra_ve_loi_ro_rang() { - let res = handle("volume-prune", serde_json::json!({ "configId": "khong-ton-tai" })).await; - assert!(res.is_err()); - } - - #[tokio::test] - async fn network_prune_config_khong_ton_tai_tra_ve_loi_ro_rang() { - let res = handle("network-prune", serde_json::json!({ "configId": "khong-ton-tai" })).await; - assert!(res.is_err()); - } - - #[tokio::test] - async fn system_info_thieu_config_id_tra_ve_loi_ro_rang() { - let res = handle("system-info", serde_json::json!({})).await; - assert!(res.unwrap_err().contains("configId")); - } - - #[tokio::test] - async fn system_df_config_khong_ton_tai_tra_ve_loi_ro_rang() { - let res = handle("system-df", serde_json::json!({ "configId": "khong-ton-tai" })).await; - assert!(res.is_err()); - } - - #[test] - fn detect_sockets_khong_panic_va_tra_ve_vec() { - // Best-effort probe, không đảm bảo tìm thấy gì trên máy CI — chỉ cần - // không panic và trả về một Vec hợp lệ (có thể rỗng). - let found = detect_sockets(); - assert!(found.len() < 1000); // sanity: không phải giá trị rác/loop vô hạn - } - - /// `handle_line` trả về qua kênh `tx` — test dựng một kênh riêng và đọc - /// lại đúng một `Response`, giống mẫu ở devtool-svc-redis.rs. - async fn call_handle_line(line: &str) -> Response { - let (tx, mut rx) = mpsc::unbounded_channel::(); - handle_line(line, &tx).await; - drop(tx); - rx.recv().await.expect("handle_line phải gửi đúng một response") - } - - #[tokio::test] - async fn handle_line_tra_response_hop_le_cho_method_mot_lan() { - let line = serde_json::json!({ - "protocol": SERVICE_PROTOCOL, "id": "1", "method": "delete-config", - "params": { "configId": "khong-quan-trong" }, - }) - .to_string(); - let response = call_handle_line(&line).await; - assert_eq!(response.id, "1"); - assert_eq!(response.protocol, SERVICE_PROTOCOL); - } - - #[tokio::test] - async fn lech_protocol_bi_tu_choi_truoc_khi_cham_toi_method() { - let line = serde_json::json!({ "protocol": 99, "id": "1", "method": "list-configs", "params": null }).to_string(); - let response = call_handle_line(&line).await; - assert!(response.error.unwrap().contains("lệch protocol")); - } - - #[tokio::test] - async fn json_hong_khong_panic_tra_ve_loi() { - let response = call_handle_line("{khong-phai-json").await; - assert!(response.error.unwrap().contains("JSON không hợp lệ")); - } - - #[tokio::test] - async fn method_la_tra_ve_loi_ro_rang() { - let line = serde_json::json!({ "protocol": SERVICE_PROTOCOL, "id": "9", "method": "khong-ton-tai", "params": null }).to_string(); - let response = call_handle_line(&line).await; - assert!(response.error.unwrap().contains("khong-ton-tai")); - } - - // ── Bước 3: đường lỗi cho logs-start/stats-start (stream), *-unsubscribe, - // stats-snapshot — mirror phong cách `pubsub_subscribe_*` của - // devtool-svc-redis.rs. Xác nhận thật với daemon thật (nhận sự kiện - // "subscribed"/"line"/"frame") làm riêng qua script JSONL tạm, không - // phải trong test suite này. - - #[tokio::test] - async fn logs_start_thieu_config_id_bao_loi_qua_stream_event_khong_phai_response_err() { - // Lỗi phải đi qua như một SỰ KIỆN STREAM (`response.error.is_none()`, - // `stream: true`, payload `{"type":"error",...}`) — KHÔNG phải - // `response.error: Some(...)` ở tầng khung tin nhắn, vì - // `service_host.rs::dispatch()` âm thầm bỏ qua lỗi tầng khung cho một - // `Waiter::Stream` (xem comment ở `send_stream_error`). - let (tx, mut rx) = mpsc::unbounded_channel::(); - handle_logs_start("req-1".to_string(), serde_json::json!({ "containerId": "x" }), tx).await; - let response = rx.recv().await.expect("phải gửi đúng một sự kiện lỗi"); - assert_eq!(response.id, "req-1"); - assert!(response.error.is_none(), "lỗi phải đi qua stream event, không phải response.error"); - assert!(response.stream); - let event = response.result.expect("sự kiện lỗi phải mang result"); - assert_eq!(event["type"], "error"); - assert!(event["message"].as_str().unwrap().contains("configId")); - } - - #[tokio::test] - async fn logs_start_thieu_container_id_bao_loi_ro_rang() { - let (tx, mut rx) = mpsc::unbounded_channel::(); - handle_logs_start("req-2".to_string(), serde_json::json!({ "configId": "x" }), tx).await; - let response = rx.recv().await.expect("phải gửi đúng một sự kiện lỗi"); - assert!(response.error.is_none()); - let event = response.result.unwrap(); - assert_eq!(event["type"], "error"); - assert!(event["message"].as_str().unwrap().contains("containerId")); - } - - #[tokio::test] - async fn logs_start_config_khong_ton_tai_bao_loi_ro_rang() { - let (tx, mut rx) = mpsc::unbounded_channel::(); - handle_logs_start( - "req-3".to_string(), - serde_json::json!({ "configId": "khong-ton-tai-chac-chan", "containerId": "x" }), - tx, - ) - .await; - let response = rx.recv().await.expect("phải gửi đúng một sự kiện lỗi"); - assert!(response.error.is_none()); - assert_eq!(response.result.unwrap()["type"], "error"); - } - - #[tokio::test] - async fn stats_start_thieu_config_id_bao_loi_qua_stream_event() { - let (tx, mut rx) = mpsc::unbounded_channel::(); - handle_stats_start("req-4".to_string(), serde_json::json!({ "containerId": "x" }), tx).await; - let response = rx.recv().await.expect("phải gửi đúng một sự kiện lỗi"); - assert!(response.error.is_none()); - assert!(response.stream); - let event = response.result.unwrap(); - assert_eq!(event["type"], "error"); - assert!(event["message"].as_str().unwrap().contains("configId")); - } - - #[tokio::test] - async fn stats_start_thieu_container_id_bao_loi_ro_rang() { - let (tx, mut rx) = mpsc::unbounded_channel::(); - handle_stats_start("req-5".to_string(), serde_json::json!({ "configId": "x" }), tx).await; - let response = rx.recv().await.expect("phải gửi đúng một sự kiện lỗi"); - assert!(response.error.is_none()); - assert!(response.result.unwrap()["message"].as_str().unwrap().contains("containerId")); - } - - #[tokio::test] - async fn stats_start_config_khong_ton_tai_bao_loi_ro_rang() { - let (tx, mut rx) = mpsc::unbounded_channel::(); - handle_stats_start( - "req-6".to_string(), - serde_json::json!({ "configId": "khong-ton-tai-chac-chan", "containerId": "x" }), - tx, - ) - .await; - let response = rx.recv().await.expect("phải gửi đúng một sự kiện lỗi"); - assert!(response.error.is_none()); - assert_eq!(response.result.unwrap()["type"], "error"); - } - - #[tokio::test] - async fn logs_unsubscribe_id_khong_ton_tai_van_ok_khong_panic() { - let res = handle("logs-unsubscribe", serde_json::json!({ "subscriptionId": "khong-ton-tai" })).await; - assert!(res.is_ok()); - } - - #[tokio::test] - async fn logs_unsubscribe_thao_dung_entry_khoi_registry_va_bao_notify() { - let id = "logs-sub-test-1".to_string(); - let notify = register_stream(&id); - let notified = tokio::spawn({ - let notify = notify.clone(); - async move { notify.notified().await } - }); - let res = handle("logs-unsubscribe", serde_json::json!({ "subscriptionId": id })).await; - assert!(res.is_ok()); - tokio::time::timeout(std::time::Duration::from_secs(2), notified).await.expect("notify phải bắn").unwrap(); - assert!(!stream_registry().inner.lock().unwrap().contains_key(&id)); - } - - #[tokio::test] - async fn stats_unsubscribe_id_khong_ton_tai_van_ok_khong_panic() { - let res = handle("stats-unsubscribe", serde_json::json!({ "subscriptionId": "khong-ton-tai" })).await; - assert!(res.is_ok()); - } - - #[tokio::test] - async fn stats_unsubscribe_thao_dung_entry_khoi_registry_va_bao_notify() { - let id = "stats-sub-test-1".to_string(); - let notify = register_stream(&id); - let notified = tokio::spawn({ - let notify = notify.clone(); - async move { notify.notified().await } - }); - let res = handle("stats-unsubscribe", serde_json::json!({ "subscriptionId": id })).await; - assert!(res.is_ok()); - tokio::time::timeout(std::time::Duration::from_secs(2), notified).await.expect("notify phải bắn").unwrap(); - assert!(!stream_registry().inner.lock().unwrap().contains_key(&id)); - } - - #[tokio::test] - async fn logs_unsubscribe_thieu_subscription_id_tra_ve_loi_ro_rang() { - let res = handle("logs-unsubscribe", serde_json::json!({})).await; - assert!(res.unwrap_err().contains("subscriptionId")); - } - - #[tokio::test] - async fn stats_unsubscribe_thieu_subscription_id_tra_ve_loi_ro_rang() { - let res = handle("stats-unsubscribe", serde_json::json!({})).await; - assert!(res.unwrap_err().contains("subscriptionId")); - } - - #[tokio::test] - async fn stats_snapshot_thieu_config_id_tra_ve_loi_ro_rang() { - let res = handle("stats-snapshot", serde_json::json!({ "containerIds": [] })).await; - assert!(res.unwrap_err().contains("configId")); - } - - #[tokio::test] - async fn stats_snapshot_thieu_container_ids_tra_ve_loi_ro_rang() { - let res = handle("stats-snapshot", serde_json::json!({ "configId": "x" })).await; - assert!(res.unwrap_err().contains("containerIds")); - } - - #[tokio::test] - async fn stats_snapshot_config_khong_ton_tai_tra_ve_loi_ro_rang() { - let res = handle( - "stats-snapshot", - serde_json::json!({ "configId": "khong-ton-tai-chac-chan", "containerIds": ["x"] }), - ) - .await; - assert!(res.is_err()); - } - - #[test] - fn split_timestamp_tach_dung_dinh_dang_rfc3339() { - let (ts, rest) = split_timestamp("2026-08-26T09:41:02.123456789Z hello world"); - assert_eq!(ts.as_deref(), Some("2026-08-26T09:41:02.123456789Z")); - assert_eq!(rest, "hello world"); - } - - #[test] - fn split_timestamp_khong_tach_dong_khong_bat_dau_bang_timestamp() { - let (ts, rest) = split_timestamp("hello world"); - assert_eq!(ts, None); - assert_eq!(rest, "hello world"); - } -} diff --git a/src-tauri/src/bin/devtool-svc-rabbit.rs b/src-tauri/src/bin/devtool-svc-rabbit.rs deleted file mode 100644 index e1223f19..00000000 --- a/src-tauri/src/bin/devtool-svc-rabbit.rs +++ /dev/null @@ -1,1558 +0,0 @@ -// Plugin dịch vụ (tier B) cho RabbitMQ Client — Bước 1+2 gộp của Phase 2 (xem -// docs/decisions/architecture/platform-plugin-architecture.md, mục "Ba tier" và "Cách ly -// dữ liệu"). Port từ src-tauri/src/rabbit.rs, giữ nguyên HÌNH DẠNG dữ liệu -// (JSON camelCase, struct `RabbitConnection`/`PublishProps`/`PublishOutcome`/ -// ...) nhưng KHÔNG đụng file đó — nó vẫn chạy production (Tier A) song song -// cho tới Bước 4/5 cắt hẳn, đúng mẫu `devtool-svc-redis.rs`/ -// `devtool-svc-container.rs` đã làm cho Redis/Container. -// -// Khác với bản Tier A (`rabbit.rs`) đúng những chỗ là hệ quả của việc chạy -// như tiến trình riêng, không có `AppHandle`: -// - Config CRUD đọc/ghi `/connections.json` (biến -// môi trường `service_host.rs::get_or_spawn` set khi spawn) thay vì -// `plugin_data::plugin_data_dir(app, "rabbit-client")` — thư mục này ĐÃ -// được cách ly riêng cho sidecar này -// (`/service-data/devtool-svc-rabbit/`), KHÁC -// `plugin-data/rabbit-client/connections.json` mà Tier A dùng, có chủ ý — -// xem "Cách ly dữ liệu" trong ADR. KHÔNG di trú dữ liệu cũ: chỉ là cấu -// hình kết nối (host/port/thông tin đăng nhập/TLS), người dùng tự nhập -// lại được. -// - main() spawn một task async cho mỗi dòng stdin vào, một task riêng sở -// hữu stdout qua kênh mpsc — y hệt cấu trúc `devtool-svc-redis.rs`/ -// `devtool-svc-container.rs`, vì nhiều lời gọi có thể chồng lên nhau trên -// CÙNG một sidecar (host demux theo `id`, xem `service_host.rs`). -// -// Bước 1+2 (đây, gộp vì RabbitMQ nhỏ hơn Redis/Container — chỉ 12 command -// tổng): config CRUD, `amqp-test`, `rpc-call`, `publish` (full-feature: -// properties + mandatory + publisher confirms), `amqp-queues-info`/ -// `amqp-exchanges-info` (passive declare cho count, dùng ở chế độ -// `amqp_only`), `amqp-declare-queue`/`amqp-declare-exchange`/`amqp-bind-queue`. -// Mọi method ở bước này là MỘT-LẦN (đi qua `handle()`), không có method -// stream nào. -// -// Bước 3 (đây): `consume-start` (method STREAM) + `consume-stop` (một-lần). -// Port từ `rabbit_consume_start`/`rabbit_consume_stop` của rabbit.rs (dòng -// ~556-659) — giữ NGUYÊN logic ba `ack_mode` ("peek"/"consume"/"respond") và -// việc giữ `_keep_conn`/`channel` sống trong task nền suốt vòng đời consumer. -// Giao thức stream mirror `handle_logs_start` của `devtool-svc-container.rs` -// (chính nó mirror `handle_pubsub_subscribe` của `devtool-svc-redis.rs`): sự -// kiện đầu `{"type":"subscribed","subscriptionId":...}`, mỗi message nhận -// được là `{"type":"message", ...ConsumedMessage}`, lỗi giữa chừng qua -// `send_stream_error` (SỰ KIỆN STREAM bình thường) KHÔNG BAO GIỜ dùng -// `Response::err` — `service_host.rs::dispatch()` âm thầm bỏ payload của một -// response mang `error: Some` cho một `Waiter::Stream`. Đăng ký/gỡ đăng ký -// dùng CÙNG một `StreamRegistry` kiểu `Arc>>` như -// hai sidecar kia — subscriptionId do chính sidecar sinh (`Uuid::new_v4()`) -// nên một registry chung là đủ dù sau này có thêm method stream khác. - -use base64::Engine; -use futures_util::StreamExt; -use lapin::options::{ - BasicAckOptions, BasicConsumeOptions, BasicPublishOptions, BasicQosOptions, ConfirmSelectOptions, - ExchangeDeclareOptions, QueueBindOptions, QueueDeclareOptions, -}; -use lapin::tcp::{OwnedIdentity, OwnedTLSConfig}; -use lapin::types::{AMQPValue, FieldTable}; -use lapin::{BasicProperties, Connection, ConnectionProperties, ExchangeKind}; -use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; -use serde::{Deserialize, Deserializer, Serialize}; -use std::collections::{BTreeMap, HashMap}; -use std::path::PathBuf; -use std::sync::{Arc, Mutex, OnceLock}; -use std::time::Duration; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::sync::mpsc; -use uuid::Uuid; - -const SERVICE_PROTOCOL: u32 = 1; - -// ── Khung tin nhắn (giống devtool-svc-redis/devtool-svc-container, lặp lại có -// chủ ý — xem lý do ở đó) ─────────────────────────────────────────────── - -#[derive(Deserialize)] -struct Request { - protocol: u32, - id: String, - #[serde(default)] - method: String, - #[serde(default)] - params: serde_json::Value, -} - -fn is_false(b: &bool) -> bool { - !*b -} - -#[derive(Serialize)] -struct Response { - protocol: u32, - id: String, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, - #[serde(default, skip_serializing_if = "is_false")] - stream: bool, - #[serde(default, skip_serializing_if = "is_false")] - done: bool, -} - -impl Response { - fn once(id: String, result: serde_json::Value) -> Self { - Self { protocol: SERVICE_PROTOCOL, id, result: Some(result), error: None, stream: false, done: false } - } - fn ok(id: String) -> Self { - Self::once(id, serde_json::Value::Null) - } - fn err(id: String, message: impl Into) -> Self { - Self { protocol: SERVICE_PROTOCOL, id, result: None, error: Some(message.into()), stream: false, done: false } - } - /// Một-trong-nhiều sự kiện của cùng một lời gọi stream (`consume-start`) — - /// không bao giờ đi kèm `done: true`: một consumer sống tới khi bị dừng - /// tay bằng `consume-stop`, giống `logs-start`/`stats-start` của - /// `devtool-svc-container.rs`. - fn event(id: String, result: serde_json::Value) -> Self { - Self { protocol: SERVICE_PROTOCOL, id, result: Some(result), error: None, stream: true, done: false } - } -} - -// ── Connection profile — giữ nguyên hình dạng camelCase của rabbit.rs ─────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RabbitConnection { - pub id: String, - pub name: String, - pub host: String, - pub port: u16, - pub vhost: String, - pub username: String, - pub password: String, - pub use_tls: bool, - /// AMQP port (default 5672) — used by publish / consume / request-response. - #[serde(default = "default_amqp_port")] - pub amqp_port: u16, - /// Extra AMQP endpoints to try, in order, if the primary host can't be reached - /// (HA clusters / no load balancer). Each entry is `host` or `host:port`; - /// without a port it falls back to `amqp_port`. The frontend sends `null` when - /// empty, so accept that as an empty list (plain `default` only covers a - /// *missing* field, not an explicit `null`). - #[serde(default, deserialize_with = "null_as_default")] - pub extra_hosts: Vec, - /// Custom CA certificate (PEM) to trust — for self-signed / private brokers. - #[serde(default)] - pub tls_ca_pem: Option, - /// Client identity for mutual-TLS: a PKCS#12 bundle, base64-encoded. - #[serde(default)] - pub client_pkcs12_b64: Option, - #[serde(default)] - pub client_pkcs12_password: Option, - /// AMQP heartbeat interval in seconds (omitted → server default). - #[serde(default)] - pub heartbeat: Option, - /// Client-provided connection name (shows in the broker's Connections list). - #[serde(default)] - pub connection_name: Option, - /// AMQP-only mode: the broker exposes no management HTTP API, so the tool - /// works off typed queue/exchange names (passive declare for counts) and - /// declares/binds over AMQP instead of the REST API. - #[serde(default)] - pub amqp_only: bool, -} - -fn default_amqp_port() -> u16 { - 5672 -} - -/// Deserialize a value that may be `null` into `T::default()` (e.g. a `null` JSON -/// array field → an empty `Vec`). Plain `#[serde(default)]` only handles a field -/// that is *absent*, not one explicitly set to `null`. -fn null_as_default<'de, D, T>(deserializer: D) -> Result -where - D: Deserializer<'de>, - T: Default + Deserialize<'de>, -{ - Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RpcReply { - pub payload: String, - pub correlation_id: Option, - pub content_type: Option, -} - -/// AMQP message properties supplied by the publish form. -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PublishProps { - pub content_type: Option, - pub content_encoding: Option, - pub correlation_id: Option, - pub reply_to: Option, - pub message_id: Option, - #[serde(rename = "type")] - pub kind: Option, - pub app_id: Option, - pub user_id: Option, - pub expiration: Option, - pub priority: Option, - pub persistent: Option, - pub headers: Option>, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PublishOutcome { - pub confirmed: bool, - pub routed: bool, - pub return_reason: Option, -} - -/// Port nguyên vẹn từ `rabbit.rs::ConsumedMessage` — một message nhận được từ -/// `consume-start`, phát như sự kiện `{"type":"message", ...}`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ConsumedMessage { - pub payload: String, - pub exchange: String, - pub routing_key: String, - pub redelivered: bool, - pub delivery_tag: u64, - pub correlation_id: Option, - pub content_type: Option, - pub message_id: Option, - pub headers: BTreeMap, -} - -/// Port nguyên vẹn từ `rabbit.rs::ReplyOptions` — cấu hình auto-reply cho -/// `ackMode: "respond"` (tool đóng vai RPC server). -#[derive(Debug, Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ReplyOptions { - /// Reply with the request's own body instead of `payload`. - pub echo: bool, - pub payload: String, - pub content_type: Option, -} - -// ── Config persistence ─────────────────────────────────────────────────────── -// -// Lưu trong thư mục RIÊNG của sidecar này (`DEVTOOL_SERVICE_DATA_DIR` — -// `/service-data/devtool-svc-rabbit/`), KHÁC -// `plugin-data/rabbit-client/connections.json` mà `rabbit.rs` (Tier A, vẫn -// đang chạy song song) đang dùng. Cách ly bằng thư mục, không phải bằng tên -// file — xem module doc comment ở trên và ADR mục "Cách ly dữ liệu". - -fn app_data_dir() -> Result { - std::env::var("DEVTOOL_SERVICE_DATA_DIR") - .map(PathBuf::from) - .map_err(|_| "DEVTOOL_SERVICE_DATA_DIR không được set — sidecar phải chạy qua service_host.rs".to_string()) -} - -fn configs_path() -> Result { - Ok(app_data_dir()?.join("connections.json")) -} - -fn load_configs() -> Vec { - let Ok(path) = configs_path() else { return Vec::new() }; - std::fs::read_to_string(&path).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default() -} - -fn save_configs(configs: &[RabbitConnection]) -> Result<(), String> { - let path = configs_path()?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - let json = serde_json::to_string_pretty(configs).map_err(|e| e.to_string())?; - std::fs::write(&path, json).map_err(|e| e.to_string()) -} - -fn find_config(config_id: &str) -> Result { - load_configs().into_iter().find(|c| c.id == config_id).ok_or_else(|| format!("Connection '{}' not found", config_id)) -} - -// ── Params helpers — giống devtool-svc-redis.rs / devtool-svc-container.rs ── - -fn param(params: &serde_json::Value, field: &str) -> Result { - params - .get(field) - .cloned() - .ok_or_else(|| format!("thiếu tham số \"{field}\"")) - .and_then(|v| serde_json::from_value(v).map_err(|e| format!("tham số \"{field}\" sai kiểu: {e}"))) -} - -fn opt_param(params: &serde_json::Value, field: &str) -> Option { - params.get(field).cloned().and_then(|v| serde_json::from_value(v).ok()) -} - -// ── AMQP connection — port nguyên vẹn từ rabbit.rs ────────────────────────── - -fn nonempty(o: &Option) -> Option { - o.as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) -} - -/// Build the AMQP URI for a specific endpoint. vhost "/" is percent-encoded to -/// "%2F" as RabbitMQ requires. Credentials/vhost come from the profile; only the -/// host:port varies across an HA cluster's endpoints. -fn amqp_uri_for(config: &RabbitConnection, host: &str, port: u16) -> String { - let scheme = if config.use_tls { "amqps" } else { "amqp" }; - let user = utf8_percent_encode(&config.username, NON_ALPHANUMERIC); - let pass = utf8_percent_encode(&config.password, NON_ALPHANUMERIC); - let vhost = utf8_percent_encode(&config.vhost, NON_ALPHANUMERIC); - let mut uri = format!("{scheme}://{user}:{pass}@{host}:{port}/{vhost}"); - if let Some(hb) = config.heartbeat { - uri.push_str(&format!("?heartbeat={hb}")); - } - uri -} - -/// The ordered list of `(host, port)` endpoints to try: the primary first, then -/// each `extra_hosts` entry (`host` or `host:port`, defaulting to `amqp_port`). -/// Blank entries are skipped. -fn endpoints(config: &RabbitConnection) -> Vec<(String, u16)> { - let mut out = vec![(config.host.clone(), config.amqp_port)]; - for raw in &config.extra_hosts { - let entry = raw.trim(); - if entry.is_empty() { - continue; - } - // Split host:port on the LAST ':' so IPv6-in-brackets still parses host-only. - match entry.rsplit_once(':') { - Some((h, p)) if !h.is_empty() && p.parse::().is_ok() => { - out.push((h.to_string(), p.parse().unwrap())); - } - _ => out.push((entry.to_string(), config.amqp_port)), - } - } - out -} - -fn build_identity(config: &RabbitConnection) -> Result, String> { - match nonempty(&config.client_pkcs12_b64) { - None => Ok(None), - Some(b64) => { - let der = base64::engine::general_purpose::STANDARD - .decode(b64.replace(['\n', '\r', ' '], "")) - .map_err(|e| format!("Client identity (PKCS#12) is not valid base64: {e}"))?; - Ok(Some(OwnedIdentity::PKCS12 { - der, - password: config.client_pkcs12_password.clone().unwrap_or_default(), - })) - } - } -} - -/// How long to wait for the TCP+TLS+AMQP handshake before giving up. Without a -/// bound, a wrong host/port, a firewall that drops packets, or an unreachable -/// cluster node makes the handshake hang for the OS default (tens of seconds) or -/// indefinitely, so Test/publish/consume appear frozen with no error. -const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); - -/// Open an AMQP connection using the profile's TLS/heartbeat/connection-name -/// settings, integrated with the app's tokio runtime. Tries each endpoint in turn -/// (primary, then `extra_hosts`) so an HA cluster stays reachable when a node is -/// down; returns on the first success, or the collected errors if all fail. -async fn connect_amqp(config: &RabbitConnection) -> Result { - let mut options = ConnectionProperties::default(); - if let Some(name) = nonempty(&config.connection_name) { - options = options.with_connection_name(name.into()); - } - let mut errors: Vec = Vec::new(); - for (host, port) in endpoints(config) { - let uri = amqp_uri_for(config, &host, port); - let tls = OwnedTLSConfig { - identity: build_identity(config)?, - cert_chain: nonempty(&config.tls_ca_pem), - }; - // lapin 4 owns the executor/reactor internally; the default runtime is - // tokio (the `tokio` default feature). Its concrete type is private, so - // build it inline rather than naming it. One runtime per attempt. - let runtime = lapin::runtime::default_runtime() - .map_err(|e| format!("AMQP runtime init failed: {e}"))?; - match tokio::time::timeout( - CONNECT_TIMEOUT, - Connection::connect_with_config(&uri, options.clone(), tls, runtime), - ) - .await - { - Ok(Ok(conn)) => return Ok(conn), - Ok(Err(e)) => errors.push(format!("{host}:{port}: {e}")), - Err(_) => errors.push(format!("{host}:{port}: timed out after {}s", CONNECT_TIMEOUT.as_secs())), - } - } - Err(format!( - "AMQP connect failed (tried {} endpoint(s)): {}. Check the host(s), AMQP port, TLS setting and that the broker is reachable.", - errors.len(), - errors.join("; "), - )) -} - -fn to_field_table(headers: &BTreeMap) -> FieldTable { - let mut ft = FieldTable::default(); - for (k, v) in headers { - ft.insert(k.clone().into(), AMQPValue::LongString(v.clone().into())); - } - ft -} - -/// Port nguyên vẹn từ `rabbit.rs::amqp_value_to_string` — dùng để đọc header -/// của một message đã nhận (chiều ngược `to_field_table`). -fn amqp_value_to_string(v: &AMQPValue) -> String { - match v { - AMQPValue::LongString(s) => s.to_string(), - AMQPValue::Boolean(b) => b.to_string(), - AMQPValue::ShortShortInt(i) => i.to_string(), - AMQPValue::ShortShortUInt(i) => i.to_string(), - AMQPValue::ShortInt(i) => i.to_string(), - AMQPValue::ShortUInt(i) => i.to_string(), - AMQPValue::LongInt(i) => i.to_string(), - AMQPValue::LongUInt(i) => i.to_string(), - AMQPValue::LongLongInt(i) => i.to_string(), - other => format!("{other:?}"), - } -} - -/// Port nguyên vẹn từ `rabbit.rs::message_from_delivery`. -fn message_from_delivery(d: &lapin::message::Delivery) -> ConsumedMessage { - let p = &d.properties; - let mut headers = BTreeMap::new(); - if let Some(h) = p.headers().as_ref() { - for (k, v) in h.inner() { - headers.insert(k.to_string(), amqp_value_to_string(v)); - } - } - ConsumedMessage { - payload: String::from_utf8_lossy(&d.data).to_string(), - exchange: d.exchange.to_string(), - routing_key: d.routing_key.to_string(), - redelivered: d.redelivered, - delivery_tag: d.delivery_tag, - correlation_id: p.correlation_id().as_ref().map(|s| s.to_string()), - content_type: p.content_type().as_ref().map(|s| s.to_string()), - message_id: p.message_id().as_ref().map(|s| s.to_string()), - headers, - } -} - -fn build_properties(p: &PublishProps) -> BasicProperties { - let mut props = BasicProperties::default(); - if let Some(v) = nonempty(&p.content_type) { - props = props.with_content_type(v.into()); - } - if let Some(v) = nonempty(&p.content_encoding) { - props = props.with_content_encoding(v.into()); - } - if let Some(v) = nonempty(&p.correlation_id) { - props = props.with_correlation_id(v.into()); - } - if let Some(v) = nonempty(&p.reply_to) { - props = props.with_reply_to(v.into()); - } - if let Some(v) = nonempty(&p.message_id) { - props = props.with_message_id(v.into()); - } - if let Some(v) = nonempty(&p.kind) { - props = props.with_type(v.into()); - } - if let Some(v) = nonempty(&p.app_id) { - props = props.with_app_id(v.into()); - } - if let Some(v) = nonempty(&p.user_id) { - props = props.with_user_id(v.into()); - } - if let Some(v) = nonempty(&p.expiration) { - props = props.with_expiration(v.into()); - } - if let Some(pr) = p.priority { - props = props.with_priority(pr); - } - if p.persistent.unwrap_or(false) { - props = props.with_delivery_mode(2); // 2 = persistent - } - if let Some(h) = &p.headers { - if !h.is_empty() { - props = props.with_headers(to_field_table(h)); - } - } - props -} - -/// A mandatory publish is "routed" unless the broker returned it as unroutable. -fn routed_from(mandatory: bool, returned: bool) -> bool { - !(mandatory && returned) -} - -// ── AMQP topology (for brokers with no management HTTP API) — port nguyên vẹn ─ - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct QueueAmqpInfo { - pub name: String, - pub exists: bool, - pub messages: Option, - pub consumers: Option, - /// Set only for unexpected errors (a missing queue is reported via `exists`). - pub error: Option, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ExchangeAmqpInfo { - pub name: String, - pub exists: bool, - pub error: Option, -} - -/// A failed passive declare raises a channel-level NOT_FOUND that closes the -/// channel, so each probe runs on its own fresh channel. -fn is_not_found(msg: &str) -> bool { - msg.contains("NOT_FOUND") || msg.contains("404") || msg.contains("no queue") || msg.contains("no exchange") -} - -// ── Consumer streaming — Bước 3 ───────────────────────────────────────────── -// -// Registry nội bộ theo dõi các consumer đang chạy, tách biệt khỏi `Waiters` -// phía service_host.rs (đó là registry của HOST theo `request.id` của MỘT -// LẦN GỌI `consume-start`; đây là registry của SIDECAR theo `consumerId` NỘI -// BỘ, sống xuyên suốt nhiều lần gọi — một `consume-stop` request khác hẳn -// request đã gọi `consume-start` mới dừng đúng consumer này). Cùng cấu trúc -// `PubSubRegistry` của devtool-svc-redis.rs / `StreamRegistry` của -// devtool-svc-container.rs — chỉ một method stream ở sidecar này nên một -// registry riêng là đủ, không cần dùng chung với method khác. -#[derive(Default)] -struct ConsumerStreamRegistry { - inner: Mutex>>, -} - -static STREAM_REGISTRY: OnceLock = OnceLock::new(); - -fn stream_registry() -> &'static ConsumerStreamRegistry { - STREAM_REGISTRY.get_or_init(ConsumerStreamRegistry::default) -} - -/// Gửi lỗi giữa chừng của `consume-start` như một SỰ KIỆN STREAM bình thường -/// (`Response::event`, `error: None` ở tầng khung), KHÔNG BAO GIỜ dùng -/// `Response::err` — `service_host.rs::dispatch()` âm thầm bỏ qua một -/// `ServiceResponse` mang `error: Some(...)` cho một `Waiter::Stream` (xem -/// comment đầy đủ ở `send_pubsub_error`, devtool-svc-redis.rs). Payload -/// `{"type":"error","message":...}` đi qua đúng con đường event mà phía -/// client đang đợi (`"subscribed"` hoặc `"error"`). -fn send_stream_error(tx: &mpsc::UnboundedSender, id: &str, message: impl Into) { - let _ = tx.send(Response::event(id.to_string(), serde_json::json!({ "type": "error", "message": message.into() }))); -} - -/// `consume-start` là method STREAM — mirror `handle_logs_start` của -/// devtool-svc-container.rs / `handle_pubsub_subscribe` của -/// devtool-svc-redis.rs: mở connection+channel AMQP thật, `basic_qos`, -/// `basic_consume`, đăng ký vào registry nội bộ, gửi sự kiện đầu -/// `{"type":"subscribed","subscriptionId":...}` rồi spawn một task nền phát -/// mỗi message nhận được như `{"type":"message", ...ConsumedMessage}`. Port -/// ĐÚNG logic ack/reply theo `ackMode` từ `rabbit_consume_start` -/// (rabbit.rs dòng ~556-648): "peek" không ack (để lại unacked, bounded bởi -/// prefetch), "consume" ack không reply, "respond" ack + publish reply qua -/// direct reply-to nếu `reply` có mặt (echo body gốc hay payload tuỳ chỉnh, -/// set correlation_id/content_type từ request gốc). GIỮ `_keep_conn`/`channel` -/// sống trong task nền suốt vòng đời consumer — drop sớm đóng kết nối AMQP -/// ngay khi hàm return, lỗi dễ mắc nhất khi port ẩu. -async fn handle_consume_start(id: String, params: serde_json::Value, tx: mpsc::UnboundedSender) { - let config_id: String = match param(¶ms, "configId") { - Ok(v) => v, - Err(e) => return send_stream_error(&tx, &id, e), - }; - let queue: String = match param(¶ms, "queue") { - Ok(v) => v, - Err(e) => return send_stream_error(&tx, &id, e), - }; - let ack_mode: String = match param(¶ms, "ackMode") { - Ok(v) => v, - Err(e) => return send_stream_error(&tx, &id, e), - }; - let prefetch: u16 = match param(¶ms, "prefetch") { - Ok(v) => v, - Err(e) => return send_stream_error(&tx, &id, e), - }; - let reply: Option = opt_param(¶ms, "reply"); - - let config = match find_config(&config_id) { - Ok(c) => c, - Err(e) => return send_stream_error(&tx, &id, e), - }; - let conn = match connect_amqp(&config).await { - Ok(c) => c, - Err(e) => return send_stream_error(&tx, &id, e), - }; - let channel = match conn.create_channel().await { - Ok(c) => c, - Err(e) => return send_stream_error(&tx, &id, e.to_string()), - }; - - let prefetch = prefetch.clamp(1, 500); - if let Err(e) = channel.basic_qos(prefetch, BasicQosOptions::default()).await { - return send_stream_error(&tx, &id, e.to_string()); - } - - let should_ack = ack_mode != "peek"; // consume + respond both ack - let should_reply = ack_mode == "respond" && reply.is_some(); - let mut consumer = match channel - .basic_consume( - queue.clone().into(), - "devtool-consumer".into(), - BasicConsumeOptions::default(), // manual ack - FieldTable::default(), - ) - .await - { - Ok(c) => c, - Err(e) => return send_stream_error(&tx, &id, format!("Consume failed: {e}")), - }; - - let consumer_id = Uuid::new_v4().to_string(); - let notify = Arc::new(tokio::sync::Notify::new()); - stream_registry().inner.lock().unwrap().insert(consumer_id.clone(), notify.clone()); - - if tx - .send(Response::event(id.clone(), serde_json::json!({ "type": "subscribed", "subscriptionId": consumer_id }))) - .is_err() - { - stream_registry().inner.lock().unwrap().remove(&consumer_id); - return; - } - - tokio::spawn(async move { - // Giữ connection (và channel) sống suốt vòng đời consumer; channel còn - // dùng để publish reply ở chế độ "respond". Drop sớm (không giữ biến - // này) đóng kết nối AMQP ngay khi task return. - let _keep_conn = conn; - let channel = channel; - loop { - tokio::select! { - _ = notify.notified() => break, - next = consumer.next() => match next { - Some(Ok(delivery)) => { - let message = message_from_delivery(&delivery); - let event = { - let mut v = serde_json::to_value(&message).unwrap(); - v.as_object_mut().unwrap().insert("type".to_string(), serde_json::json!("message")); - v - }; - if tx.send(Response::event(id.clone(), event)).is_err() { - break; // writer task đã thoát — không còn ai đọc - } - if should_reply { - if let (Some(rep), Some(reply_to)) = - (reply.as_ref(), delivery.properties.reply_to().as_ref()) - { - let body: Vec = if rep.echo { - delivery.data.clone() - } else { - rep.payload.clone().into_bytes() - }; - let mut rprops = BasicProperties::default(); - if let Some(cid) = delivery.properties.correlation_id().as_ref() { - rprops = rprops.with_correlation_id(cid.clone()); - } - if let Some(ct) = rep.content_type.as_ref().filter(|s| !s.trim().is_empty()) { - rprops = rprops.with_content_type(ct.clone().into()); - } - let _ = channel - .basic_publish( - "".into(), - reply_to.as_str().into(), - BasicPublishOptions::default(), - &body, - rprops, - ) - .await; - } - } - if should_ack { - let _ = delivery.ack(BasicAckOptions::default()).await; - } - // peek mode: leave unacked (bounded by prefetch) - } - Some(Err(_)) | None => break, - } - } - } - stream_registry().inner.lock().unwrap().remove(&consumer_id); - }); -} - -// ── Method dispatch ─────────────────────────────────────────────────────────── - -async fn handle(method: &str, params: serde_json::Value) -> Result { - match method { - "list-configs" => Ok(serde_json::to_value(load_configs()).unwrap()), - - "save-config" => { - let mut config: RabbitConnection = param(¶ms, "config")?; - if config.id.is_empty() { - config.id = Uuid::new_v4().to_string(); - } - let mut configs = load_configs(); - if let Some(pos) = configs.iter().position(|c| c.id == config.id) { - configs[pos] = config.clone(); - } else { - configs.push(config.clone()); - } - save_configs(&configs)?; - Ok(serde_json::to_value(config).unwrap()) - } - - "delete-config" => { - let config_id: String = param(¶ms, "configId")?; - let mut configs = load_configs(); - configs.retain(|c| c.id != config_id); - save_configs(&configs)?; - Ok(serde_json::Value::Null) - } - - "amqp-test" => { - let config: RabbitConnection = param(¶ms, "config")?; - let conn = connect_amqp(&config).await?; - // A channel open confirms the connection is usable, not just the TCP/TLS handshake. - conn.create_channel().await.map_err(|e| e.to_string())?; - Ok(serde_json::Value::Null) - } - - "rpc-call" => { - let config_id: String = param(¶ms, "configId")?; - let exchange: String = param(¶ms, "exchange")?; - let routing_key: String = param(¶ms, "routingKey")?; - let payload: String = param(¶ms, "payload")?; - let correlation_id: Option = opt_param(¶ms, "correlationId"); - let content_type: Option = opt_param(¶ms, "contentType"); - let headers: Option> = opt_param(¶ms, "headers"); - let timeout_ms: u64 = param(¶ms, "timeoutMs")?; - - let config = find_config(&config_id)?; - let conn = connect_amqp(&config).await?; - let channel = conn.create_channel().await.map_err(|e| e.to_string())?; - - // Consume the direct reply-to pseudo-queue BEFORE publishing. Requires no-ack. - let mut consumer = channel - .basic_consume( - "amq.rabbitmq.reply-to".into(), - "devtool-rpc".into(), - BasicConsumeOptions { no_ack: true, ..Default::default() }, - FieldTable::default(), - ) - .await - .map_err(|e| e.to_string())?; - - let corr = correlation_id - .filter(|s| !s.trim().is_empty()) - .unwrap_or_else(|| Uuid::new_v4().to_string()); - - let mut props = BasicProperties::default() - .with_reply_to("amq.rabbitmq.reply-to".into()) - .with_correlation_id(corr.into()); - if let Some(ct) = nonempty(&content_type) { - props = props.with_content_type(ct.into()); - } - if let Some(hs) = headers { - if !hs.is_empty() { - props = props.with_headers(to_field_table(&hs)); - } - } - - channel - .basic_publish( - exchange.into(), - routing_key.into(), - BasicPublishOptions::default(), - payload.as_bytes(), - props, - ) - .await - .map_err(|e| format!("Publish failed: {e}"))? - .await - .map_err(|e| format!("Publish not confirmed: {e}"))?; - - match tokio::time::timeout(Duration::from_millis(timeout_ms), consumer.next()).await { - Ok(Some(Ok(delivery))) => Ok(serde_json::to_value(RpcReply { - payload: String::from_utf8_lossy(&delivery.data).to_string(), - correlation_id: delivery.properties.correlation_id().as_ref().map(|s| s.to_string()), - content_type: delivery.properties.content_type().as_ref().map(|s| s.to_string()), - }) - .unwrap()), - Ok(Some(Err(e))) => Err(format!("Error receiving reply: {e}")), - Ok(None) => Err("Reply stream closed before a reply arrived".to_string()), - Err(_) => Err(format!( - "No reply within {timeout_ms} ms. Check the routing key reaches a responder that replies to reply_to." - )), - } - } - - "publish" => { - let config_id: String = param(¶ms, "configId")?; - let exchange: String = param(¶ms, "exchange")?; - let routing_key: String = param(¶ms, "routingKey")?; - let payload: String = param(¶ms, "payload")?; - let properties: PublishProps = opt_param(¶ms, "properties").unwrap_or_default(); - let mandatory: bool = opt_param(¶ms, "mandatory").unwrap_or(false); - let confirm: bool = opt_param(¶ms, "confirm").unwrap_or(false); - - let config = find_config(&config_id)?; - let conn = connect_amqp(&config).await?; - let channel = conn.create_channel().await.map_err(|e| e.to_string())?; - - // Publisher confirms are also how we learn whether a mandatory message was - // returned as unroutable, so enable them when either is requested. - let use_confirm = confirm || mandatory; - if use_confirm { - channel - .confirm_select(ConfirmSelectOptions::default()) - .await - .map_err(|e| e.to_string())?; - } - - let props = build_properties(&properties); - let publish = channel - .basic_publish( - exchange.into(), - routing_key.into(), - BasicPublishOptions { mandatory, ..Default::default() }, - payload.as_bytes(), - props, - ) - .await - .map_err(|e| format!("Publish failed: {e}"))?; - - if use_confirm { - let confirmation = publish.await.map_err(|e| format!("Publish not confirmed: {e}"))?; - let confirmed = confirmation.is_ack(); - let returned = confirmation.take_message(); - let return_reason = returned - .as_ref() - .map(|m| format!("{} {}", m.reply_code, m.reply_text.as_str())); - Ok(serde_json::to_value(PublishOutcome { - confirmed, - routed: routed_from(mandatory, returned.is_some()), - return_reason, - }) - .unwrap()) - } else { - let _ = publish.await; // flush - Ok(serde_json::to_value(PublishOutcome { confirmed: false, routed: true, return_reason: None }).unwrap()) - } - } - - "amqp-queues-info" => { - let config_id: String = param(¶ms, "configId")?; - let names: Vec = param(¶ms, "names")?; - let config = find_config(&config_id)?; - let conn = connect_amqp(&config).await?; - let mut out = Vec::with_capacity(names.len()); - for name in names { - let channel = match conn.create_channel().await { - Ok(ch) => ch, - Err(e) => { - out.push(QueueAmqpInfo { name, exists: false, messages: None, consumers: None, error: Some(e.to_string()) }); - continue; - } - }; - match channel - .queue_declare( - name.clone().into(), - QueueDeclareOptions { passive: true, ..Default::default() }, - FieldTable::default(), - ) - .await - { - Ok(q) => { - out.push(QueueAmqpInfo { - name, - exists: true, - messages: Some(q.message_count()), - consumers: Some(q.consumer_count()), - error: None, - }); - let _ = channel.close(200, "ok".into()).await; - } - Err(e) => { - let msg = e.to_string(); - let not_found = is_not_found(&msg); - out.push(QueueAmqpInfo { - name, - exists: false, - messages: None, - consumers: None, - error: if not_found { None } else { Some(msg) }, - }); - // Channel is already closed by the broker on error. - } - } - } - Ok(serde_json::to_value(out).unwrap()) - } - - "amqp-exchanges-info" => { - let config_id: String = param(¶ms, "configId")?; - let names: Vec = param(¶ms, "names")?; - let config = find_config(&config_id)?; - let conn = connect_amqp(&config).await?; - let mut out = Vec::with_capacity(names.len()); - for name in names { - let channel = match conn.create_channel().await { - Ok(ch) => ch, - Err(e) => { - out.push(ExchangeAmqpInfo { name, exists: false, error: Some(e.to_string()) }); - continue; - } - }; - // Passive declare ignores the kind; it only checks existence. - match channel - .exchange_declare( - name.clone().into(), - ExchangeKind::Direct, - ExchangeDeclareOptions { passive: true, ..Default::default() }, - FieldTable::default(), - ) - .await - { - Ok(_) => { - out.push(ExchangeAmqpInfo { name, exists: true, error: None }); - let _ = channel.close(200, "ok".into()).await; - } - Err(e) => { - let msg = e.to_string(); - let not_found = is_not_found(&msg); - out.push(ExchangeAmqpInfo { name, exists: false, error: if not_found { None } else { Some(msg) } }); - } - } - } - Ok(serde_json::to_value(out).unwrap()) - } - - "amqp-declare-queue" => { - let config_id: String = param(¶ms, "configId")?; - let name: String = param(¶ms, "name")?; - let durable: bool = param(¶ms, "durable")?; - let auto_delete: bool = param(¶ms, "autoDelete")?; - let config = find_config(&config_id)?; - let conn = connect_amqp(&config).await?; - let channel = conn.create_channel().await.map_err(|e| e.to_string())?; - channel - .queue_declare( - name.into(), - QueueDeclareOptions { durable, auto_delete, ..Default::default() }, - FieldTable::default(), - ) - .await - .map_err(|e| format!("Declare queue failed: {e}"))?; - Ok(serde_json::Value::Null) - } - - "amqp-declare-exchange" => { - let config_id: String = param(¶ms, "configId")?; - let name: String = param(¶ms, "name")?; - let kind: String = param(¶ms, "kind")?; - let durable: bool = param(¶ms, "durable")?; - let auto_delete: bool = param(¶ms, "autoDelete")?; - let internal: bool = param(¶ms, "internal")?; - let config = find_config(&config_id)?; - let conn = connect_amqp(&config).await?; - let channel = conn.create_channel().await.map_err(|e| e.to_string())?; - let ek = match kind.as_str() { - "direct" => ExchangeKind::Direct, - "fanout" => ExchangeKind::Fanout, - "topic" => ExchangeKind::Topic, - "headers" => ExchangeKind::Headers, - other => ExchangeKind::Custom(other.to_string()), - }; - channel - .exchange_declare( - name.into(), - ek, - ExchangeDeclareOptions { durable, auto_delete, internal, ..Default::default() }, - FieldTable::default(), - ) - .await - .map_err(|e| format!("Declare exchange failed: {e}"))?; - Ok(serde_json::Value::Null) - } - - "amqp-bind-queue" => { - let config_id: String = param(¶ms, "configId")?; - let queue: String = param(¶ms, "queue")?; - let exchange: String = param(¶ms, "exchange")?; - let routing_key: String = param(¶ms, "routingKey")?; - let config = find_config(&config_id)?; - let conn = connect_amqp(&config).await?; - let channel = conn.create_channel().await.map_err(|e| e.to_string())?; - channel - .queue_bind( - queue.into(), - exchange.into(), - routing_key.into(), - QueueBindOptions::default(), - FieldTable::default(), - ) - .await - .map_err(|e| format!("Bind failed: {e}"))?; - Ok(serde_json::Value::Null) - } - - // "consume-start" (stream) KHÔNG đi qua đây — xem `handle_consume_start`. - - "consume-stop" => { - let consumer_id: String = param(¶ms, "consumerId")?; - if let Some(notify) = stream_registry().inner.lock().unwrap().remove(&consumer_id) { - notify.notify_one(); - } - Ok(serde_json::Value::Null) - } - - other => Err(format!("method không hỗ trợ: \"{other}\"")), - } -} - -// ── main: đọc stdin, spawn một task async cho mỗi dòng, một task khác sở hữu -// stdout để mọi phản hồi ghi ra không bị xen lẫn giữa hai task — y hệt -// cấu trúc devtool-svc-redis.rs / devtool-svc-container.rs. ───────────── - -#[tokio::main] -async fn main() { - let (tx, mut rx) = mpsc::unbounded_channel::(); - - let writer = tokio::spawn(async move { - let mut stdout = tokio::io::stdout(); - while let Some(response) = rx.recv().await { - let Ok(json) = serde_json::to_string(&response) else { continue }; - if stdout.write_all(json.as_bytes()).await.is_err() { break; } - if stdout.write_all(b"\n").await.is_err() { break; } - if stdout.flush().await.is_err() { break; } - } - }); - - let stdin = tokio::io::stdin(); - let mut lines = BufReader::new(stdin).lines(); - while let Ok(Some(line)) = lines.next_line().await { - if line.trim().is_empty() { - continue; - } - let tx = tx.clone(); - tokio::spawn(async move { - handle_line(&line, &tx).await; - }); - } - - drop(tx); - let _ = writer.await; - // Lưu ý: nếu có consumer `consume-start` đang chạy, task nền của nó vẫn - // giữ một bản sao `tx` nên `writer` (và do đó main()) sẽ KHÔNG tự thoát - // chỉ vì stdin đóng — `service_host.rs` spawn sidecar với - // `kill_on_drop(true)`, nên tiến trình vẫn bị buộc dừng khi host - // kill/drop child handle bất kể trạng thái kênh nội bộ này. -} - -async fn handle_line(line: &str, tx: &mpsc::UnboundedSender) { - let req: Request = match serde_json::from_str(line) { - Ok(r) => r, - Err(e) => { - let _ = tx.send(Response::err(String::new(), format!("JSON không hợp lệ: {e}"))); - return; - } - }; - if req.protocol != SERVICE_PROTOCOL { - let _ = tx.send(Response::err(req.id, format!("lệch protocol: client {}, sidecar {SERVICE_PROTOCOL}", req.protocol))); - return; - } - // `consume-start` KHÔNG đi qua `handle()` (một-lần) — nó cần giữ `tx` để - // phát nhiều sự kiện theo thời gian, xem `handle_consume_start`. Mọi - // method khác (bao gồm `consume-stop`) vẫn đi qua đường một-lần cũ. - if req.method == "consume-start" { - handle_consume_start(req.id, req.params, tx.clone()).await; - return; - } - let response = match handle(&req.method, req.params).await { - Ok(value) if value.is_null() => Response::ok(req.id), - Ok(value) => Response::once(req.id, value), - Err(e) => Response::err(req.id, e), - }; - let _ = tx.send(response); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn base_config() -> RabbitConnection { - RabbitConnection { - id: "1".into(), - name: "t".into(), - host: "localhost".into(), - port: 15672, - vhost: "/".into(), - username: "guest".into(), - password: "guest".into(), - use_tls: false, - amqp_port: 5672, - extra_hosts: Vec::new(), - tls_ca_pem: None, - client_pkcs12_b64: None, - client_pkcs12_password: None, - heartbeat: None, - connection_name: None, - amqp_only: false, - } - } - - fn amqp_uri(c: &RabbitConnection) -> String { - amqp_uri_for(c, &c.host, c.amqp_port) - } - - // ── Test mang theo từ rabbit.rs (logic thuần, không cần broker) ───────── - - #[test] - fn amqp_uri_encodes_default_vhost_and_uses_amqp() { - let uri = amqp_uri(&base_config()); - assert_eq!(uri, "amqp://guest:guest@localhost:5672/%2F"); - } - - #[test] - fn amqp_uri_uses_amqps_when_tls_and_appends_heartbeat() { - let mut c = base_config(); - c.use_tls = true; - c.heartbeat = Some(30); - c.vhost = "prod".into(); - let uri = amqp_uri(&c); - assert_eq!(uri, "amqps://guest:guest@localhost:5672/prod?heartbeat=30"); - } - - #[test] - fn amqp_uri_percent_encodes_credentials() { - let mut c = base_config(); - c.username = "user@host".into(); - c.password = "p:s/w".into(); - let uri = amqp_uri(&c); - assert!(uri.contains("user%40host")); - assert!(uri.contains("p%3As%2Fw")); - } - - #[test] - fn config_deserializes_null_extra_hosts_as_empty() { - // The frontend sends `extraHosts: null` when empty; it must not error. - let json = r#"{ - "id": "1", "name": "t", "host": "localhost", "port": 15672, - "vhost": "/", "username": "guest", "password": "guest", "useTls": false, - "amqpPort": 5672, "extraHosts": null - }"#; - let c: RabbitConnection = serde_json::from_str(json).unwrap(); - assert!(c.extra_hosts.is_empty()); - } - - #[test] - fn endpoints_lists_primary_then_extras_with_port_fallback() { - let mut c = base_config(); - c.extra_hosts = vec![ - "node2".into(), // host only → falls back to amqp_port - "node3:5673".into(), // explicit port - " ".into(), // blank → skipped - "node4:notaport".into(), // bad port → whole entry treated as host - ]; - assert_eq!( - endpoints(&c), - vec![ - ("localhost".to_string(), 5672), - ("node2".to_string(), 5672), - ("node3".to_string(), 5673), - ("node4:notaport".to_string(), 5672), - ], - ); - } - - #[test] - fn nonempty_trims_and_filters() { - assert_eq!(nonempty(&Some(" x ".into())), Some("x".into())); - assert_eq!(nonempty(&Some(" ".into())), None); - assert_eq!(nonempty(&None), None); - } - - #[test] - fn build_properties_sets_persistent_and_fields() { - let p = PublishProps { - content_type: Some("application/json".into()), - persistent: Some(true), - priority: Some(5), - correlation_id: Some("abc".into()), - content_encoding: Some("".into()), // empty → omitted - ..Default::default() - }; - let props = build_properties(&p); - assert_eq!(props.content_type().as_ref().map(|s| s.as_str()), Some("application/json")); - assert_eq!(props.delivery_mode(), &Some(2)); - assert_eq!(props.priority(), &Some(5)); - assert_eq!(props.correlation_id().as_ref().map(|s| s.as_str()), Some("abc")); - assert!(props.content_encoding().is_none()); - } - - #[test] - fn build_properties_omits_delivery_mode_when_not_persistent() { - let props = build_properties(&PublishProps::default()); - assert_eq!(props.delivery_mode(), &None); - } - - #[test] - fn to_field_table_carries_all_headers() { - let mut h = BTreeMap::new(); - h.insert("__TypeId__".to_string(), "com.example.X".to_string()); - h.insert("source".to_string(), "devtool".to_string()); - let ft = to_field_table(&h); - assert_eq!(ft.inner().len(), 2); - } - - #[test] - fn routed_from_logic() { - assert!(routed_from(false, false)); // not mandatory → routed - assert!(routed_from(false, true)); // not mandatory, ignore return - assert!(routed_from(true, false)); // mandatory, not returned → routed - assert!(!routed_from(true, true)); // mandatory + returned → unroutable - } - - // ── Test riêng cho phần hạ tầng chỉ có ở sidecar (không có trong rabbit.rs) ─ - - #[test] - fn app_data_dir_bao_loi_ro_rang_khi_thieu_bien_moi_truong() { - // Không set biến môi trường trong test này — nhưng test khác trong - // cùng binary CÓ THỂ chạy song song và set nó, nên chỉ assert khi - // biến thực sự vắng mặt để tránh test chập chờn. - if std::env::var("DEVTOOL_SERVICE_DATA_DIR").is_err() { - assert!(app_data_dir().unwrap_err().contains("DEVTOOL_SERVICE_DATA_DIR")); - } - } - - #[tokio::test] - async fn method_khong_ho_tro_tra_ve_loi_khong_phai_panic() { - let res = handle("khong-ton-tai", serde_json::Value::Null).await; - assert!(res.unwrap_err().contains("khong-ton-tai")); - } - - #[tokio::test] - async fn list_configs_khong_co_du_lieu_tra_ve_vec_rong() { - // Không set DEVTOOL_SERVICE_DATA_DIR trong test này → load_configs() - // phải rơi về rỗng, không panic. - let res = handle("list-configs", serde_json::Value::Null).await; - assert!(res.is_ok()); - } - - #[tokio::test] - async fn delete_config_thieu_config_id_tra_ve_loi_ro_rang() { - let res = handle("delete-config", serde_json::json!({})).await; - assert!(res.unwrap_err().contains("configId")); - } - - #[tokio::test] - async fn save_config_thieu_config_tra_ve_loi_ro_rang() { - let res = handle("save-config", serde_json::json!({})).await; - assert!(res.unwrap_err().contains("config")); - } - - #[tokio::test] - async fn amqp_test_thieu_config_tra_ve_loi_ro_rang() { - let res = handle("amqp-test", serde_json::json!({})).await; - assert!(res.unwrap_err().contains("config")); - } - - #[tokio::test] - async fn amqp_test_config_khong_ket_noi_duoc_tra_ve_loi_ro_rang() { - let mut c = base_config(); - c.host = "127.0.0.1".into(); - c.amqp_port = 1; // cổng gần như chắc chắn không có broker nào lắng nghe - let res = handle("amqp-test", serde_json::json!({ "config": c })).await; - assert!(res.is_err()); - } - - #[tokio::test] - async fn rpc_call_thieu_config_id_tra_ve_loi_ro_rang() { - let res = handle( - "rpc-call", - serde_json::json!({ "exchange": "", "routingKey": "x", "payload": "y", "timeoutMs": 100 }), - ) - .await; - assert!(res.unwrap_err().contains("configId")); - } - - #[tokio::test] - async fn rpc_call_config_khong_ton_tai_tra_ve_loi_ro_rang() { - let res = handle( - "rpc-call", - serde_json::json!({ - "configId": "khong-ton-tai-chac-chan", "exchange": "", "routingKey": "x", - "payload": "y", "timeoutMs": 100, - }), - ) - .await; - assert!(res.is_err()); - } - - #[tokio::test] - async fn publish_thieu_routing_key_tra_ve_loi_ro_rang() { - let res = handle( - "publish", - serde_json::json!({ "configId": "x", "exchange": "", "payload": "y" }), - ) - .await; - assert!(res.unwrap_err().contains("routingKey")); - } - - #[tokio::test] - async fn publish_config_khong_ton_tai_tra_ve_loi_ro_rang() { - let res = handle( - "publish", - serde_json::json!({ - "configId": "khong-ton-tai-chac-chan", "exchange": "", "routingKey": "x", "payload": "y", - }), - ) - .await; - assert!(res.is_err()); - } - - #[tokio::test] - async fn amqp_queues_info_thieu_names_tra_ve_loi_ro_rang() { - let res = handle("amqp-queues-info", serde_json::json!({ "configId": "x" })).await; - assert!(res.unwrap_err().contains("names")); - } - - #[tokio::test] - async fn amqp_queues_info_config_khong_ton_tai_tra_ve_loi_ro_rang() { - let res = handle( - "amqp-queues-info", - serde_json::json!({ "configId": "khong-ton-tai-chac-chan", "names": ["q1"] }), - ) - .await; - assert!(res.is_err()); - } - - #[tokio::test] - async fn amqp_exchanges_info_thieu_names_tra_ve_loi_ro_rang() { - let res = handle("amqp-exchanges-info", serde_json::json!({ "configId": "x" })).await; - assert!(res.unwrap_err().contains("names")); - } - - #[tokio::test] - async fn amqp_declare_queue_thieu_durable_tra_ve_loi_ro_rang() { - let res = handle( - "amqp-declare-queue", - serde_json::json!({ "configId": "x", "name": "q1", "autoDelete": false }), - ) - .await; - assert!(res.unwrap_err().contains("durable")); - } - - #[tokio::test] - async fn amqp_declare_queue_config_khong_ton_tai_tra_ve_loi_ro_rang() { - let res = handle( - "amqp-declare-queue", - serde_json::json!({ - "configId": "khong-ton-tai-chac-chan", "name": "q1", "durable": true, "autoDelete": false, - }), - ) - .await; - assert!(res.is_err()); - } - - #[tokio::test] - async fn amqp_declare_exchange_thieu_kind_tra_ve_loi_ro_rang() { - let res = handle( - "amqp-declare-exchange", - serde_json::json!({ "configId": "x", "name": "e1", "durable": true, "autoDelete": false, "internal": false }), - ) - .await; - assert!(res.unwrap_err().contains("kind")); - } - - #[tokio::test] - async fn amqp_bind_queue_thieu_exchange_tra_ve_loi_ro_rang() { - let res = handle( - "amqp-bind-queue", - serde_json::json!({ "configId": "x", "queue": "q1", "routingKey": "rk" }), - ) - .await; - assert!(res.unwrap_err().contains("exchange")); - } - - #[tokio::test] - async fn amqp_bind_queue_config_khong_ton_tai_tra_ve_loi_ro_rang() { - let res = handle( - "amqp-bind-queue", - serde_json::json!({ - "configId": "khong-ton-tai-chac-chan", "queue": "q1", "exchange": "e1", "routingKey": "rk", - }), - ) - .await; - assert!(res.is_err()); - } - - // ── Bước 3: consume-start (stream) / consume-stop — đường lỗi + registry ─ - - #[tokio::test] - async fn consume_stop_id_khong_ton_tai_van_ok_khong_panic() { - // Gỡ một consumerId chưa từng đăng ký là no-op (giống bản Tier A) — - // không có gì để dừng nhưng vẫn phải trả Ok, không lỗi/panic. - let res = handle("consume-stop", serde_json::json!({ "consumerId": "khong-ton-tai" })).await; - assert!(res.is_ok()); - } - - #[tokio::test] - async fn consume_stop_thieu_consumer_id_tra_ve_loi_ro_rang() { - let res = handle("consume-stop", serde_json::json!({})).await; - assert!(res.unwrap_err().contains("consumerId")); - } - - #[tokio::test] - async fn consume_stop_thao_dung_entry_khoi_registry_va_bao_notify() { - let id = "consumer-test-1".to_string(); - let notify = Arc::new(tokio::sync::Notify::new()); - stream_registry().inner.lock().unwrap().insert(id.clone(), notify.clone()); - - let notified = tokio::spawn({ - let notify = notify.clone(); - async move { notify.notified().await } - }); - - let res = handle("consume-stop", serde_json::json!({ "consumerId": id })).await; - assert!(res.is_ok()); - // notify_one() đã được gọi bên trong handle("consume-stop", …) — task - // đang chờ notified() phải hoàn tất, không treo. - tokio::time::timeout(std::time::Duration::from_secs(2), notified).await.expect("notify phải bắn").unwrap(); - assert!(!stream_registry().inner.lock().unwrap().contains_key(&id)); - } - - #[tokio::test] - async fn consume_start_thieu_config_id_bao_loi_qua_stream_event() { - // Lỗi phải đi qua như một SỰ KIỆN STREAM (`response.error.is_none()`, - // `stream: true`, payload `{"type":"error",...}`) — KHÔNG phải - // `response.error: Some(...)` ở tầng khung tin nhắn, vì - // `service_host.rs::dispatch()` âm thầm bỏ qua lỗi tầng khung cho một - // `Waiter::Stream` (xem comment ở `send_stream_error`). Nếu test này - // đỏ vì `response.error` lại có giá trị, đó là dấu hiệu bug cũ (fail - // giữa chừng nhưng client không bao giờ biết) đã quay lại. - let (tx, mut rx) = mpsc::unbounded_channel::(); - handle_consume_start( - "req-1".to_string(), - serde_json::json!({ "queue": "q1", "ackMode": "consume", "prefetch": 10 }), - tx, - ) - .await; - let response = rx.recv().await.expect("phải gửi đúng một sự kiện lỗi"); - assert_eq!(response.id, "req-1"); - assert!(response.error.is_none(), "lỗi phải đi qua stream event, không phải response.error"); - assert!(response.stream); - let event = response.result.expect("sự kiện lỗi phải mang result"); - assert_eq!(event["type"], "error"); - assert!(event["message"].as_str().unwrap().contains("configId")); - } - - #[tokio::test] - async fn consume_start_thieu_queue_bao_loi_qua_stream_event() { - let (tx, mut rx) = mpsc::unbounded_channel::(); - handle_consume_start( - "req-2".to_string(), - serde_json::json!({ "configId": "x", "ackMode": "consume", "prefetch": 10 }), - tx, - ) - .await; - let response = rx.recv().await.expect("phải gửi đúng một sự kiện lỗi"); - let event = response.result.expect("sự kiện lỗi phải mang result"); - assert_eq!(event["type"], "error"); - assert!(event["message"].as_str().unwrap().contains("queue")); - } - - #[tokio::test] - async fn consume_start_thieu_ack_mode_bao_loi_qua_stream_event() { - let (tx, mut rx) = mpsc::unbounded_channel::(); - handle_consume_start( - "req-3".to_string(), - serde_json::json!({ "configId": "x", "queue": "q1", "prefetch": 10 }), - tx, - ) - .await; - let response = rx.recv().await.expect("phải gửi đúng một sự kiện lỗi"); - let event = response.result.expect("sự kiện lỗi phải mang result"); - assert_eq!(event["type"], "error"); - assert!(event["message"].as_str().unwrap().contains("ackMode")); - } - - #[tokio::test] - async fn consume_start_config_khong_ton_tai_bao_loi_qua_stream_event() { - let (tx, mut rx) = mpsc::unbounded_channel::(); - handle_consume_start( - "req-4".to_string(), - serde_json::json!({ - "configId": "khong-ton-tai-chac-chan", "queue": "q1", "ackMode": "consume", "prefetch": 10, - }), - tx, - ) - .await; - let response = rx.recv().await.expect("phải gửi đúng một sự kiện lỗi"); - assert!(response.error.is_none()); - let event = response.result.expect("sự kiện lỗi phải mang result"); - assert_eq!(event["type"], "error"); - } - - #[tokio::test] - async fn consume_start_di_qua_handle_line_khong_qua_handle_mot_lan() { - // `handle_line` phải phân nhánh `consume-start` sang - // `handle_consume_start` (đường stream event), không rơi vào đường - // `handle()` một-lần (đường đó sẽ trả `response.error: Some(...)`). - let line = serde_json::json!({ - "protocol": SERVICE_PROTOCOL, "id": "req-5", "method": "consume-start", - "params": { "queue": "q1", "ackMode": "consume", "prefetch": 10 }, - }) - .to_string(); - let response = call_handle_line(&line).await; - assert!(response.error.is_none(), "lỗi phải đi qua stream event, không phải response.error"); - assert!(response.stream); - let event = response.result.expect("sự kiện lỗi phải mang result"); - assert_eq!(event["type"], "error"); - } - - /// `handle_line` trả về qua kênh `tx` — test dựng một kênh riêng và đọc - /// lại đúng một `Response`, giống mẫu ở devtool-svc-redis.rs / - /// devtool-svc-container.rs. - async fn call_handle_line(line: &str) -> Response { - let (tx, mut rx) = mpsc::unbounded_channel::(); - handle_line(line, &tx).await; - drop(tx); - rx.recv().await.expect("handle_line phải gửi đúng một response") - } - - #[tokio::test] - async fn handle_line_tra_response_hop_le_cho_method_mot_lan() { - let line = serde_json::json!({ - "protocol": SERVICE_PROTOCOL, "id": "1", "method": "delete-config", - "params": { "configId": "khong-quan-trong" }, - }) - .to_string(); - let response = call_handle_line(&line).await; - assert_eq!(response.id, "1"); - assert_eq!(response.protocol, SERVICE_PROTOCOL); - } - - #[tokio::test] - async fn lech_protocol_bi_tu_choi_truoc_khi_cham_toi_method() { - let line = serde_json::json!({ "protocol": 99, "id": "1", "method": "list-configs", "params": null }).to_string(); - let response = call_handle_line(&line).await; - assert!(response.error.unwrap().contains("lệch protocol")); - } - - #[tokio::test] - async fn json_hong_khong_panic_tra_ve_loi() { - let response = call_handle_line("{khong-phai-json").await; - assert!(response.error.unwrap().contains("JSON không hợp lệ")); - } - - #[tokio::test] - async fn method_la_tra_ve_loi_ro_rang() { - let line = serde_json::json!({ "protocol": SERVICE_PROTOCOL, "id": "9", "method": "khong-ton-tai", "params": null }).to_string(); - let response = call_handle_line(&line).await; - assert!(response.error.unwrap().contains("khong-ton-tai")); - } -} diff --git a/src-tauri/src/bin/devtool-svc-redis.rs b/src-tauri/src/bin/devtool-svc-redis.rs deleted file mode 100644 index 12391f94..00000000 --- a/src-tauri/src/bin/devtool-svc-redis.rs +++ /dev/null @@ -1,1233 +0,0 @@ -// Plugin dịch vụ (tier B) cho Redis Client — Bước 1 của Phase 2 (xem -// docs/decisions/architecture/platform-plugin-architecture.md, mục "Việc còn lại"). -// -// Port từ src-tauri/src/redis_tool.rs, giữ nguyên HÌNH DẠNG dữ liệu (JSON -// camelCase, ngữ nghĩa SCAN/VALUE_CAP) nhưng ĐỔI chỗ lưu cấu hình kết nối một -// cách có chủ ý — xem "Cách ly dữ liệu" dưới. Khác với bản Tier A đúng hai -// chỗ, cả hai đều là hệ quả của việc chạy như tiến trình riêng: -// - Không có `AppHandle` → thư mục dữ liệu đọc từ biến môi trường -// `DEVTOOL_SERVICE_DATA_DIR` mà `service_host.rs::get_or_spawn` set khi -// spawn — thư mục này ĐÃ được cách ly riêng cho sidecar này -// (`/service-data/devtool-svc-redis`), sidecar không có cách -// nào biết tới app_data gốc hay thư mục của sidecar khác. -// - Nhiều lời gọi có thể chồng lên nhau trên CÙNG một sidecar (host demux -// theo `id`, xem service_host.rs) — main() vì vậy KHÔNG đọc-xử lý-trả lời -// tuần tự như devtool-svc-echo, mà spawn một task async cho mỗi dòng vào, -// tất cả ghi ra CÙNG MỘT ống qua một mpsc channel (một task riêng sở hữu -// stdout, tránh hai response ghi xen kẽ làm hỏng ranh giới dòng). -// -// Bước 1 (xong): CRUD cấu hình kết nối, connect/test, overview (INFO+DBSIZE), -// và duyệt key (scan/summary/get/set/ttl/delete/rename/memory-usage). -// -// Bước 2 (đây): CLI exec (`exec`) + admin (`client-list`/`slowlog`/ -// `config-get`/`config-set`) — method một-lần, đi qua `handle()` như mọi -// method Bước 1. -// -// Bước 3 (đây): Pub/Sub qua giao thức STREAM. Method `pubsub-subscribe` KHÔNG -// đi qua `handle()` (một-lần) — nó mở một Pub/Sub connection thật rồi phát -// NHIỀU sự kiện `Response::event(id, …)` cho CÙNG một `request.id`, không bao -// giờ tự kết bằng `done: true` (một subscription sống tới khi bị dừng tay). -// Sự kiện đầu tiên mang `{ "type": "subscribed", "subscriptionId": … }` — một -// id NỘI BỘ do chính sidecar sinh ra, khác `request.id` của khung JSONL (một -// `request.id` ứng với một lần gọi `pubsub-subscribe`; dừng phải đến từ một -// request KHÁC, method `unsubscribe`, mang đúng `subscriptionId` đó). Registry -// nội bộ (`REGISTRY`, `Arc>>`) theo dõi -// mọi subscription đang chạy để `unsubscribe` có thể dừng đúng cái cần dừng — -// nhiều subscription có thể chạy đồng thời trên cùng sidecar. -// -// `service_stream_stop` (host) gỡ waiter phía HOST, không truyền tín hiệu -// xuống sidecar (xem service_host.rs) — nên task nền của một subscription chỉ -// tự dừng khi chính nó phát hiện KHÔNG CÒN AI ĐỌC PHẢN HỒI: task nền gửi mọi -// sự kiện qua kênh `tx` dùng chung với writer task sở hữu stdout; writer task -// thoát vòng lặp ngay khi một lần ghi ra stdout thất bại (host đã đóng ống — -// ví dụ bị kill) và khi đó DROP `rx`, nên `tx.send(...)` của task nền bắt đầu -// trả lỗi — đó là tín hiệu THẬT duy nhất để tự dừng, không phải một "unsubscribe" -// nào gọi tới. Gọi `unsubscribe` là cách CHỦ ĐỘNG dừng sạch trước khi đóng app. - -use std::collections::{BTreeMap, HashMap}; -use std::path::PathBuf; -use std::sync::{Arc, Mutex, OnceLock}; - -use futures_util::StreamExt; -use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; -use redis::Value; -use serde::{Deserialize, Serialize}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::sync::mpsc; -use uuid::Uuid; - -const SERVICE_PROTOCOL: u32 = 1; - -// ── Khung tin nhắn (giống devtool-svc-echo, lặp lại có chủ ý — xem lý do ở đó) ─ - -#[derive(Deserialize)] -struct Request { - protocol: u32, - id: String, - #[serde(default)] - method: String, - #[serde(default)] - params: serde_json::Value, -} - -fn is_false(b: &bool) -> bool { - !*b -} - -#[derive(Serialize)] -struct Response { - protocol: u32, - id: String, - #[serde(skip_serializing_if = "Option::is_none")] - result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, - #[serde(default, skip_serializing_if = "is_false")] - stream: bool, - #[serde(default, skip_serializing_if = "is_false")] - done: bool, -} - -impl Response { - fn once(id: String, result: serde_json::Value) -> Self { - Self { protocol: SERVICE_PROTOCOL, id, result: Some(result), error: None, stream: false, done: false } - } - fn ok(id: String) -> Self { - Self::once(id, serde_json::Value::Null) - } - fn err(id: String, message: impl Into) -> Self { - Self { protocol: SERVICE_PROTOCOL, id, result: None, error: Some(message.into()), stream: false, done: false } - } - /// Một-trong-nhiều sự kiện của cùng một lời gọi stream (Pub/Sub) — không - /// bao giờ đi kèm `done: true` ở sidecar này, khác `tick-stream` của - /// devtool-svc-echo (stream hữu hạn). Dừng là việc của registry + method - /// `unsubscribe`, không phải một dòng `done` tự nhiên. - fn event(id: String, result: serde_json::Value) -> Self { - Self { protocol: SERVICE_PROTOCOL, id, result: Some(result), error: None, stream: true, done: false } - } -} - -// ── Connection profile — giữ nguyên hình dạng camelCase của redis_tool.rs ──── - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RedisConnection { - pub id: String, - pub name: String, - pub host: String, - pub port: u16, - #[serde(default)] - pub username: Option, - #[serde(default)] - pub password: Option, - #[serde(default)] - pub use_tls: bool, -} - -// ── Config persistence ─────────────────────────────────────────────────────── -// -// Lưu trong thư mục RIÊNG của sidecar này (`DEVTOOL_SERVICE_DATA_DIR` — -// `/service-data/devtool-svc-redis/`), KHÔNG phải cùng chỗ với file -// `redis-connections.json` mà `redis_tool.rs` (Tier A, vẫn đang chạy song -// song) đang dùng. Cách ly bằng thư mục, không phải bằng tên file: sidecar -// này không biết và không có cách nào đọc/ghi ra ngoài thư mục của chính nó. -// Hệ quả: cấu hình lưu qua Tier A hiện tại và qua sidecar này (chưa có -// `plugin.ts` nào gọi tới nó) là HAI bản riêng biệt cho tới khi Bước 4/5 của -// Phase 2 (xem docs/decisions/architecture/platform-plugin-architecture.md) chuyển hẳn -// frontend sang sidecar này — lúc đó cần một bước di trú (copy nội dung -// `redis-connections.json` cũ vào `connections.json` trong thư mục mới) để -// không mất cấu hình người dùng đã lưu qua Tier A. - -fn app_data_dir() -> Result { - std::env::var("DEVTOOL_SERVICE_DATA_DIR") - .map(PathBuf::from) - .map_err(|_| "DEVTOOL_SERVICE_DATA_DIR không được set — sidecar phải chạy qua service_host.rs".to_string()) -} - -fn configs_path() -> Result { - Ok(app_data_dir()?.join("connections.json")) -} - -fn load_configs() -> Vec { - let Ok(path) = configs_path() else { return Vec::new() }; - std::fs::read_to_string(&path).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default() -} - -fn save_configs(configs: &[RedisConnection]) -> Result<(), String> { - let path = configs_path()?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - let json = serde_json::to_string_pretty(configs).map_err(|e| e.to_string())?; - std::fs::write(&path, json).map_err(|e| e.to_string()) -} - -fn find_config(config_id: &str) -> Result { - load_configs().into_iter().find(|c| c.id == config_id).ok_or_else(|| format!("Connection '{}' not found", config_id)) -} - -// ── Connection ──────────────────────────────────────────────────────────── - -fn redis_url(c: &RedisConnection) -> String { - let scheme = if c.use_tls { "rediss" } else { "redis" }; - let enc = |s: &str| utf8_percent_encode(s, NON_ALPHANUMERIC).to_string(); - let username = c.username.as_deref().unwrap_or(""); - let password = c.password.as_deref().unwrap_or(""); - let auth = if !password.is_empty() { format!("{}:{}@", enc(username), enc(password)) } else { String::new() }; - format!("{scheme}://{auth}{}:{}/0", c.host, c.port) -} - -const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(6); - -async fn connect(c: &RedisConnection, db: u8) -> Result { - let client = redis::Client::open(redis_url(c)).map_err(|e| e.to_string())?; - let mut con = tokio::time::timeout(CONNECT_TIMEOUT, client.get_multiplexed_async_connection()) - .await - .map_err(|_| format!("Connection to {}:{} timed out after {}s", c.host, c.port, CONNECT_TIMEOUT.as_secs()))? - .map_err(|e| e.to_string())?; - if db != 0 { - redis::cmd("SELECT").arg(db).query_async::<()>(&mut con).await.map_err(|e| e.to_string())?; - } - Ok(con) -} - -// ── Overview ──────────────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct RedisOverview { - info: String, - dbsize: i64, -} - -// ── Key browsing ───────────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ScanPage { - cursor: u64, - keys: Vec, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct KeySummary { - key: String, - #[serde(rename = "type")] - kind: String, - ttl_ms: i64, -} - -const VALUE_CAP: i64 = 2000; - -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "type", rename_all = "lowercase")] -enum KeyValue { - None, - #[serde(rename_all = "camelCase")] - String { value: String, ttl_ms: i64 }, - #[serde(rename_all = "camelCase")] - Hash { fields: Vec<(String, String)>, ttl_ms: i64, truncated: bool }, - #[serde(rename_all = "camelCase")] - List { items: Vec, ttl_ms: i64, truncated: bool }, - #[serde(rename_all = "camelCase")] - Set { members: Vec, ttl_ms: i64, truncated: bool }, - #[serde(rename_all = "camelCase")] - Zset { members: Vec<(String, f64)>, ttl_ms: i64, truncated: bool }, - #[serde(rename_all = "camelCase")] - Stream { entries: Vec<(String, Vec<(String, String)>)>, ttl_ms: i64, truncated: bool }, - #[serde(rename_all = "camelCase")] - Unsupported { redis_type: String, ttl_ms: i64 }, -} - -fn value_to_i64(v: Value) -> Option { - match v { - Value::Int(i) => Some(i), - Value::BulkString(b) => String::from_utf8_lossy(&b).parse().ok(), - _ => None, - } -} - -// ── Generic command execution (CLI console + type-editor mutations) — Bước 2 ─ - -/// Port nguyên vẹn từ `redis_tool.rs`'s `RedisReply`/`value_to_reply` — xem -/// đó cho chú thích đầy đủ (binary payload decode lossy có chủ ý, `Value` là -/// `#[non_exhaustive]` nên nhánh `_` bắt các biến thể RESP3 mới thay vì vỡ -/// build khi crate `redis` nâng version). -#[derive(Debug, Clone, Serialize)] -#[serde(tag = "kind", content = "data")] -enum RedisReply { - Nil, - Int(i64), - Bulk(String), - Status(String), - Array(Vec), - Error(String), -} - -fn value_to_reply(v: Value) -> RedisReply { - match v { - Value::Nil => RedisReply::Nil, - Value::Int(i) => RedisReply::Int(i), - Value::BulkString(b) => RedisReply::Bulk(String::from_utf8_lossy(&b).into_owned()), - Value::SimpleString(s) => RedisReply::Status(s), - Value::Okay => RedisReply::Status("OK".to_string()), - Value::Double(d) => RedisReply::Bulk(d.to_string()), - Value::Boolean(b) => RedisReply::Int(if b { 1 } else { 0 }), - Value::VerbatimString { text, .. } => RedisReply::Bulk(text), - Value::Array(items) | Value::Set(items) => RedisReply::Array(items.into_iter().map(value_to_reply).collect()), - Value::Map(pairs) => RedisReply::Array( - pairs.into_iter().flat_map(|(k, v)| [value_to_reply(k), value_to_reply(v)]).collect(), - ), - Value::Push { data, .. } => RedisReply::Array(data.into_iter().map(value_to_reply).collect()), - Value::BigNumber(n) => RedisReply::Bulk(format!("{n:?}")), - Value::Attribute { data, .. } => value_to_reply(*data), - Value::ServerError(e) => RedisReply::Error(e.to_string()), - _ => RedisReply::Bulk(String::new()), - } -} - -fn value_to_status_string(v: Value) -> String { - match value_to_reply(v) { - RedisReply::Status(s) | RedisReply::Bulk(s) => s, - RedisReply::Nil => "none".to_string(), - other => format!("{other:?}"), - } -} - -// ── Server admin — Bước 2 ──────────────────────────────────────────────────── - -/// Port nguyên vẹn từ `redis_tool.rs` — một dòng `SLOWLOG GET` là một mảng -/// lồng `[id, timestamp, duration_micros, [cmd_args…], client_addr, client_name]`. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SlowLogEntry { - id: i64, - timestamp: i64, - duration_micros: i64, - command: Vec, - client_addr: Option, - client_name: Option, -} - -// ── Pub/Sub — Bước 3 ───────────────────────────────────────────────────────── -// -// Registry nội bộ theo dõi các subscription đang chạy, tách biệt khỏi -// `Waiters` phía service_host.rs (đó là registry của HOST theo `request.id` -// của MỘT LẦN GỌI; đây là registry của SIDECAR theo `subscriptionId` NỘI BỘ, -// sống xuyên suốt nhiều lần gọi — một `unsubscribe` request khác hẳn request -// đã gọi `pubsub-subscribe` mới dừng đúng subscription này). -#[derive(Default)] -struct PubSubRegistry { - inner: Mutex>>, -} - -static REGISTRY: OnceLock = OnceLock::new(); - -fn registry() -> &'static PubSubRegistry { - REGISTRY.get_or_init(PubSubRegistry::default) -} - -// ── Params helpers ─────────────────────────────────────────────────────────── - -fn param(params: &serde_json::Value, field: &str) -> Result { - params - .get(field) - .cloned() - .ok_or_else(|| format!("thiếu tham số \"{field}\"")) - .and_then(|v| serde_json::from_value(v).map_err(|e| format!("tham số \"{field}\" sai kiểu: {e}"))) -} - -fn opt_param(params: &serde_json::Value, field: &str) -> Option { - params.get(field).cloned().and_then(|v| serde_json::from_value(v).ok()) -} - -// ── Method dispatch ─────────────────────────────────────────────────────────── - -async fn handle(method: &str, params: serde_json::Value) -> Result { - match method { - "list-configs" => Ok(serde_json::to_value(load_configs()).unwrap()), - - "save-config" => { - let mut config: RedisConnection = param(¶ms, "config")?; - if config.id.is_empty() { - config.id = Uuid::new_v4().to_string(); - } - let mut configs = load_configs(); - if let Some(pos) = configs.iter().position(|c| c.id == config.id) { - configs[pos] = config.clone(); - } else { - configs.push(config.clone()); - } - save_configs(&configs)?; - Ok(serde_json::to_value(config).unwrap()) - } - - "delete-config" => { - let config_id: String = param(¶ms, "configId")?; - let mut configs = load_configs(); - configs.retain(|c| c.id != config_id); - save_configs(&configs)?; - Ok(serde_json::Value::Null) - } - - "test-connection" => { - let config: RedisConnection = param(¶ms, "config")?; - let mut con = connect(&config, 0).await?; - let pong: String = redis::cmd("PING").query_async(&mut con).await.map_err(|e| e.to_string())?; - if pong.eq_ignore_ascii_case("PONG") { - Ok(serde_json::Value::Null) - } else { - Err(format!("Unexpected PING reply: {pong}")) - } - } - - "overview" => { - let config_id: String = param(¶ms, "configId")?; - let db: u8 = param(¶ms, "db")?; - let config = find_config(&config_id)?; - let mut con = connect(&config, db).await?; - let info: String = redis::cmd("INFO").query_async(&mut con).await.map_err(|e| e.to_string())?; - let dbsize: i64 = redis::cmd("DBSIZE").query_async(&mut con).await.map_err(|e| e.to_string())?; - Ok(serde_json::to_value(RedisOverview { info, dbsize }).unwrap()) - } - - "scan-keys" => { - let config_id: String = param(¶ms, "configId")?; - let db: u8 = param(¶ms, "db")?; - let cursor: u64 = param(¶ms, "cursor")?; - let pattern: String = param(¶ms, "pattern")?; - let count: i64 = param(¶ms, "count")?; - let config = find_config(&config_id)?; - let mut con = connect(&config, db).await?; - let pattern = if pattern.trim().is_empty() { "*".to_string() } else { pattern }; - let (next, keys): (u64, Vec) = redis::cmd("SCAN") - .arg(cursor) - .arg("MATCH") - .arg(pattern) - .arg("COUNT") - .arg(count) - .query_async(&mut con) - .await - .map_err(|e| e.to_string())?; - Ok(serde_json::to_value(ScanPage { cursor: next, keys }).unwrap()) - } - - "key-summary" => { - let config_id: String = param(¶ms, "configId")?; - let db: u8 = param(¶ms, "db")?; - let keys: Vec = param(¶ms, "keys")?; - if keys.is_empty() { - return Ok(serde_json::to_value(Vec::::new()).unwrap()); - } - let config = find_config(&config_id)?; - let mut con = connect(&config, db).await?; - let mut pipe = redis::pipe(); - for k in &keys { - pipe.cmd("TYPE").arg(k); - pipe.cmd("PTTL").arg(k); - } - let results: Vec = pipe.query_async(&mut con).await.map_err(|e| e.to_string())?; - let mut out = Vec::with_capacity(keys.len()); - for (i, key) in keys.into_iter().enumerate() { - let kind = value_to_status_string(results.get(i * 2).cloned().unwrap_or(Value::Nil)); - let ttl_ms = value_to_i64(results.get(i * 2 + 1).cloned().unwrap_or(Value::Nil)).unwrap_or(-1); - out.push(KeySummary { key, kind, ttl_ms }); - } - Ok(serde_json::to_value(out).unwrap()) - } - - "get-key" => { - let config_id: String = param(¶ms, "configId")?; - let db: u8 = param(¶ms, "db")?; - let key: String = param(¶ms, "key")?; - let config = find_config(&config_id)?; - let mut con = connect(&config, db).await?; - - let kind: String = redis::cmd("TYPE").arg(&key).query_async(&mut con).await.map_err(|e| e.to_string())?; - if kind == "none" { - return Ok(serde_json::to_value(KeyValue::None).unwrap()); - } - let ttl_ms: i64 = redis::cmd("PTTL").arg(&key).query_async(&mut con).await.map_err(|e| e.to_string())?; - - let value = match kind.as_str() { - "string" => { - let value: String = redis::cmd("GET").arg(&key).query_async(&mut con).await.map_err(|e| e.to_string())?; - KeyValue::String { value, ttl_ms } - } - "hash" => { - let (next, flat): (u64, Vec) = redis::cmd("HSCAN") - .arg(&key) - .arg(0) - .arg("COUNT") - .arg(VALUE_CAP) - .query_async(&mut con) - .await - .map_err(|e| e.to_string())?; - let fields = flat.chunks(2).filter(|c| c.len() == 2).map(|c| (c[0].clone(), c[1].clone())).collect(); - KeyValue::Hash { fields, ttl_ms, truncated: next != 0 } - } - "list" => { - let len: i64 = redis::cmd("LLEN").arg(&key).query_async(&mut con).await.map_err(|e| e.to_string())?; - let items: Vec = redis::cmd("LRANGE") - .arg(&key) - .arg(0) - .arg(VALUE_CAP - 1) - .query_async(&mut con) - .await - .map_err(|e| e.to_string())?; - KeyValue::List { truncated: len > VALUE_CAP, items, ttl_ms } - } - "set" => { - let (next, members): (u64, Vec) = redis::cmd("SSCAN") - .arg(&key) - .arg(0) - .arg("COUNT") - .arg(VALUE_CAP) - .query_async(&mut con) - .await - .map_err(|e| e.to_string())?; - KeyValue::Set { truncated: next != 0, members, ttl_ms } - } - "zset" => { - let (next, flat): (u64, Vec) = redis::cmd("ZSCAN") - .arg(&key) - .arg(0) - .arg("COUNT") - .arg(VALUE_CAP) - .query_async(&mut con) - .await - .map_err(|e| e.to_string())?; - let members = flat - .chunks(2) - .filter(|c| c.len() == 2) - .map(|c| (c[0].clone(), c[1].parse::().unwrap_or(0.0))) - .collect(); - KeyValue::Zset { truncated: next != 0, members, ttl_ms } - } - "stream" => { - let len: i64 = redis::cmd("XLEN").arg(&key).query_async(&mut con).await.map_err(|e| e.to_string())?; - let raw: Vec<(String, Vec)> = redis::cmd("XRANGE") - .arg(&key) - .arg("-") - .arg("+") - .arg("COUNT") - .arg(VALUE_CAP) - .query_async(&mut con) - .await - .map_err(|e| e.to_string())?; - let entries = raw - .into_iter() - .map(|(id, flat)| { - let fields = flat.chunks(2).filter(|c| c.len() == 2).map(|c| (c[0].clone(), c[1].clone())).collect(); - (id, fields) - }) - .collect(); - KeyValue::Stream { entries, ttl_ms, truncated: len > VALUE_CAP } - } - other => KeyValue::Unsupported { redis_type: other.to_string(), ttl_ms }, - }; - Ok(serde_json::to_value(value).unwrap()) - } - - "memory-usage" => { - let config_id: String = param(¶ms, "configId")?; - let db: u8 = param(¶ms, "db")?; - let key: String = param(¶ms, "key")?; - let config = find_config(&config_id)?; - let mut con = connect(&config, db).await?; - let usage: Option = - redis::cmd("MEMORY").arg("USAGE").arg(&key).query_async(&mut con).await.map_err(|e| e.to_string())?; - Ok(serde_json::to_value(usage).unwrap()) - } - - "set-string" => { - let config_id: String = param(¶ms, "configId")?; - let db: u8 = param(¶ms, "db")?; - let key: String = param(¶ms, "key")?; - let value: String = param(¶ms, "value")?; - let ttl_seconds: Option = opt_param(¶ms, "ttlSeconds"); - let config = find_config(&config_id)?; - let mut con = connect(&config, db).await?; - let mut cmd = redis::cmd("SET"); - cmd.arg(&key).arg(&value); - if let Some(t) = ttl_seconds { - if t > 0 { - cmd.arg("EX").arg(t); - } - } - cmd.query_async::<()>(&mut con).await.map_err(|e| e.to_string())?; - Ok(serde_json::Value::Null) - } - - "set-ttl" => { - let config_id: String = param(¶ms, "configId")?; - let db: u8 = param(¶ms, "db")?; - let key: String = param(¶ms, "key")?; - let ttl_seconds: Option = opt_param(¶ms, "ttlSeconds"); - let config = find_config(&config_id)?; - let mut con = connect(&config, db).await?; - match ttl_seconds { - Some(t) if t > 0 => redis::cmd("EXPIRE").arg(&key).arg(t).query_async::<()>(&mut con).await, - _ => redis::cmd("PERSIST").arg(&key).query_async::<()>(&mut con).await, - } - .map_err(|e| e.to_string())?; - Ok(serde_json::Value::Null) - } - - "delete-keys" => { - let config_id: String = param(¶ms, "configId")?; - let db: u8 = param(¶ms, "db")?; - let keys: Vec = param(¶ms, "keys")?; - if keys.is_empty() { - return Ok(serde_json::json!(0)); - } - let config = find_config(&config_id)?; - let mut con = connect(&config, db).await?; - let mut cmd = redis::cmd("DEL"); - for k in &keys { - cmd.arg(k); - } - let deleted: i64 = cmd.query_async(&mut con).await.map_err(|e| e.to_string())?; - Ok(serde_json::json!(deleted)) - } - - "rename-key" => { - let config_id: String = param(¶ms, "configId")?; - let db: u8 = param(¶ms, "db")?; - let old_key: String = param(¶ms, "oldKey")?; - let new_key: String = param(¶ms, "newKey")?; - let config = find_config(&config_id)?; - let mut con = connect(&config, db).await?; - let renamed: i64 = redis::cmd("RENAMENX") - .arg(&old_key) - .arg(&new_key) - .query_async(&mut con) - .await - .map_err(|e| e.to_string())?; - if renamed == 1 { - Ok(serde_json::Value::Null) - } else { - Err(format!("A key named '{new_key}' already exists")) - } - } - - // ── Bước 2: CLI exec + admin ───────────────────────────────────────── - - "exec" => { - let config_id: String = param(¶ms, "configId")?; - let db: u8 = param(¶ms, "db")?; - let args: Vec = param(¶ms, "args")?; - if args.is_empty() { - return Err("Empty command".to_string()); - } - let config = find_config(&config_id)?; - let mut con = connect(&config, db).await?; - let mut cmd = redis::cmd(&args[0]); - for a in &args[1..] { - cmd.arg(a); - } - let value: Value = cmd.query_async(&mut con).await.map_err(|e| e.to_string())?; - Ok(serde_json::to_value(value_to_reply(value)).unwrap()) - } - - "client-list" => { - let config_id: String = param(¶ms, "configId")?; - let config = find_config(&config_id)?; - let mut con = connect(&config, 0).await?; - let raw: String = redis::cmd("CLIENT").arg("LIST").query_async(&mut con).await.map_err(|e| e.to_string())?; - let mut out = Vec::new(); - for line in raw.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - let mut row = BTreeMap::new(); - for kv in line.split_whitespace() { - if let Some((k, v)) = kv.split_once('=') { - row.insert(k.to_string(), v.to_string()); - } - } - out.push(row); - } - let out: Vec> = out; - Ok(serde_json::to_value(out).unwrap()) - } - - "slowlog" => { - let config_id: String = param(¶ms, "configId")?; - let count: i64 = param(¶ms, "count")?; - let config = find_config(&config_id)?; - let mut con = connect(&config, 0).await?; - let value: Value = redis::cmd("SLOWLOG").arg("GET").arg(count).query_async(&mut con).await.map_err(|e| e.to_string())?; - let Value::Array(entries) = value else { return Ok(serde_json::to_value(Vec::::new()).unwrap()) }; - let mut out = Vec::with_capacity(entries.len()); - for entry in entries { - let Value::Array(fields) = entry else { continue }; - let id = fields.first().cloned().and_then(value_to_i64).unwrap_or(0); - let timestamp = fields.get(1).cloned().and_then(value_to_i64).unwrap_or(0); - let duration_micros = fields.get(2).cloned().and_then(value_to_i64).unwrap_or(0); - let command = match fields.get(3) { - Some(Value::Array(args)) => args.iter().cloned().map(value_to_status_string).collect(), - _ => Vec::new(), - }; - let client_addr = fields.get(4).cloned().map(value_to_status_string).filter(|s| !s.is_empty()); - let client_name = fields.get(5).cloned().map(value_to_status_string).filter(|s| !s.is_empty()); - out.push(SlowLogEntry { id, timestamp, duration_micros, command, client_addr, client_name }); - } - Ok(serde_json::to_value(out).unwrap()) - } - - "config-get" => { - let config_id: String = param(¶ms, "configId")?; - let pattern: String = param(¶ms, "pattern")?; - let config = find_config(&config_id)?; - let mut con = connect(&config, 0).await?; - let pattern = if pattern.trim().is_empty() { "*".to_string() } else { pattern }; - let flat: Vec = redis::cmd("CONFIG").arg("GET").arg(pattern).query_async(&mut con).await.map_err(|e| e.to_string())?; - let out: Vec<(String, String)> = flat.chunks(2).filter(|c| c.len() == 2).map(|c| (c[0].clone(), c[1].clone())).collect(); - Ok(serde_json::to_value(out).unwrap()) - } - - "config-set" => { - let config_id: String = param(¶ms, "configId")?; - let param_name: String = param(¶ms, "param")?; - let value: String = param(¶ms, "value")?; - let config = find_config(&config_id)?; - let mut con = connect(&config, 0).await?; - redis::cmd("CONFIG").arg("SET").arg(¶m_name).arg(&value).query_async::<()>(&mut con).await.map_err(|e| e.to_string())?; - Ok(serde_json::Value::Null) - } - - // ── Bước 3: Pub/Sub — one-shot phần của method dispatch ────────────── - // `pubsub-subscribe` (stream) KHÔNG đi qua đây — xem `handle_pubsub_subscribe`. - - "unsubscribe" => { - let subscription_id: String = param(¶ms, "subscriptionId")?; - if let Some(notify) = registry().inner.lock().unwrap().remove(&subscription_id) { - notify.notify_one(); - } - Ok(serde_json::Value::Null) - } - - "publish" => { - let config_id: String = param(¶ms, "configId")?; - let channel: String = param(¶ms, "channel")?; - let message: String = param(¶ms, "message")?; - let config = find_config(&config_id)?; - let mut con = connect(&config, 0).await?; - let n: i64 = redis::cmd("PUBLISH").arg(&channel).arg(&message).query_async(&mut con).await.map_err(|e| e.to_string())?; - Ok(serde_json::json!(n)) - } - - other => Err(format!("method không hỗ trợ: \"{other}\"")), - } -} - -/// `pubsub-subscribe` là method STREAM — không trả một `Ok(Value)` đơn lẻ như -/// `handle()`, vì nó phải phát nhiều sự kiện cho cùng một `request.id` theo -/// thời gian. Mở kết nối Pub/Sub thật (`get_async_pubsub`, giống -/// `redis_pubsub_subscribe` cũ ở `redis_tool.rs`), đăng ký subscription vào -/// `REGISTRY`, gửi một sự kiện "subscribed" mang `subscriptionId` NỘI BỘ rồi -/// spawn một task nền phát mỗi message Pub/Sub nhận được như một sự kiện -/// riêng — task đó tự dừng khi `tx.send(...)` bắt đầu lỗi (writer task đã -/// thoát vì ghi stdout lỗi), hoặc khi `unsubscribe` báo `notify`. -/// Gửi lỗi như một SỰ KIỆN STREAM bình thường (`Response::event`, `error: -/// None`), KHÔNG dùng `Response::err` (đặt `error: Some(...)` ở tầng khung -/// tin nhắn). Lý do bắt buộc: `service_host.rs::dispatch()` coi một -/// `ServiceResponse` mang `error: Some(...)` cho một `Waiter::Stream` là "kết -/// thúc" và ÂM THẦM BỎ QUA — không có cách nào đưa nó tới `EventSink` (`sink` -/// chỉ nhận `send(&self, Value)`, không có khái niệm lỗi). Nếu dùng -/// `Response::err` ở đây, một lần subscribe thất bại (sai config, mất kết -/// nối, timeout) sẽ KHÔNG BAO GIỜ tới được `onMessage` phía client — người -/// dùng thấy "Đang lắng nghe…" mãi mãi mà không có gì xảy ra, không một dòng -/// lỗi nào. Gói lỗi vào chính giao thức ứng dụng (`{"type":"error", ...}`, -/// cùng tầng với `"subscribed"`/`"message"` đã có) để nó đi qua đúng con -/// đường event bình thường, nơi phía TS (`createRedisApi.pubsubSubscribe`) -/// đợi đúng "subscribed" hoặc "error" trước khi coi lời gọi là thành công. -fn send_pubsub_error(tx: &mpsc::UnboundedSender, id: &str, message: impl Into) { - let _ = tx.send(Response::event(id.to_string(), serde_json::json!({ "type": "error", "message": message.into() }))); -} - -async fn handle_pubsub_subscribe(id: String, params: serde_json::Value, tx: mpsc::UnboundedSender) { - let channels: Vec = opt_param(¶ms, "channels").unwrap_or_default(); - let patterns: Vec = opt_param(¶ms, "patterns").unwrap_or_default(); - if channels.is_empty() && patterns.is_empty() { - send_pubsub_error(&tx, &id, "Provide at least one channel or pattern"); - return; - } - let config_id: String = match param(¶ms, "configId") { - Ok(v) => v, - Err(e) => { - send_pubsub_error(&tx, &id, e); - return; - } - }; - let config = match find_config(&config_id) { - Ok(c) => c, - Err(e) => { - send_pubsub_error(&tx, &id, e); - return; - } - }; - let client = match redis::Client::open(redis_url(&config)) { - Ok(c) => c, - Err(e) => { - send_pubsub_error(&tx, &id, e.to_string()); - return; - } - }; - let mut pubsub = match tokio::time::timeout(CONNECT_TIMEOUT, client.get_async_pubsub()).await { - Ok(Ok(p)) => p, - Ok(Err(e)) => { - send_pubsub_error(&tx, &id, e.to_string()); - return; - } - Err(_) => { - send_pubsub_error( - &tx, - &id, - format!("Connection to {}:{} timed out after {}s", config.host, config.port, CONNECT_TIMEOUT.as_secs()), - ); - return; - } - }; - - for ch in &channels { - if let Err(e) = pubsub.subscribe(ch).await { - send_pubsub_error(&tx, &id, e.to_string()); - return; - } - } - for pat in &patterns { - if let Err(e) = pubsub.psubscribe(pat).await { - send_pubsub_error(&tx, &id, e.to_string()); - return; - } - } - - let subscription_id = Uuid::new_v4().to_string(); - let notify = Arc::new(tokio::sync::Notify::new()); - registry().inner.lock().unwrap().insert(subscription_id.clone(), notify.clone()); - - if tx - .send(Response::event(id.clone(), serde_json::json!({ "type": "subscribed", "subscriptionId": subscription_id }))) - .is_err() - { - registry().inner.lock().unwrap().remove(&subscription_id); - return; - } - - tokio::spawn(async move { - let mut stream = pubsub.into_on_message(); - loop { - tokio::select! { - _ = notify.notified() => break, - next = stream.next() => match next { - Some(msg) => { - let event = serde_json::json!({ - "type": "message", - "channel": msg.get_channel_name(), - "pattern": msg.get_pattern::().ok(), - "payload": msg.get_payload::().unwrap_or_default(), - }); - if tx.send(Response::event(id.clone(), event)).is_err() { - // Writer task đã thoát (ghi stdout lỗi) — không còn - // ai đọc phản hồi nữa, tự dừng thay vì trôi vô hạn. - break; - } - } - None => break, - } - } - } - registry().inner.lock().unwrap().remove(&subscription_id); - }); -} - -// ── main: đọc stdin, spawn một task async cho mỗi dòng, một task khác sở hữu -// stdout để mọi phản hồi ghi ra không bị xen lẫn giữa hai task. ────────── - -#[tokio::main] -async fn main() { - let (tx, mut rx) = mpsc::unbounded_channel::(); - - let writer = tokio::spawn(async move { - let mut stdout = tokio::io::stdout(); - while let Some(response) = rx.recv().await { - let Ok(json) = serde_json::to_string(&response) else { continue }; - if stdout.write_all(json.as_bytes()).await.is_err() { break; } - if stdout.write_all(b"\n").await.is_err() { break; } - if stdout.flush().await.is_err() { break; } - } - }); - - let stdin = tokio::io::stdin(); - let mut lines = BufReader::new(stdin).lines(); - while let Ok(Some(line)) = lines.next_line().await { - if line.trim().is_empty() { - continue; - } - let tx = tx.clone(); - tokio::spawn(async move { - handle_line(&line, &tx).await; - }); - } - - drop(tx); - let _ = writer.await; - // Lưu ý: nếu có subscription Pub/Sub đang chạy, task nền của nó vẫn giữ - // một bản sao `tx` nên `writer` (và do đó main()) sẽ KHÔNG tự thoát chỉ vì - // stdin đóng — `service_host.rs` spawn sidecar với `kill_on_drop(true)` - // (xem đó), nên tiến trình vẫn bị buộc dừng khi host kill/drop child - // handle bất kể trạng thái kênh nội bộ này. -} - -/// `pubsub-subscribe` KHÔNG đi qua `handle()` (một-lần) — nó cần giữ `tx` để -/// phát nhiều sự kiện theo thời gian, xem `handle_pubsub_subscribe`. Mọi -/// method khác vẫn đi qua đường một-lần cũ, không đổi hành vi. -async fn handle_line(line: &str, tx: &mpsc::UnboundedSender) { - let req: Request = match serde_json::from_str(line) { - Ok(r) => r, - Err(e) => { - let _ = tx.send(Response::err(String::new(), format!("JSON không hợp lệ: {e}"))); - return; - } - }; - if req.protocol != SERVICE_PROTOCOL { - let _ = tx.send(Response::err(req.id, format!("lệch protocol: client {}, sidecar {SERVICE_PROTOCOL}", req.protocol))); - return; - } - if req.method == "pubsub-subscribe" { - handle_pubsub_subscribe(req.id, req.params, tx.clone()).await; - return; - } - let response = match handle(&req.method, req.params).await { - Ok(value) if value.is_null() => Response::ok(req.id), - Ok(value) => Response::once(req.id, value), - Err(e) => Response::err(req.id, e), - }; - let _ = tx.send(response); -} - -#[cfg(test)] -mod tests { - use super::*; - - fn conn(host: &str, port: u16, username: Option<&str>, password: Option<&str>, use_tls: bool) -> RedisConnection { - RedisConnection { - id: "id".to_string(), - name: "name".to_string(), - host: host.to_string(), - port, - username: username.map(str::to_string), - password: password.map(str::to_string), - use_tls, - } - } - - // Mười test đầu là bằng chứng safety-net: y hệt characterization test của - // redis_tool.rs (giữ nguyên tên và nội dung có chủ ý — đây là bằng chứng - // hành vi không đổi khi port sang tiến trình riêng). - - #[test] - fn redis_url_plain_no_auth() { - assert_eq!(redis_url(&conn("localhost", 6379, None, None, false)), "redis://localhost:6379/0"); - } - - #[test] - fn redis_url_uses_rediss_scheme_for_tls() { - assert_eq!(redis_url(&conn("localhost", 6380, None, None, true)), "rediss://localhost:6380/0"); - } - - #[test] - fn redis_url_includes_auth_when_password_set() { - assert_eq!( - redis_url(&conn("localhost", 6379, Some("alice"), Some("secret"), false)), - "redis://alice:secret@localhost:6379/0" - ); - } - - #[test] - fn redis_url_omits_auth_when_password_empty() { - assert_eq!(redis_url(&conn("localhost", 6379, Some("alice"), None, false)), "redis://localhost:6379/0"); - } - - #[test] - fn redis_url_percent_encodes_special_chars_in_credentials() { - assert_eq!( - redis_url(&conn("localhost", 6379, Some("a b"), Some("p@ss/word"), false)), - "redis://a%20b:p%40ss%2Fword@localhost:6379/0" - ); - } - - #[test] - fn value_to_status_string_prefers_status_or_bulk_text() { - assert_eq!(value_to_status_string(Value::Okay), "OK"); - assert_eq!(value_to_status_string(Value::BulkString(b"abc".to_vec())), "abc"); - } - - #[test] - fn value_to_status_string_nil_is_none_literal() { - assert_eq!(value_to_status_string(Value::Nil), "none"); - } - - #[test] - fn value_to_i64_parses_int_variant() { - assert_eq!(value_to_i64(Value::Int(123)), Some(123)); - } - - #[test] - fn value_to_i64_parses_numeric_bulk_string() { - assert_eq!(value_to_i64(Value::BulkString(b"456".to_vec())), Some(456)); - } - - #[test] - fn value_to_i64_none_for_non_numeric_bulk_string() { - assert_eq!(value_to_i64(Value::BulkString(b"not-a-number".to_vec())), None); - } - - // ── Test riêng cho phần hạ tầng chỉ có ở sidecar (không có trong redis_tool.rs) ─ - - #[test] - fn app_data_dir_bao_loi_ro_rang_khi_thieu_bien_moi_truong() { - // Không set biến môi trường trong test này — nhưng test khác trong - // cùng binary CÓ THỂ chạy song song và set nó (không có ở đây), nên chỉ - // assert khi biến thực sự vắng mặt để tránh test chập chờn. - if std::env::var("DEVTOOL_SERVICE_DATA_DIR").is_err() { - assert!(app_data_dir().unwrap_err().contains("DEVTOOL_SERVICE_DATA_DIR")); - } - } - - #[tokio::test] - async fn method_khong_ho_tro_tra_ve_loi_khong_phai_panic() { - let res = handle("khong-ton-tai", serde_json::Value::Null).await; - assert!(res.unwrap_err().contains("khong-ton-tai")); - } - - #[tokio::test] - async fn thieu_tham_so_bat_buoc_tra_ve_loi_ro_rang() { - let res = handle("overview", serde_json::json!({})).await; - assert!(res.unwrap_err().contains("configId")); - } - - #[tokio::test] - async fn config_id_khong_ton_tai_tra_ve_loi_ro_rang() { - // configs_path() phụ thuộc DEVTOOL_SERVICE_DATA_DIR; nếu thiếu, find_config - // vẫn phải trả lỗi rõ ràng (load_configs() rơi về rỗng), không panic. - let res = handle("overview", serde_json::json!({ "configId": "khong-ton-tai", "db": 0 })).await; - assert!(res.is_err()); - } - - #[tokio::test] - async fn delete_keys_rong_tra_ve_khong_can_config() { - // Nhánh sớm-thoát khi `keys` rỗng — không cần configId hợp lệ, y hệt - // hành vi gốc `redis_delete_keys` (kiểm bằng cách KHÔNG set configId). - let res = handle("delete-keys", serde_json::json!({ "configId": "x", "db": 0, "keys": [] })).await; - assert_eq!(res.unwrap(), serde_json::json!(0)); - } - - #[tokio::test] - async fn key_summary_rong_tra_ve_mang_rong_khong_can_config() { - let res = handle("key-summary", serde_json::json!({ "configId": "x", "db": 0, "keys": [] })).await; - assert_eq!(res.unwrap(), serde_json::json!([])); - } - - /// `handle_line` giờ trả về qua kênh `tx` thay vì trực tiếp (cần vậy để - /// `pubsub-subscribe` phát nhiều sự kiện) — test dựng một kênh riêng và - /// đọc lại đúng một `Response` cho các method một-lần. - async fn call_handle_line(line: &str) -> Response { - let (tx, mut rx) = mpsc::unbounded_channel::(); - handle_line(line, &tx).await; - drop(tx); - rx.recv().await.expect("handle_line phải gửi đúng một response cho method một-lần") - } - - #[tokio::test] - async fn handle_line_tra_null_cho_ket_qua_null_thanh_response_khong_result() { - let line = serde_json::json!({ - "protocol": SERVICE_PROTOCOL, "id": "1", "method": "delete-config", - "params": { "configId": "khong-quan-trong" }, - }) - .to_string(); - // delete-config không cần config tồn tại (retain là no-op nếu không có) — - // nhưng vẫn cần DEVTOOL_SERVICE_DATA_DIR để ghi lại file, nên test này chỉ - // xác nhận KHÔNG panic và trả về một Response hợp lệ (lỗi hay không tuỳ - // môi trường chạy test có set biến hay không). - let response = call_handle_line(&line).await; - assert_eq!(response.id, "1"); - assert_eq!(response.protocol, SERVICE_PROTOCOL); - } - - #[tokio::test] - async fn lech_protocol_bi_tu_choi_truoc_khi_cham_toi_method() { - let line = serde_json::json!({ "protocol": 99, "id": "1", "method": "list-configs", "params": null }).to_string(); - let response = call_handle_line(&line).await; - assert!(response.error.unwrap().contains("lệch protocol")); - } - - #[tokio::test] - async fn json_hong_khong_panic_tra_ve_loi() { - let response = call_handle_line("{khong-phai-json").await; - assert!(response.error.unwrap().contains("JSON không hợp lệ")); - } - - // ── Bước 2: value_to_reply / RedisReply — port test từ redis_tool.rs ────── - - #[test] - fn value_to_reply_maps_scalars() { - assert!(matches!(value_to_reply(Value::Nil), RedisReply::Nil)); - assert!(matches!(value_to_reply(Value::Int(42)), RedisReply::Int(42))); - assert!(matches!(value_to_reply(Value::Okay), RedisReply::Status(s) if s == "OK")); - match value_to_reply(Value::SimpleString("PONG".to_string())) { - RedisReply::Status(s) => assert_eq!(s, "PONG"), - other => panic!("unexpected {other:?}"), - } - match value_to_reply(Value::BulkString(b"hello".to_vec())) { - RedisReply::Bulk(s) => assert_eq!(s, "hello"), - other => panic!("unexpected {other:?}"), - } - } - - #[test] - fn value_to_reply_maps_boolean_to_int() { - assert!(matches!(value_to_reply(Value::Boolean(true)), RedisReply::Int(1))); - assert!(matches!(value_to_reply(Value::Boolean(false)), RedisReply::Int(0))); - } - - #[test] - fn value_to_reply_maps_array_recursively() { - let v = Value::Array(vec![Value::Int(1), Value::BulkString(b"two".to_vec())]); - match value_to_reply(v) { - RedisReply::Array(items) => { - assert_eq!(items.len(), 2); - assert!(matches!(items[0], RedisReply::Int(1))); - match &items[1] { - RedisReply::Bulk(s) => assert_eq!(s, "two"), - other => panic!("unexpected {other:?}"), - } - } - other => panic!("unexpected {other:?}"), - } - } - - #[test] - fn value_to_reply_flattens_map_into_kv_pairs() { - let v = Value::Map(vec![(Value::BulkString(b"k".to_vec()), Value::Int(1))]); - match value_to_reply(v) { - RedisReply::Array(items) => assert_eq!(items.len(), 2), - other => panic!("unexpected {other:?}"), - } - } - - #[test] - fn value_to_reply_unwraps_attribute() { - let v = Value::Attribute { data: Box::new(Value::Int(7)), attributes: vec![] }; - assert!(matches!(value_to_reply(v), RedisReply::Int(7))); - } - - #[test] - fn value_to_status_string_falls_back_to_debug_for_other_variants() { - let s = value_to_status_string(Value::Int(5)); - assert_eq!(s, format!("{:?}", RedisReply::Int(5))); - } - - #[tokio::test] - async fn exec_voi_args_rong_tra_ve_loi_khong_can_config() { - let res = handle("exec", serde_json::json!({ "configId": "x", "db": 0, "args": [] })).await; - assert_eq!(res.unwrap_err(), "Empty command"); - } - - #[tokio::test] - async fn exec_thieu_tham_so_tra_ve_loi_ro_rang() { - let res = handle("exec", serde_json::json!({})).await; - assert!(res.unwrap_err().contains("configId")); - } - - #[tokio::test] - async fn slowlog_thieu_tham_so_tra_ve_loi_ro_rang() { - let res = handle("slowlog", serde_json::json!({ "configId": "x" })).await; - assert!(res.unwrap_err().contains("count")); - } - - #[tokio::test] - async fn config_get_thieu_tham_so_tra_ve_loi_ro_rang() { - let res = handle("config-get", serde_json::json!({ "configId": "x" })).await; - assert!(res.unwrap_err().contains("pattern")); - } - - // ── Bước 3: registry + validation của pubsub-subscribe/unsubscribe ─────── - - #[tokio::test] - async fn unsubscribe_id_khong_ton_tai_van_ok_khong_panic() { - // Gỡ một subscriptionId chưa từng đăng ký là no-op (giống bản Tier A) — - // không có gì để dừng nhưng vẫn phải trả Ok, không lỗi/panic. - let res = handle("unsubscribe", serde_json::json!({ "subscriptionId": "khong-ton-tai" })).await; - assert!(res.is_ok()); - } - - #[tokio::test] - async fn unsubscribe_thao_dung_entry_khoi_registry_va_bao_notify() { - let id = "sub-test-1".to_string(); - let notify = Arc::new(tokio::sync::Notify::new()); - registry().inner.lock().unwrap().insert(id.clone(), notify.clone()); - - let notified = tokio::spawn({ - let notify = notify.clone(); - async move { notify.notified().await } - }); - - let res = handle("unsubscribe", serde_json::json!({ "subscriptionId": id })).await; - assert!(res.is_ok()); - // notify_one() đã được gọi bên trong handle("unsubscribe", …) — task - // đang chờ notified() phải hoàn tất, không treo. - tokio::time::timeout(std::time::Duration::from_secs(2), notified).await.expect("notify phải bắn").unwrap(); - assert!(!registry().inner.lock().unwrap().contains_key(&id)); - } - - #[tokio::test] - async fn pubsub_subscribe_khong_channel_khong_pattern_bao_loi_ro_rang() { - // Lỗi phải đi qua như một SỰ KIỆN STREAM (`response.error.is_none()`, - // `stream: true`, payload `{"type":"error",...}`) — KHÔNG phải - // `response.error: Some(...)` ở tầng khung tin nhắn, vì - // `service_host.rs::dispatch()` âm thầm bỏ qua lỗi tầng khung cho một - // `Waiter::Stream` (xem comment ở `send_pubsub_error`). Nếu test này - // đỏ vì `response.error` lại có giá trị, đó là dấu hiệu bug cũ - // (subscribe thất bại nhưng client không bao giờ biết) đã quay lại. - let (tx, mut rx) = mpsc::unbounded_channel::(); - handle_pubsub_subscribe("req-1".to_string(), serde_json::json!({ "channels": [], "patterns": [] }), tx).await; - let response = rx.recv().await.expect("phải gửi đúng một sự kiện lỗi"); - assert_eq!(response.id, "req-1"); - assert!(response.error.is_none(), "lỗi phải đi qua stream event, không phải response.error"); - assert!(response.stream); - let event = response.result.expect("sự kiện lỗi phải mang result"); - assert_eq!(event["type"], "error"); - assert!(event["message"].as_str().unwrap().contains("channel or pattern")); - } - - #[tokio::test] - async fn pubsub_subscribe_thieu_config_id_bao_loi_ro_rang() { - let (tx, mut rx) = mpsc::unbounded_channel::(); - handle_pubsub_subscribe("req-2".to_string(), serde_json::json!({ "channels": ["ch"] }), tx).await; - let response = rx.recv().await.expect("phải gửi đúng một sự kiện lỗi"); - assert!(response.error.is_none()); - let event = response.result.unwrap(); - assert_eq!(event["type"], "error"); - assert!(event["message"].as_str().unwrap().contains("configId")); - } - - #[tokio::test] - async fn pubsub_subscribe_config_khong_ton_tai_bao_loi_ro_rang() { - let (tx, mut rx) = mpsc::unbounded_channel::(); - handle_pubsub_subscribe( - "req-3".to_string(), - serde_json::json!({ "channels": ["ch"], "configId": "khong-ton-tai-chac-chan" }), - tx, - ) - .await; - let response = rx.recv().await.expect("phải gửi đúng một sự kiện lỗi"); - assert!(response.error.is_none()); - assert_eq!(response.result.unwrap()["type"], "error"); - } - -} diff --git a/src-tauri/src/kafka.rs b/src-tauri/src/kafka.rs deleted file mode 100644 index b8021c47..00000000 --- a/src-tauri/src/kafka.rs +++ /dev/null @@ -1,1466 +0,0 @@ -use rskafka::client::{ClientBuilder, partition::{Compression, OffsetAt, UnknownTopicHandling}}; -use rskafka::record::Record; -use serde::{Deserialize, Serialize}; -use tauri::AppHandle; -use tauri::ipc::Channel; -use chrono::Utc; -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpStream; -use tokio::sync::watch; -use base64::Engine; -use uuid::Uuid; - -// ── Data types ──────────────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct BrokerConfig { - pub id: String, - pub name: String, - pub bootstrap_servers: String, - pub sasl_mechanism: Option, - pub sasl_username: Option, - pub sasl_password: Option, - pub ssl_enabled: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TopicSummary { - pub name: String, - pub partition_count: i32, - pub replication_factor: i32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PartitionInfo { - pub id: i32, - pub leader: i32, - pub earliest_offset: i64, - pub latest_offset: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GroupLag { - pub group_id: String, - pub partition: i32, - pub committed_offset: i64, - pub lag: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TopicDetails { - pub name: String, - pub partitions: Vec, - pub replication_factor: i32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TopicConfig { - pub name: String, - pub value: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GroupSummary { - pub group_id: String, - pub state: String, - pub protocol_type: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Assignment { - pub topic: String, - pub partition: i32, - pub committed_offset: i64, - pub lag: i64, - // The member currently assigned this partition, if the group is active. - // None for committed offsets with no live owner (e.g. Empty groups). - pub client_id: Option, - pub client_host: Option, - pub member_id: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GroupMember { - pub member_id: String, - pub client_id: String, - pub client_host: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GroupDetails { - pub group_id: String, - pub state: String, - pub member_count: i32, - pub members: Vec, - pub assignments: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProduceResult { - pub partition: i32, - pub offset: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct KafkaMessage { - pub offset: i64, - pub partition: i32, - pub timestamp: String, - pub key: Option, - pub value: Option, - pub headers: std::collections::BTreeMap, -} - -/// A record delivered by the realtime (anonymous) consumer. Carries the value -/// both as UTF-8 text (when decodable) and base64 of the raw bytes, so the UI can -/// render it as plain string, prettified JSON, or a hex dump. -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct KafkaConsumedMessage { - pub partition: i32, - pub offset: i64, - pub timestamp: String, - pub key: Option, - /// UTF-8 value when decodable, else null. - pub value: Option, - /// Raw value bytes, base64-encoded (None when the record had no value). - pub value_b64: Option, - pub headers: std::collections::BTreeMap, -} - -/// Tracks running realtime consumers so they can be stopped. Each entry's -/// `watch::Sender` flips to `true` to signal every per-partition poll task -/// to stop. The spawned task removes itself from the map on exit. -#[derive(Default, Clone)] -pub struct KafkaConsumerRegistry { - inner: Arc>>>, -} - -// ── Config persistence ──────────────────────────────────────────────────────── - -// Nằm trong thư mục RIÊNG của plugin này (`plugin-data/kafka-explorer/`), không -// còn phẳng ở gốc app_data — xem plugin_data.rs. Đổi vị trí có chủ ý, không di -// trú file cũ: đây chỉ là cấu hình kết nối (broker host/port), người dùng tự -// nhập lại được, không phải dữ liệu cần bảo toàn. -fn configs_path(app: &AppHandle) -> Result { - Ok(crate::plugin_data::plugin_data_dir(app, "kafka-explorer")?.join("brokers.json")) -} - -fn load_configs(app: &AppHandle) -> Vec { - // Treat a missing/unresolvable path as "no configs yet" rather than panicking. - let Ok(path) = configs_path(app) else { return Vec::new(); }; - std::fs::read_to_string(&path) - .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default() -} - -fn save_configs(app: &AppHandle, configs: &[BrokerConfig]) -> Result<(), String> { - let path = configs_path(app)?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - let json = serde_json::to_string_pretty(configs).map_err(|e| e.to_string())?; - std::fs::write(&path, json).map_err(|e| e.to_string()) -} - -fn find_config(app: &AppHandle, config_id: &str) -> Result { - load_configs(app) - .into_iter() - .find(|c| c.id == config_id) - .ok_or_else(|| format!("Broker config '{}' not found", config_id)) -} - -// ── rskafka client ──────────────────────────────────────────────────────────── - -fn parse_brokers(bootstrap_servers: &str) -> Vec { - bootstrap_servers - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect() -} - -/// Validate create-topic inputs before any broker call, so obviously-invalid -/// requests (empty name, 0/negative/absurd partition count, bad RF) are -/// rejected client-side with a clear message. -fn validate_create_topic(name: &str, num_partitions: i32, replication_factor: i16) -> Result<(), String> { - if name.trim().is_empty() { - return Err("Topic name is required".into()); - } - if !(1..=10_000).contains(&num_partitions) { - return Err(format!("Partitions must be between 1 and 10000 (got {num_partitions})")); - } - if replication_factor < 1 { - return Err(format!("Replication factor must be at least 1 (got {replication_factor})")); - } - Ok(()) -} - -async fn make_client(config: &BrokerConfig) -> Result { - let brokers = parse_brokers(&config.bootstrap_servers); - if brokers.is_empty() { - return Err("No broker addresses specified".to_string()); - } - ClientBuilder::new(brokers) - .build() - .await - .map_err(|e| e.to_string()) -} - -// ── Kafka wire protocol ─────────────────────────────────────────────────────── -// Used for operations not covered by rskafka: metadata, offsets, consumer groups, create topic. - -async fn connect_broker(addr: &str) -> Result { - tokio::time::timeout( - std::time::Duration::from_secs(5), - TcpStream::connect(addr), - ) - .await - .map_err(|_| format!("Timeout connecting to {}", addr))? - .map_err(|e| format!("Failed to connect to {}: {}", addr, e)) -} - -/// Opens a TcpStream to the first address in bootstrap_servers that speaks Kafka protocol. -/// Probes each address with a MetadataRequest so that non-Kafka ports (e.g. ZooKeeper) -/// are skipped automatically instead of returning "early eof". -async fn open_kafka_stream(config: &BrokerConfig) -> Result { - let brokers = parse_brokers(&config.bootstrap_servers); - if brokers.is_empty() { - return Err("No broker addresses specified".to_string()); - } - let mut errors: Vec = Vec::new(); - for addr in &brokers { - match connect_broker(addr).await { - Err(e) => { errors.push(format!("{addr}: {e}")); } - Ok(mut probe) => { - match wire_metadata(&mut probe, &[]).await { - Ok(_) => { - // Confirmed Kafka. Reuse this very connection for the command: - // send_request read the probe's full framed response, so the - // stream is at a clean message boundary. Reusing it (instead of - // opening a second connection) halves the TCP connections every - // read operation makes against the broker. - return Ok(probe); - } - Err(e) => { - errors.push(format!("{addr}: {e} (not a Kafka broker?)")); - } - } - } - } - } - Err(format!( - "Could not reach any Kafka broker. Tried: {}. \ - Make sure bootstrap servers contains only Kafka broker ports, not ZooKeeper.", - errors.join("; ") - )) -} - -async fn send_request( - stream: &mut TcpStream, - api_key: i16, - api_version: i16, - correlation_id: i32, - body: &[u8], -) -> Result, String> { - let client_id = b"devtool"; - let mut msg = Vec::new(); - msg.extend_from_slice(&api_key.to_be_bytes()); - msg.extend_from_slice(&api_version.to_be_bytes()); - msg.extend_from_slice(&correlation_id.to_be_bytes()); - msg.extend_from_slice(&(client_id.len() as i16).to_be_bytes()); - msg.extend_from_slice(client_id); - msg.extend_from_slice(body); - - const WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); - const READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); - - // Wrap writes in a timeout too — a half-open / wedged socket would otherwise - // let write_all block indefinitely and hang the command. - let size = msg.len() as i32; - tokio::time::timeout(WRITE_TIMEOUT, stream.write_all(&size.to_be_bytes())) - .await - .map_err(|_| "Kafka write timed out (30 s)".to_string())? - .map_err(|e| e.to_string())?; - tokio::time::timeout(WRITE_TIMEOUT, stream.write_all(&msg)) - .await - .map_err(|_| "Kafka write timed out (30 s)".to_string())? - .map_err(|e| e.to_string())?; - - let mut size_buf = [0u8; 4]; - tokio::time::timeout(READ_TIMEOUT, stream.read_exact(&mut size_buf)) - .await - .map_err(|_| "Kafka read timed out (30 s)".to_string())? - .map_err(|e| e.to_string())?; - let rlen = i32::from_be_bytes(size_buf); - // The length comes straight off the socket. Validate it before allocating: - // a non-Kafka server (e.g. an HTTP/TLS port reached by a typo in - // bootstrap_servers) or a malicious peer could otherwise send a huge or - // negative size and trigger a multi-GB allocation → OOM crash. A response - // must also be at least 4 bytes to contain the correlation id we skip below. - const MAX_RESPONSE_BYTES: i32 = 64 * 1024 * 1024; // 64 MB — ample for metadata/offsets/groups/configs - if rlen < 4 || rlen > MAX_RESPONSE_BYTES { - return Err(format!( - "Invalid Kafka response size ({rlen} bytes) — is this a Kafka broker port?" - )); - } - let mut resp = vec![0u8; rlen as usize]; - tokio::time::timeout(READ_TIMEOUT, stream.read_exact(&mut resp)) - .await - .map_err(|_| "Kafka read timed out (30 s)".to_string())? - .map_err(|e| e.to_string())?; - // skip correlation_id (4 bytes) - Ok(resp[4..].to_vec()) -} - -struct Dec<'a> { - data: &'a [u8], - pos: usize, -} - -impl<'a> Dec<'a> { - fn new(data: &'a [u8]) -> Self { - Self { data, pos: 0 } - } - - fn i16(&mut self) -> Result { - self.ensure(2)?; - let v = i16::from_be_bytes(self.data[self.pos..self.pos + 2].try_into().unwrap()); - self.pos += 2; - Ok(v) - } - - fn i32(&mut self) -> Result { - self.ensure(4)?; - let v = i32::from_be_bytes(self.data[self.pos..self.pos + 4].try_into().unwrap()); - self.pos += 4; - Ok(v) - } - - fn i64(&mut self) -> Result { - self.ensure(8)?; - let v = i64::from_be_bytes(self.data[self.pos..self.pos + 8].try_into().unwrap()); - self.pos += 8; - Ok(v) - } - - fn str(&mut self) -> Result { - let len = self.i16()?; - if len < 0 { - return Ok(String::new()); - } - let len = len as usize; - self.ensure(len)?; - let s = String::from_utf8_lossy(&self.data[self.pos..self.pos + len]).to_string(); - self.pos += len; - Ok(s) - } - - fn bytes(&mut self) -> Result, String> { - let len = self.i32()?; - if len < 0 { - return Ok(vec![]); - } - let len = len as usize; - self.ensure(len)?; - let b = self.data[self.pos..self.pos + len].to_vec(); - self.pos += len; - Ok(b) - } - - fn array Result>(&mut self, mut f: F) -> Result, String> { - let count = self.i32()?; - if count < 0 { - return Ok(vec![]); - } - // Reject impossible counts before iterating. Every array element occupies - // at least one byte on the wire, so a count larger than the bytes left in - // the buffer is malformed — bail instead of spinning the loop (and - // pre-sizing a huge Vec) on a bogus/hostile length. - let count = count as usize; - if count > self.data.len() - self.pos { - return Err(format!( - "Decode array count ({}) exceeds remaining buffer ({} bytes)", - count, - self.data.len() - self.pos - )); - } - let mut out = Vec::with_capacity(count); - for _ in 0..count { - out.push(f(self)?); - } - Ok(out) - } - - fn nullable_str(&mut self) -> Result, String> { - let len = self.i16()?; - if len < 0 { return Ok(None); } - let len = len as usize; - self.ensure(len)?; - let s = String::from_utf8_lossy(&self.data[self.pos..self.pos + len]).to_string(); - self.pos += len; - Ok(Some(s)) - } - - fn bool_val(&mut self) -> Result { - self.ensure(1)?; - let v = self.data[self.pos] != 0; - self.pos += 1; - Ok(v) - } - - fn skip(&mut self, n: usize) -> Result<(), String> { - self.ensure(n)?; - self.pos += n; - Ok(()) - } - - fn ensure(&self, n: usize) -> Result<(), String> { - if self.pos + n > self.data.len() { - Err(format!("Decode buffer too short (need {} at pos {})", n, self.pos)) - } else { - Ok(()) - } - } -} - -fn enc_str(buf: &mut Vec, s: &str) { - buf.extend_from_slice(&(s.len() as i16).to_be_bytes()); - buf.extend_from_slice(s.as_bytes()); -} - -fn enc_i32(buf: &mut Vec, v: i32) { - buf.extend_from_slice(&v.to_be_bytes()); -} - -fn enc_i64(buf: &mut Vec, v: i64) { - buf.extend_from_slice(&v.to_be_bytes()); -} - -// MetadataRequest v0 (API 3) — returns topics with partition/replica info -async fn wire_metadata( - stream: &mut TcpStream, - topics: &[&str], -) -> Result, String> { - let mut body = Vec::new(); - enc_i32(&mut body, topics.len() as i32); - for t in topics { - enc_str(&mut body, t); - } - let resp = send_request(stream, 3, 0, 1, &body).await?; - let mut d = Dec::new(&resp); - // skip brokers array - let broker_count = d.i32()?; - for _ in 0..broker_count { - d.i32()?; // node_id - d.str()?; // host - d.i32()?; // port - } - // topic metadata - d.array(|d| { - let _err = d.i16()?; - let name = d.str()?; - let partitions = d.array(|d| { - d.i16()?; // partition error - let _pid = d.i32()?; - let _leader = d.i32()?; - let replicas = d.array(|d| d.i32())?; - d.array(|d| d.i32())?; // isr - Ok(replicas.len() as i32) - })?; - let partition_count = partitions.len() as i32; - let replication_factor = partitions.first().copied().unwrap_or(0); - Ok(TopicSummary { name, partition_count, replication_factor }) - }) -} - -// ListOffsetsRequest v0 (API 2) — earliest (-2) or latest (-1) -async fn wire_list_offsets( - stream: &mut TcpStream, - topic: &str, - partition_ids: &[i32], - timestamp: i64, // -1 = latest, -2 = earliest -) -> Result, String> { - let mut body = Vec::new(); - enc_i32(&mut body, -1); // replica_id - enc_i32(&mut body, 1); // topic count - enc_str(&mut body, topic); - enc_i32(&mut body, partition_ids.len() as i32); - for &pid in partition_ids { - enc_i32(&mut body, pid); - enc_i64(&mut body, timestamp); - enc_i32(&mut body, 1); // max_num_offsets (v0 only) - } - let resp = send_request(stream, 2, 0, 2, &body).await?; - let mut d = Dec::new(&resp); - let mut result = Vec::new(); - d.array(|d| { - let _topic = d.str()?; - d.array(|d| { - let pid = d.i32()?; - let _err = d.i16()?; - let offsets = d.array(|d| d.i64())?; - let offset = offsets.into_iter().next().unwrap_or(-1); - result.push((pid, offset)); - Ok(()) - }) - })?; - Ok(result) -} - -// ListGroupsRequest v0 (API 16) -async fn wire_list_groups(stream: &mut TcpStream) -> Result, String> { - let resp = send_request(stream, 16, 0, 3, &[]).await?; - let mut d = Dec::new(&resp); - let _err = d.i16()?; - d.array(|d| { - let group_id = d.str()?; - let protocol_type = d.str()?; - Ok(GroupSummary { group_id, state: String::new(), protocol_type }) - }) -} - -// A group member plus the topic/partitions it currently owns, decoded from -// the member_assignment blob in the DescribeGroups response. -struct MemberWithAssignment { - member: GroupMember, - owned: Vec<(String, i32)>, -} - -// Decode the consumer-protocol member_assignment blob: -// Version: int16 -// Assignment: [ Topic: string, Partitions: [int32] ] -// UserData: bytes -// Returns the (topic, partition) pairs owned by the member. Tolerant of -// non-consumer protocols / malformed data — returns what it could parse. -fn parse_member_assignment(bytes: &[u8]) -> Vec<(String, i32)> { - if bytes.is_empty() { - return Vec::new(); - } - let mut d = Dec::new(bytes); - if d.i16().is_err() { - return Vec::new(); - } - let mut owned = Vec::new(); - let topics = d.array(|d| { - let topic = d.str()?; - let parts = d.array(|d| d.i32())?; - Ok((topic, parts)) - }); - if let Ok(topics) = topics { - for (topic, parts) in topics { - for p in parts { - owned.push((topic.clone(), p)); - } - } - } - owned -} - -// DescribeGroupsRequest v0 (API 15) -async fn wire_describe_groups( - stream: &mut TcpStream, - group_ids: &[&str], -) -> Result)>, String> { - // returns (group_id, state, members) - let mut body = Vec::new(); - enc_i32(&mut body, group_ids.len() as i32); - for gid in group_ids { - enc_str(&mut body, gid); - } - let resp = send_request(stream, 15, 0, 4, &body).await?; - let mut d = Dec::new(&resp); - d.array(|d| { - let _err = d.i16()?; - let group_id = d.str()?; - let state = d.str()?; - let _protocol_type = d.str()?; - let _protocol = d.str()?; - let members = d.array(|d| { - let member_id = d.str()?; - let client_id = d.str()?; - let client_host = d.str()?; - d.bytes()?; // member_metadata - let assignment = d.bytes()?; // member_assignment - let owned = parse_member_assignment(&assignment); - Ok(MemberWithAssignment { - member: GroupMember { member_id, client_id, client_host }, - owned, - }) - })?; - Ok((group_id, state, members)) - }) -} - -// OffsetFetchRequest v2 (API 9) — committed offsets for a group. -// -// `topics = Some(&[...])` fetches the listed topic/partitions; `topics = None` -// uses v2's null-topics form to return *all* committed offsets for the group in -// a single request — no need to enumerate cluster topics first. v2 adds a -// trailing top-level error_code (consumed and ignored below). -async fn wire_offset_fetch( - stream: &mut TcpStream, - group_id: &str, - topics: Option<&[(&str, Vec)]>, -) -> Result, String> { - // returns (topic, partition, committed_offset) - let mut body = Vec::new(); - enc_str(&mut body, group_id); - match topics { - None => enc_i32(&mut body, -1), // null array = all topics for the group - Some(topics) => { - enc_i32(&mut body, topics.len() as i32); - for (topic, parts) in topics { - enc_str(&mut body, topic); - enc_i32(&mut body, parts.len() as i32); - for &p in parts { - enc_i32(&mut body, p); - } - } - } - } - let resp = send_request(stream, 9, 2, 5, &body).await?; - let mut d = Dec::new(&resp); - let mut result = Vec::new(); - d.array(|d| { - let topic = d.str()?; - d.array(|d| { - let partition = d.i32()?; - let offset = d.i64()?; - d.str()?; // metadata - let _err = d.i16()?; - if offset >= 0 { - result.push((topic.clone(), partition, offset)); - } - Ok(()) - }) - })?; - let _top_level_err = d.i16(); // v2 trailing error_code; ignore (may be absent on error) - Ok(result) -} - -// DescribeConfigs v0 (API 32) — topic config entries (retention.ms, cleanup.policy, etc.) -async fn wire_describe_configs( - stream: &mut TcpStream, - topic: &str, -) -> Result, String> { - let mut body = Vec::new(); - enc_i32(&mut body, 1); // 1 resource - body.push(2u8); // resource_type = TOPIC - enc_str(&mut body, topic); - enc_i32(&mut body, 0); // config_names: empty array = all configs - - let resp = send_request(stream, 32, 0, 6, &body).await?; - let mut d = Dec::new(&resp); - - d.i32()?; // throttle_time_ms - - let mut result = Vec::new(); - d.array(|d| { - let _err = d.i16()?; - let _msg = d.nullable_str()?; - d.skip(1)?; // resource_type (i8) - let _rname = d.str()?; - d.array(|d| { - let name = d.str()?; - let value = d.nullable_str()?; - d.bool_val()?; // read_only - d.bool_val()?; // is_default - d.bool_val()?; // is_sensitive - result.push(TopicConfig { name, value }); - Ok(()) - })?; - Ok(()) - })?; - - result.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(result) -} - -// ── Tauri Commands ──────────────────────────────────────────────────────────── - -#[tauri::command] -pub async fn kafka_list_configs(app: AppHandle) -> Result, String> { - Ok(load_configs(&app)) -} - -#[tauri::command] -pub async fn kafka_save_config(app: AppHandle, mut config: BrokerConfig) -> Result { - if config.id.is_empty() { - config.id = Uuid::new_v4().to_string(); - } - let mut configs = load_configs(&app); - if let Some(pos) = configs.iter().position(|c| c.id == config.id) { - configs[pos] = config.clone(); - } else { - configs.push(config.clone()); - } - save_configs(&app, &configs)?; - Ok(config) -} - -#[tauri::command] -pub async fn kafka_delete_config(app: AppHandle, config_id: String) -> Result<(), String> { - let mut configs = load_configs(&app); - configs.retain(|c| c.id != config_id); - save_configs(&app, &configs) -} - -#[tauri::command] -pub async fn kafka_test_connection(app: AppHandle, config_id: String) -> Result<(), String> { - let config = find_config(&app, &config_id)?; - // open_kafka_stream already probes with a MetadataRequest, so success == connected - open_kafka_stream(&config).await?; - Ok(()) -} - -#[tauri::command] -pub async fn kafka_list_topics(app: AppHandle, config_id: String) -> Result, String> { - let config = find_config(&app, &config_id)?; - let mut stream = open_kafka_stream(&config).await?; - let mut topics = wire_metadata(&mut stream, &[]).await?; - topics.sort_by(|a, b| a.name.cmp(&b.name)); - Ok(topics) -} - -#[tauri::command] -pub async fn kafka_topic_details( - app: AppHandle, - config_id: String, - topic: String, -) -> Result { - let config = find_config(&app, &config_id)?; - let mut stream = open_kafka_stream(&config).await?; - - // Get partition metadata - let meta = wire_metadata(&mut stream, &[topic.as_str()]).await?; - let summary = meta.into_iter().find(|t| t.name == topic) - .ok_or_else(|| format!("Topic '{}' not found", topic))?; - - let partition_ids: Vec = (0..summary.partition_count).collect(); - - // Get earliest offsets - let earliest = wire_list_offsets(&mut stream, &topic, &partition_ids, -2).await - .unwrap_or_default(); - // Get latest offsets - let latest = wire_list_offsets(&mut stream, &topic, &partition_ids, -1).await - .unwrap_or_default(); - - let earliest_map: std::collections::HashMap = earliest.into_iter().collect(); - let latest_map: std::collections::HashMap = latest.into_iter().collect(); - - let partitions: Vec = partition_ids.iter().map(|&id| PartitionInfo { - id, - leader: 0, // metadata doesn't surface leader separately here - earliest_offset: earliest_map.get(&id).copied().unwrap_or(-1), - latest_offset: latest_map.get(&id).copied().unwrap_or(-1), - }).collect(); - - // Note: consumer groups are NOT gathered here — opening a topic must stay - // cheap. The Consumers tab fetches them on demand via - // `kafka_topic_consumer_groups`. - Ok(TopicDetails { - name: topic, - partitions, - replication_factor: summary.replication_factor, - }) -} - -/// Consumer groups committed to a topic — used only when the user opens the -/// Consumers tab, never on topic open. Lists every group once, then asks each -/// for its committed offsets on this topic's partitions over a single -/// connection. `wire_offset_fetch` only returns partitions with a real (>= 0) -/// committed offset, so groups that never consumed this topic contribute -/// nothing. -#[tauri::command] -pub async fn kafka_topic_consumer_groups( - app: AppHandle, - config_id: String, - topic: String, -) -> Result, String> { - let config = find_config(&app, &config_id)?; - let mut stream = open_kafka_stream(&config).await?; - - let meta = wire_metadata(&mut stream, &[topic.as_str()]).await?; - let summary = meta.into_iter().find(|t| t.name == topic) - .ok_or_else(|| format!("Topic '{}' not found", topic))?; - let partition_ids: Vec = (0..summary.partition_count).collect(); - - let latest: std::collections::HashMap = - wire_list_offsets(&mut stream, &topic, &partition_ids, -1).await - .unwrap_or_default() - .into_iter() - .collect(); - - let groups = wire_list_groups(&mut stream).await?; - // Safety cap: this issues one OffsetFetch per group (O(#groups) round-trips). - // On clusters with thousands of groups, bound the scan so opening this tab - // can't hammer the broker. - const MAX_GROUPS_SCANNED: usize = 500; - let topics_arg = [(topic.as_str(), partition_ids)]; - let mut result: Vec = Vec::new(); - for g in groups.iter().take(MAX_GROUPS_SCANNED) { - match wire_offset_fetch(&mut stream, &g.group_id, Some(&topics_arg)).await { - Ok(offsets) => { - for (_t, partition, committed_offset) in offsets { - let latest_off = latest.get(&partition).copied().unwrap_or(-1); - let lag = if latest_off >= 0 && committed_offset >= 0 { - latest_off - committed_offset - } else { - -1 - }; - result.push(GroupLag { group_id: g.group_id.clone(), partition, committed_offset, lag }); - } - } - // A read error leaves the TCP stream in an unknown state. Stop here - // rather than continuing: subsequent reads on a corrupted stream - // would each block for the full 30 s timeout before failing. - Err(_) => break, - } - } - result.sort_by(|a, b| a.group_id.cmp(&b.group_id).then(a.partition.cmp(&b.partition))); - Ok(result) -} - -#[tauri::command] -pub async fn kafka_topic_configs( - app: AppHandle, - config_id: String, - topic: String, -) -> Result, String> { - let config = find_config(&app, &config_id)?; - let mut stream = open_kafka_stream(&config).await?; - wire_describe_configs(&mut stream, &topic).await -} - -#[tauri::command] -pub async fn kafka_create_topic( - app: AppHandle, - config_id: String, - name: String, - num_partitions: i32, - replication_factor: i16, -) -> Result<(), String> { - // Validate client-side so an obviously-invalid request (0/negative, or an - // absurd partition count) never reaches the broker. - validate_create_topic(&name, num_partitions, replication_factor)?; - let config = find_config(&app, &config_id)?; - let client = make_client(&config).await?; - let controller = client.controller_client().map_err(|e| e.to_string())?; - controller - .create_topic(name, num_partitions, replication_factor, 5_000) - .await - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn kafka_delete_topic( - app: AppHandle, - config_id: String, - name: String, -) -> Result<(), String> { - let config = find_config(&app, &config_id)?; - let client = make_client(&config).await?; - let controller = client.controller_client().map_err(|e| e.to_string())?; - controller - .delete_topic(name, 5_000) - .await - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn kafka_list_groups( - app: AppHandle, - config_id: String, -) -> Result, String> { - let config = find_config(&app, &config_id)?; - let mut stream = open_kafka_stream(&config).await?; - let mut groups = wire_list_groups(&mut stream).await?; - - if !groups.is_empty() { - let gids: Vec<&str> = groups.iter().map(|g| g.group_id.as_str()).collect(); - if let Ok(details) = wire_describe_groups(&mut stream, &gids).await { - let state_map: std::collections::HashMap = - details.into_iter().map(|(id, state, _members)| (id, state)).collect(); - for g in &mut groups { - if let Some(state) = state_map.get(&g.group_id) { - g.state = state.clone(); - } - } - } - } - - groups.sort_by(|a, b| a.group_id.cmp(&b.group_id)); - Ok(groups) -} - -#[tauri::command] -pub async fn kafka_group_details( - app: AppHandle, - config_id: String, - group_id: String, -) -> Result { - let config = find_config(&app, &config_id)?; - let mut stream = open_kafka_stream(&config).await?; - - // Describe group for state + members - let desc = wire_describe_groups(&mut stream, &[group_id.as_str()]).await?; - let (state, members_raw) = desc - .into_iter() - .find(|(id, _, _)| id == &group_id) - .map(|(_, s, m)| (s, m)) - .unwrap_or_else(|| (String::from("Unknown"), Vec::new())); - let member_count = members_raw.len() as i32; - - // Map each owned (topic, partition) -> its assigned member, so we can show - // which consumer is reading each partition. - let owner_map: std::collections::HashMap<(String, i32), GroupMember> = members_raw - .iter() - .flat_map(|mw| { - let member = mw.member.clone(); - mw.owned - .iter() - .map(move |(t, p)| ((t.clone(), *p), member.clone())) - }) - .collect(); - let members: Vec = members_raw.into_iter().map(|mw| mw.member).collect(); - - // OffsetFetch v2 with null topics returns every committed offset for the - // group in one request — no need to fetch all-cluster metadata and ask for - // every topic/partition first. - let committed = wire_offset_fetch(&mut stream, &group_id, None).await - .unwrap_or_default(); - - // Compute lag. Batch ListOffsets per topic (one request for all of a - // topic's partitions) instead of one request per partition — avoids the - // old N+1 (50 partitions = 50 round-trips). - let mut by_topic: std::collections::HashMap> = std::collections::HashMap::new(); - for (topic, partition, committed_offset) in committed { - by_topic.entry(topic).or_default().push((partition, committed_offset)); - } - - let mut assignments = Vec::new(); - for (topic, parts) in by_topic { - let pids: Vec = parts.iter().map(|(p, _)| *p).collect(); - let latest_map: std::collections::HashMap = - wire_list_offsets(&mut stream, &topic, &pids, -1).await - .unwrap_or_default() - .into_iter() - .collect(); - for (partition, committed_offset) in parts { - let latest = latest_map.get(&partition).copied().unwrap_or(-1); - let lag = if latest >= 0 && committed_offset >= 0 { - latest - committed_offset - } else { - -1 - }; - let owner = owner_map.get(&(topic.clone(), partition)); - assignments.push(Assignment { - topic: topic.clone(), - partition, - committed_offset, - lag, - client_id: owner.map(|m| m.client_id.clone()), - client_host: owner.map(|m| m.client_host.clone()), - member_id: owner.map(|m| m.member_id.clone()), - }); - } - } - - assignments.sort_by(|a, b| a.topic.cmp(&b.topic).then(a.partition.cmp(&b.partition))); - - Ok(GroupDetails { group_id, state, member_count, members, assignments }) -} - -#[tauri::command] -pub async fn kafka_produce( - app: AppHandle, - config_id: String, - topic: String, - partition: Option, - key: Option, - value: String, - headers: Option>, -) -> Result { - let config = find_config(&app, &config_id)?; - let client = make_client(&config).await?; - let part = partition.unwrap_or(0); - let pc = client - .partition_client(topic.as_str(), part, UnknownTopicHandling::Retry) - .await - .map_err(|e| e.to_string())?; - - let record_headers: std::collections::BTreeMap> = headers - .unwrap_or_default() - .into_iter() - .map(|(k, v)| (k, v.into_bytes())) - .collect(); - - let record = Record { - key: key.map(|k| k.into_bytes()), - value: Some(value.into_bytes()), - headers: record_headers, - timestamp: Utc::now(), - }; - let offsets = pc - .produce(vec![record], Compression::NoCompression) - .await - .map_err(|e| e.to_string())?; - - let offset = offsets.into_iter().next().unwrap_or(-1); - Ok(ProduceResult { partition: part, offset }) -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct BatchRecord { - pub key: Option, - pub value: String, - #[serde(default)] - pub headers: std::collections::HashMap, -} - -/// Produce many records to one partition in a single request (e.g. from a -/// pasted JSON array). Returns the assigned offsets in order. -#[tauri::command] -pub async fn kafka_produce_batch( - app: AppHandle, - config_id: String, - topic: String, - partition: Option, - records: Vec, -) -> Result, String> { - if records.is_empty() { - return Err("No records to produce".into()); - } - // Bound a single batch so a runaway paste can't try to ship an unreasonable - // request to the broker in one go. - const MAX_BATCH_RECORDS: usize = 10_000; - if records.len() > MAX_BATCH_RECORDS { - return Err(format!( - "Batch too large: {} records (max {MAX_BATCH_RECORDS} per send)", - records.len() - )); - } - let config = find_config(&app, &config_id)?; - let client = make_client(&config).await?; - let part = partition.unwrap_or(0); - let pc = client - .partition_client(topic.as_str(), part, UnknownTopicHandling::Retry) - .await - .map_err(|e| e.to_string())?; - - let recs: Vec = records - .into_iter() - .map(|r| Record { - key: r.key.map(|k| k.into_bytes()), - value: Some(r.value.into_bytes()), - headers: r.headers.into_iter().map(|(k, v)| (k, v.into_bytes())).collect(), - timestamp: Utc::now(), - }) - .collect(); - - pc.produce(recs, Compression::NoCompression) - .await - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn kafka_fetch_messages( - app: AppHandle, - config_id: String, - topic: String, - partition: i32, - offset: i64, // -1 = tail (latest - limit), >= 0 = exact offset - limit: i32, - // When set, fetch from the first offset whose timestamp is >= this epoch-ms - // (resolved via ListOffsets). Takes precedence over `offset`. - start_timestamp: Option, -) -> Result, String> { - // Server-side backstop on the requested count. The UI clamps to - // config.kafka.maxFetchMessages, but an out-of-range value must never reach - // `.take(limit)` / `(high - limit)` and drive a huge allocation. - const MAX_FETCH_MESSAGES: i32 = 100_000; - let limit = limit.clamp(1, MAX_FETCH_MESSAGES); - - let config = find_config(&app, &config_id)?; - - // Time-based start: resolve the offset at/after the given timestamp first. - let resolved_from_ts = if let Some(ts_ms) = start_timestamp { - let mut stream = open_kafka_stream(&config).await?; - let offs = wire_list_offsets(&mut stream, &topic, &[partition], ts_ms) - .await - .unwrap_or_default(); - match offs.into_iter().next().map(|(_, o)| o) { - Some(o) if o >= 0 => Some(o), - // No message at/after that time — nothing to show. - _ => return Ok(Vec::new()), - } - } else { - None - }; - - let client = make_client(&config).await?; - let pc = client - .partition_client(topic.as_str(), partition, UnknownTopicHandling::Retry) - .await - .map_err(|e| e.to_string())?; - - // Resolve start offset: timestamp > exact offset > tail (latest - limit). - let start_offset = if let Some(o) = resolved_from_ts { - o - } else if offset < 0 { - let (_, high) = pc.get_offset(rskafka::client::partition::OffsetAt::Latest) - .await - .map(|o| (0i64, o)) - .unwrap_or((0, 0)); - (high - limit as i64).max(0) - } else { - offset - }; - - let bytes_range = 1..10 * 1024 * 1024; // up to 10 MB - let (records, _) = pc - .fetch_records(start_offset, bytes_range, 1000) - .await - .map_err(|e| e.to_string())?; - - let messages: Vec = records - .into_iter() - .take(limit as usize) - .map(|ro| { - let key = ro.record.key.map(|b| { - String::from_utf8(b.clone()).unwrap_or_else(|_| format!("", b.len())) - }); - let value = ro.record.value.map(|b| { - String::from_utf8(b.clone()).unwrap_or_else(|_| format!("", b.len())) - }); - let headers = ro.record.headers - .into_iter() - .map(|(k, v)| (k, String::from_utf8(v.clone()).unwrap_or_else(|_| format!("", v.len())))) - .collect(); - KafkaMessage { - offset: ro.offset, - partition, - timestamp: ro.record.timestamp.to_rfc3339(), - key, - value, - headers, - } - }) - .collect(); - - Ok(messages) -} - -// ── Realtime consumer (anonymous; no consumer group, no offset commits) ─────── -// Long-polls every partition of a topic from a chosen start position and streams -// each record to the frontend over a Channel. "Anonymous" = it doesn't join a -// consumer group or commit offsets, so it never disturbs real consumers' lag. - -/// Start a realtime consumer over all partitions of `topic`. -/// `from`: "latest" (only new messages, default) or "earliest" (from the start). -/// Returns the consumer id; stop it with `kafka_consume_stop`. -#[tauri::command] -pub async fn kafka_consume_start( - app: AppHandle, - registry: tauri::State<'_, KafkaConsumerRegistry>, - config_id: String, - topic: String, - from: String, - on_message: Channel, -) -> Result { - let config = find_config(&app, &config_id)?; - - // Resolve the partition count from metadata (errors if the topic is missing). - let mut stream = open_kafka_stream(&config).await?; - let meta = wire_metadata(&mut stream, &[topic.as_str()]).await?; - let summary = meta - .into_iter() - .find(|t| t.name == topic) - .ok_or_else(|| format!("Topic '{}' not found", topic))?; - drop(stream); - let partition_count = summary.partition_count; - if partition_count <= 0 { - return Err(format!("Topic '{}' has no partitions", topic)); - } - - let client = make_client(&config).await?; - let from_latest = from != "earliest"; - - let id = Uuid::new_v4().to_string(); - let (stop_tx, stop_rx) = watch::channel(false); - registry.inner.lock().unwrap().insert(id.clone(), stop_tx); - - let reg = registry.inner.clone(); - let id_task = id.clone(); - - tokio::spawn(async move { - // Keep the client alive for the consumer's lifetime; per-partition clients - // borrow its connection pool. - let client = client; - let mut handles = Vec::new(); - - for p in 0..partition_count { - let pc = match client - .partition_client(topic.as_str(), p, UnknownTopicHandling::Retry) - .await - { - Ok(pc) => pc, - Err(_) => continue, - }; - let start = if from_latest { - pc.get_offset(OffsetAt::Latest).await.unwrap_or(0) - } else { - pc.get_offset(OffsetAt::Earliest).await.unwrap_or(0) - }; - - let mut rx = stop_rx.clone(); - let chan = on_message.clone(); - handles.push(tokio::spawn(async move { - let mut offset = start.max(0); - loop { - tokio::select! { - _ = rx.changed() => break, - res = pc.fetch_records(offset, 1..(8 * 1024 * 1024), 1000) => match res { - Ok((records, _high)) => { - for ro in records { - offset = ro.offset + 1; - let (value, value_b64) = match ro.record.value { - Some(b) => ( - String::from_utf8(b.clone()).ok(), - Some(base64::engine::general_purpose::STANDARD.encode(&b)), - ), - None => (None, None), - }; - let key = ro.record.key.map(|b| { - String::from_utf8(b.clone()) - .unwrap_or_else(|_| format!("", b.len())) - }); - let headers = ro - .record - .headers - .into_iter() - .map(|(k, v)| { - ( - k, - String::from_utf8(v.clone()) - .unwrap_or_else(|_| format!("", v.len())), - ) - }) - .collect(); - let msg = KafkaConsumedMessage { - partition: p, - offset: ro.offset, - timestamp: ro.record.timestamp.to_rfc3339(), - key, - value, - value_b64, - headers, - }; - if chan.send(msg).is_err() { - return; // frontend dropped the channel - } - } - } - Err(_) => { - // Brief backoff so a transient fetch error doesn't hot-loop. - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - } - } - } - } - })); - } - - // Wait for the stop signal, then tear down the partition tasks. - let mut rx = stop_rx; - let _ = rx.changed().await; - for h in handles { - h.abort(); - } - reg.lock().unwrap().remove(&id_task); - }); - - Ok(id) -} - -#[tauri::command] -pub async fn kafka_consume_stop( - registry: tauri::State<'_, KafkaConsumerRegistry>, - consumer_id: String, -) -> Result<(), String> { - if let Some(tx) = registry.inner.lock().unwrap().remove(&consumer_id) { - let _ = tx.send(true); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── parse_brokers ────────────────────────────────────────────────────── - #[test] - fn parse_brokers_handles_whitespace_and_empties() { - assert_eq!(parse_brokers("localhost:9092"), vec!["localhost:9092"]); - assert_eq!(parse_brokers("a:1, b:2 , c:3"), vec!["a:1", "b:2", "c:3"]); - assert_eq!(parse_brokers(" , , a:1 ,"), vec!["a:1"]); - assert!(parse_brokers("").is_empty()); - assert!(parse_brokers(" ").is_empty()); - assert!(parse_brokers(",,,").is_empty()); - } - - // ── validate_create_topic ────────────────────────────────────────────── - #[test] - fn validate_create_topic_accepts_sane_input() { - assert!(validate_create_topic("orders", 3, 1).is_ok()); - assert!(validate_create_topic("t", 1, 1).is_ok()); - assert!(validate_create_topic("t", 10_000, 3).is_ok()); - } - - #[test] - fn validate_create_topic_rejects_bad_input() { - assert!(validate_create_topic("", 1, 1).is_err()); - assert!(validate_create_topic(" ", 1, 1).is_err()); - assert!(validate_create_topic("t", 0, 1).is_err()); - assert!(validate_create_topic("t", -1, 1).is_err()); - assert!(validate_create_topic("t", 10_001, 1).is_err()); - assert!(validate_create_topic("t", 3, 0).is_err()); - assert!(validate_create_topic("t", 3, -1).is_err()); - } - - // ── encode/decode round-trips ────────────────────────────────────────── - #[test] - fn enc_str_round_trips_through_dec() { - let mut buf = Vec::new(); - enc_str(&mut buf, "demo-events"); - let mut d = Dec::new(&buf); - assert_eq!(d.str().unwrap(), "demo-events"); - } - - #[test] - fn enc_i32_i64_round_trip() { - let mut buf = Vec::new(); - enc_i32(&mut buf, -123); - enc_i64(&mut buf, 9_000_000_000); - let mut d = Dec::new(&buf); - assert_eq!(d.i32().unwrap(), -123); - assert_eq!(d.i64().unwrap(), 9_000_000_000); - } - - // ── Dec primitives ───────────────────────────────────────────────────── - #[test] - fn dec_reads_fixed_width_big_endian() { - let mut d = Dec::new(&[0x12, 0x34, 0x00, 0x00, 0x00, 0x2A]); - assert_eq!(d.i16().unwrap(), 0x1234); - assert_eq!(d.i32().unwrap(), 42); - } - - #[test] - fn dec_ensure_guards_short_buffers() { - assert!(Dec::new(&[0x00]).i32().is_err()); - assert!(Dec::new(&[]).i16().is_err()); - // bytes() with a length longer than the remaining buffer must error. - assert!(Dec::new(&[0, 0, 0, 5, b'a']).bytes().is_err()); - } - - #[test] - fn dec_string_and_bytes() { - // string: i16 len=3 then "abc" - let mut d = Dec::new(&[0, 3, b'a', b'b', b'c']); - assert_eq!(d.str().unwrap(), "abc"); - // bytes: i32 len=2 then 0xDE 0xAD - let mut d = Dec::new(&[0, 0, 0, 2, 0xDE, 0xAD]); - assert_eq!(d.bytes().unwrap(), vec![0xDE, 0xAD]); - } - - #[test] - fn dec_negative_lengths_are_null_like() { - // i16 = -1 → empty string - assert_eq!(Dec::new(&[0xFF, 0xFF]).str().unwrap(), ""); - // i16 = -1 → None for nullable_str - assert_eq!(Dec::new(&[0xFF, 0xFF]).nullable_str().unwrap(), None); - // i32 = -1 → empty Vec for bytes - assert_eq!(Dec::new(&[0xFF, 0xFF, 0xFF, 0xFF]).bytes().unwrap(), Vec::::new()); - } - - #[test] - fn dec_array_reads_each_element() { - // count=2 then two i32 values 10, 20 - let bytes = [0, 0, 0, 2, 0, 0, 0, 10, 0, 0, 0, 20]; - let mut d = Dec::new(&bytes); - let out = d.array(|d| d.i32()).unwrap(); - assert_eq!(out, vec![10, 20]); - } - - #[test] - fn dec_array_negative_count_is_empty() { - let mut d = Dec::new(&[0xFF, 0xFF, 0xFF, 0xFF]); - let out: Vec = d.array(|d| d.i32()).unwrap(); - assert!(out.is_empty()); - } - - #[test] - fn dec_array_count_exceeding_buffer_is_rejected() { - // count = 1000 but no element bytes follow → must error early, not spin. - let mut d = Dec::new(&[0, 0, 0x03, 0xE8]); - let res = d.array(|d| d.i32()); - assert!(res.is_err()); - assert!(res.unwrap_err().contains("exceeds remaining buffer")); - } - - // ── parse_member_assignment ──────────────────────────────────────────── - fn build_assignment(topics: &[(&str, &[i32])]) -> Vec { - let mut b = Vec::new(); - b.extend_from_slice(&0i16.to_be_bytes()); // version - b.extend_from_slice(&(topics.len() as i32).to_be_bytes()); - for (topic, parts) in topics { - enc_str(&mut b, topic); - b.extend_from_slice(&(parts.len() as i32).to_be_bytes()); - for &p in *parts { - b.extend_from_slice(&p.to_be_bytes()); - } - } - b.extend_from_slice(&0i32.to_be_bytes()); // userdata: empty bytes - b - } - - #[test] - fn parse_member_assignment_decodes_owned_partitions() { - let blob = build_assignment(&[("demo-events", &[0, 2]), ("orders", &[5])]); - let owned = parse_member_assignment(&blob); - assert_eq!( - owned, - vec![ - ("demo-events".to_string(), 0), - ("demo-events".to_string(), 2), - ("orders".to_string(), 5), - ] - ); - } - - #[test] - fn parse_member_assignment_is_tolerant_of_empty_and_garbage() { - assert!(parse_member_assignment(&[]).is_empty()); - // Only a version, no topic array → tolerated as empty, never panics. - assert!(parse_member_assignment(&[0, 0]).is_empty()); - // Truncated topic array length → tolerated. - assert!(parse_member_assignment(&[0, 0, 0, 0, 0, 5]).is_empty()); - } -} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 72998700..ab519cc1 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,7 +1,6 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -mod kafka; mod checksum; mod dropped; mod netinfo; @@ -15,13 +14,33 @@ mod artifact_installer; mod plugin_data; use tauri::Manager; +use tauri_plugin_deep_link::DeepLinkExt; #[cfg(target_os = "macos")] use tauri::menu::{Menu, PredefinedMenuItem, Submenu}; fn main() { tauri::Builder::default() + // Đăng ký TRƯỚC hết mọi `.plugin()` khác — yêu cầu của chính plugin + // này: nếu người dùng bấm một link `desktop-devtool-app://install?...` thứ hai + // trong khi app đã mở, closure dưới đây chạy trên tiến trình ĐANG + // CHẠY còn tiến trình mới tự thoát ngay, thay vì mở thêm một cửa sổ + // trùng. Callback rỗng là đủ: feature "deep-link" (Cargo.toml) đã tự + // gọi `deep_link.handle_cli_arguments(argv)` trước khi chạy closure, + // và đó là bước phát ra `deep-link://new-url` mà `onOpenUrl` phía JS + // (src/lib/deepLink.ts) đang nghe. + .plugin(tauri_plugin_single_instance::init(|_app, _argv, _cwd| {})) + .plugin(tauri_plugin_deep_link::init()) .setup(|_app| { + // AppImage không có bước cài đặt nào đăng ký URI scheme vào hệ + // thống (khác .deb/.msi/.dmg, nơi trình cài đặt làm việc đó) — tự + // đăng ký lúc khởi động là cách duy nhất `desktop-devtool-app://` hoạt động + // trên bản AppImage. Vô hại khi gọi lại nhiều lần hoặc trên các + // bản Linux không phải AppImage. + #[cfg(target_os = "linux")] + { + let _ = _app.deep_link().register_all(); + } // Local-only control channel the MCP stdio sidecar // (src/bin/devtool-mcp-server.rs) talks to, so an MCP client // (Claude Desktop/Code) can drive the API Client tool through @@ -78,7 +97,6 @@ fn main() { } }) .manage(mockserver::MockState::default()) - .manage(kafka::KafkaConsumerRegistry::default()) // Host cho plugin dịch vụ (tier B) — xem service_host.rs. .manage(service_host::ServiceRegistry::default()) .manage(secrets_vault::VaultState::default()) @@ -115,6 +133,8 @@ fn main() { files::read_file_data_url, mcp_bridge::mcp_respond, mcp_bridge::mcp_sidecar_path, + mcp_bridge::mcp_register_tools, + mcp_bridge::mcp_unregister_tools, service_host::service_call, service_host::service_stream_start, service_host::service_stream_stop, @@ -132,23 +152,6 @@ fn main() { artifact_installer::artifact_installer_list, artifact_installer::artifact_installer_uninstall, artifact_installer::artifact_installer_read_bundle, - kafka::kafka_list_configs, - kafka::kafka_save_config, - kafka::kafka_delete_config, - kafka::kafka_test_connection, - kafka::kafka_list_topics, - kafka::kafka_topic_details, - kafka::kafka_topic_consumer_groups, - kafka::kafka_create_topic, - kafka::kafka_list_groups, - kafka::kafka_group_details, - kafka::kafka_produce, - kafka::kafka_produce_batch, - kafka::kafka_fetch_messages, - kafka::kafka_delete_topic, - kafka::kafka_topic_configs, - kafka::kafka_consume_start, - kafka::kafka_consume_stop, mockserver::mock_start, mockserver::mock_stop, mockserver::mock_status, diff --git a/src-tauri/src/mcp_bridge.rs b/src-tauri/src/mcp_bridge.rs index de3f9ba9..8ed8d8a4 100644 --- a/src-tauri/src/mcp_bridge.rs +++ b/src-tauri/src/mcp_bridge.rs @@ -1,28 +1,43 @@ -// MCP (Model Context Protocol) bridge — local control channel. +// MCP (Model Context Protocol) bridge — local control channel + the tool +// REGISTRY that makes DevTool's MCP surface plugin-driven instead of a +// hardcoded list. // // A standalone MCP stdio server (src/bin/devtool-mcp-server.rs, run by // Claude Desktop/Code — NOT by this app) can't reach into the running // webview directly, so this module runs a small loopback-only HTTP server -// the sidecar calls into. Each -// call is handed to the frontend as an `mcp:call` event, and the frontend -// (src/components/tools/apiclient/mcpBridge.ts, wired up from ApiClient.tsx) -// answers over the `mcp_respond` command — so an MCP tool call runs through -// the exact same API Client store and request-sending engine the user's own -// UI uses, and shows up in the UI/History like any other action. +// the sidecar calls into, exposing two things: +// +// - `GET /tools` — the tool catalogue: name/description/inputSchema for +// every tool CURRENTLY registered. Each tool is bundled with and +// registered by its own owner's frontend bridge (e.g. +// src/components/tools/apiclient/mcpBridge.ts calls +// `mcp_register_tools` once on mount with API Client's own tool defs; +// a Tier-B plugin installed from developer-desktop-util-plugin — +// Redis/RabbitMQ/Container Manager, Kafka Explorer — does the exact +// same from ITS OWN mcpBridge.ts). This module has no compiled-in +// knowledge of what tools exist — it only stores whatever's been +// registered, keyed by the registering plugin's id so a second +// registration (re-mount) cleanly replaces the first instead of +// duplicating entries. +// - `POST /call` — a tool CALL is handed to the frontend as an `mcp:call` +// event, answered over the `mcp_respond` command by whichever bridge +// owns that tool name — so an MCP tool call runs through the exact +// same store/engine the user's own UI uses, and shows up in the UI/ +// History like any other action, same as before this registry existed. // // Loopback-only (127.0.0.1, OS-assigned port), gated by a random token // written alongside the port to `/mcp-bridge.json` on -// startup — the sidecar reads that file to find both. This module only -// emits the event and waits (CALL_TIMEOUT below) — it has no idea whether +// startup — the sidecar reads that file to find both. `/call` only emits +// the event and waits (CALL_TIMEOUT below) — it has no idea whether // anything is actually listening for it. By default, a tool call only -// succeeds while the app is running AND the tool that owns it (API Client -// or Mock Server) is the one currently mounted, because that's the only -// time either frontend bridge (apiclient/mcpBridge.ts, -// mockserver/mcpBridge.ts) is registered. Settings → MCP → "Background MCP -// bridge" (McpBackgroundBridge.tsx, off by default) mounts both -// unconditionally instead, so a call succeeds regardless of which tool is -// on screen — any other case (app closed, both the specific tool AND the -// background setting are off) just times out with a clear error. +// succeeds while the app is running AND the tool that owns it is the one +// currently mounted, because that's the only time its frontend bridge is +// registered (and hence able to answer `mcp:call`). Settings → MCP → +// "Background MCP bridge" (McpBackgroundBridge.tsx, off by default) mounts +// every eligible bridge unconditionally instead, so a call succeeds +// regardless of which tool is on screen — any other case (app closed, +// both the specific tool AND the background setting are off) just times +// out with a clear error. use std::collections::HashMap; use std::io::{Error as IoError, ErrorKind}; @@ -40,6 +55,8 @@ use tauri::{AppHandle, Emitter, Manager}; use tokio::sync::oneshot; use uuid::Uuid; +// Trùng giá trị với `service_host.rs`'s `CALL_TIMEOUT` một cách có chủ đích, +// không phải hằng số dùng chung — xem comment ở đó. const CALL_TIMEOUT: Duration = Duration::from_secs(30); #[derive(Clone, Default)] @@ -51,6 +68,31 @@ pub struct McpBridgeState { struct Inner { token: String, pending: HashMap>, + /// Registered MCP tools, keyed by the REGISTERING plugin's id (not by + /// tool name — a plugin registers its whole list in one call, and a + /// re-registration, e.g. its bridge re-mounting, replaces its own + /// entry wholesale rather than appending duplicates). Flattened into a + /// single list by `GET /tools` — see `list_tools_http` below. + tools: HashMap>, +} + +/// One MCP tool's catalogue entry — name/description/JSON-Schema, no +/// handler. The actual call still goes through `POST /call` → `mcp:call` → +/// whichever bridge answers that tool name; this is ONLY what +/// `devtool-mcp-server.rs` needs to advertise it to an MCP client via +/// `list_tools`. Matches the shape that process's `fetch_registered_tools()` +/// expects verbatim (plain fields, no envelope). +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ToolDef { + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default = "empty_schema", rename = "inputSchema")] + pub input_schema: serde_json::Value, +} + +fn empty_schema() -> serde_json::Value { + serde_json::json!({ "type": "object", "properties": {} }) } struct CallOutcome { @@ -78,6 +120,18 @@ struct DiscoveryFile { token: String, } +/// So sánh KHÔNG rò rỉ thời gian qua độ dài chuỗi khớp trước khi lệch — bản +/// `==` thường có thể trả lời sớm ngay byte đầu tiên sai. Rủi ro thực tế ở +/// đây rất thấp (chỉ loopback, token UUID-v4 ngẫu nhiên, không có kẻ tấn công +/// từ xa nào đo được độ trễ), nhưng đây là đúng điểm kiểm tin cậy nên vẫn +/// đáng làm cho chắc thay vì dựa vào "khó khai thác trong thực tế". +fn constant_time_eq(a: &str, b: &str) -> bool { + if a.len() != b.len() { + return false; + } + a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + fn check_auth(state: &McpBridgeState, headers: &HeaderMap) -> bool { let expected = state.inner.lock().unwrap().token.clone(); let got = headers @@ -85,7 +139,7 @@ fn check_auth(state: &McpBridgeState, headers: &HeaderMap) -> bool { .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .unwrap_or(""); - !expected.is_empty() && got == expected + !expected.is_empty() && constant_time_eq(got, &expected) } async fn health() -> impl IntoResponse { @@ -141,6 +195,35 @@ async fn call( } } +async fn list_tools_http(State((_app, state)): State<(AppHandle, McpBridgeState)>, headers: HeaderMap) -> impl IntoResponse { + if !check_auth(&state, &headers) { + return (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ "error": "unauthorized" }))).into_response(); + } + let tools: Vec = state.inner.lock().unwrap().tools.values().flatten().cloned().collect(); + (StatusCode::OK, Json(tools)).into_response() +} + +/// Called once by a tool's own frontend bridge when it mounts (e.g. +/// `apiclient/mcpBridge.ts`, or a Tier-B plugin's own `mcpBridge.ts` — +/// compiled-in or installed from developer-desktop-util-plugin, no +/// difference to this registry). `plugin_id` scopes the entry so a second +/// call from the SAME plugin (its bridge re-mounting) replaces its list +/// wholesale rather than duplicating tool names in `GET /tools`. +#[tauri::command] +pub fn mcp_register_tools(state: tauri::State<'_, McpBridgeState>, plugin_id: String, tools: Vec) { + state.inner.lock().unwrap().tools.insert(plugin_id, tools); +} + +/// Called on unmount by a bridge that only wants to answer calls while its +/// own tool is on screen (the common case — see this module's own doc +/// comment on the "on screen" default). A bridge that stays registered +/// unconditionally (Settings → MCP → Background MCP bridge; the platform's +/// own meta-tools in McpManageBridge.tsx) simply never calls this. +#[tauri::command] +pub fn mcp_unregister_tools(state: tauri::State<'_, McpBridgeState>, plugin_id: String) { + state.inner.lock().unwrap().tools.remove(&plugin_id); +} + // Answers a pending `/call` with the result the frontend's handler produced // (or an error message) — the other half of the request/response pair the // HTTP handler above is blocked waiting on. @@ -236,8 +319,23 @@ pub fn start(app: &AppHandle) { let router = Router::new() .route("/health", get(health)) .route("/call", post(call)) + .route("/tools", get(list_tools_http)) .with_state((app_handle.clone(), state)); let _ = axum::serve(listener, router).await; }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constant_time_eq_dung_nhu_so_sanh_thuong() { + assert!(constant_time_eq("abc", "abc")); + assert!(constant_time_eq("", "")); + assert!(!constant_time_eq("abc", "abd")); + assert!(!constant_time_eq("abc", "ab")); + assert!(!constant_time_eq("abc", "abcd")); + } +} diff --git a/src-tauri/src/mockserver.rs b/src-tauri/src/mockserver.rs index 4196c7c4..b7fb2ca5 100644 --- a/src-tauri/src/mockserver.rs +++ b/src-tauri/src/mockserver.rs @@ -472,6 +472,17 @@ fn build_engine() -> Engine { engine } +// Một Engine DÙNG CHUNG cho mọi request script-mode, dựng đúng một lần — +// trước đây `run_script` tự gọi `build_engine()` mỗi lần được gọi, tức mỗi +// HTTP request tới một stub script-mode phải trả phí cấu hình 6 giới hạn lại +// từ đầu, đúng loại chi phí per-request mà comment ở đầu module này (`Arc` +// rule-set clone, batched log flush, cached `REGEX_CACHE`) đã chủ ý tránh ở +// mọi chỗ khác. `Engine` bản thân stateless/immutable sau khi cấu hình xong +// (mọi state của một lần chạy nằm ở `Scope`, tạo mới cho mỗi request) nên +// dùng chung an toàn — cần feature "sync" của rhai (Cargo.toml) để `Engine` +// là `Send + Sync`, truy cập được từ nhiều task/thread của server async. +static ENGINE: OnceLock = OnceLock::new(); + fn req_to_rhai(ctx: &RequestCtx, params: &HashMap) -> RhaiMap { let mut map = RhaiMap::new(); map.insert("method".into(), ctx.method.clone().into()); @@ -497,7 +508,7 @@ fn run_script( ctx: &RequestCtx, params: &HashMap, ) -> Result { - let engine = build_engine(); + let engine = ENGINE.get_or_init(build_engine); let mut scope = Scope::new(); scope.push("req", req_to_rhai(ctx, params)); diff --git a/src-tauri/src/secrets_vault.rs b/src-tauri/src/secrets_vault.rs index d31bde69..0d62a6cb 100644 --- a/src-tauri/src/secrets_vault.rs +++ b/src-tauri/src/secrets_vault.rs @@ -80,6 +80,18 @@ struct VaultFile { #[derive(Default)] pub struct VaultState { key: Mutex>, + // Khoá RIÊNG cho TOÀN BỘ một lệnh — cả bước phân giải khoá mã hoá + // (`key_for`, có thể TẠO MỚI khoá lúc chưa từng chạm vault) lẫn chu trình + // đọc-sửa-ghi `secrets.enc` sau đó — không chỉ phần đọc-sửa-ghi. Lấy khoá + // này TRƯỚC `key_for()` ở mọi lệnh: nếu chỉ bọc phần đọc-sửa-ghi (như bản + // đầu), hai lệnh ghi ĐẦU TIÊN gọi gần như đồng thời (vd `Promise.all` lưu + // nhiều mục 2FA lúc app mới cài) có thể mỗi lệnh tự phân giải MỘT khoá + // khác nhau (đường keychain có thể ghi thất bại nửa chừng) trước khi vào + // đoạn có khoá, để lại `secrets.enc` mã hoá bằng một khoá không khớp cache/ + // keychain nữa — cùng loại lỗi mất-cập-nhật-thầm-lặng mà việc bọc riêng + // đọc-sửa-ghi định phòng, chỉ là ở bước SỚM hơn. Cùng cách + // `artifact_installer::InstalledIndex` đã làm cho `extensions/index.json`. + write_lock: Mutex<()>, } fn random_bytes(len: usize) -> Result, String> { @@ -214,14 +226,20 @@ fn write_entries(app: &AppHandle, key: &[u8; KEY_LEN], entries: &Entries) -> Res ciphertext: BASE64.encode(&ciphertext), }; let path = app_data(app)?.join(VAULT_FILE); - std::fs::write(&path, serde_json::to_string(&file).map_err(|e| e.to_string())?) + // Atomic (tmp + rename), cùng khuôn `artifact_installer.rs`'s `write_index` + // đã dùng: `write_lock` chỉ chặn hai LỆNH ghi chồng lên nhau, không chặn + // được một crash/mất điện giữa lúc `std::fs::write` đang chạy — trước đây + // ghi thẳng vào `secrets.enc`, một lần dở dang để lại blob nửa vời mà + // `read_entries` chỉ có thể báo "Kho bí mật hỏng", không khôi phục được. + let tmp_path = app_data(app)?.join(format!("{VAULT_FILE}.tmp")); + std::fs::write(&tmp_path, serde_json::to_string(&file).map_err(|e| e.to_string())?) .map_err(|e| e.to_string())?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + let _ = std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o600)); } - Ok(()) + std::fs::rename(&tmp_path, &path).map_err(|e| e.to_string()) } #[tauri::command] @@ -230,6 +248,7 @@ pub fn secret_vault_get( state: tauri::State<'_, VaultState>, key: String, ) -> Result, String> { + let _guard = state.write_lock.lock().map_err(|e| e.to_string())?; let (k, _) = key_for(&app, &state)?; Ok(read_entries(&app, &k)?.get(&key).cloned()) } @@ -241,6 +260,7 @@ pub fn secret_vault_set( key: String, value: String, ) -> Result<(), String> { + let _guard = state.write_lock.lock().map_err(|e| e.to_string())?; let (k, _) = key_for(&app, &state)?; let mut entries = read_entries(&app, &k)?; entries.insert(key, value); @@ -253,6 +273,7 @@ pub fn secret_vault_delete( state: tauri::State<'_, VaultState>, key: String, ) -> Result<(), String> { + let _guard = state.write_lock.lock().map_err(|e| e.to_string())?; let (k, _) = key_for(&app, &state)?; let mut entries = read_entries(&app, &k)?; if entries.remove(&key).is_some() { @@ -263,6 +284,7 @@ pub fn secret_vault_delete( #[tauri::command] pub fn secret_vault_keys(app: AppHandle, state: tauri::State<'_, VaultState>) -> Result, String> { + let _guard = state.write_lock.lock().map_err(|e| e.to_string())?; let (k, _) = key_for(&app, &state)?; Ok(read_entries(&app, &k)?.into_keys().collect()) } @@ -270,6 +292,7 @@ pub fn secret_vault_keys(app: AppHandle, state: tauri::State<'_, VaultState>) -> /// Xoá toàn bộ nội dung, GIỮ nguyên khoá. Dùng bởi nhánh DEV của frontend. #[tauri::command] pub fn secret_vault_clear(app: AppHandle, state: tauri::State<'_, VaultState>) -> Result<(), String> { + let _guard = state.write_lock.lock().map_err(|e| e.to_string())?; let (k, _) = key_for(&app, &state)?; write_entries(&app, &k, &Entries::new()) } @@ -278,6 +301,7 @@ pub fn secret_vault_clear(app: AppHandle, state: tauri::State<'_, VaultState>) - /// giải mã được và người dùng CHỦ ĐỘNG chấp nhận mất dữ liệu cũ. #[tauri::command] pub fn secret_vault_reset(app: AppHandle, state: tauri::State<'_, VaultState>) -> Result<(), String> { + let _guard = state.write_lock.lock().map_err(|e| e.to_string())?; let dir = app_data(&app)?; let _ = std::fs::remove_file(dir.join(VAULT_FILE)); let _ = std::fs::remove_file(dir.join(KEY_FILE)); @@ -290,6 +314,7 @@ pub fn secret_vault_reset(app: AppHandle, state: tauri::State<'_, VaultState>) - #[tauri::command] pub fn secret_vault_status(app: AppHandle, state: tauri::State<'_, VaultState>) -> Result { + let _guard = state.write_lock.lock().map_err(|e| e.to_string())?; let (k, mode) = key_for(&app, &state)?; Ok(VaultStatus { encrypted: true, diff --git a/src-tauri/src/service_host.rs b/src-tauri/src/service_host.rs index 7ec10c68..0c4faf3b 100644 --- a/src-tauri/src/service_host.rs +++ b/src-tauri/src/service_host.rs @@ -54,21 +54,47 @@ use tokio::time::timeout; /// giải sai payload của nhau. pub const SERVICE_PROTOCOL: u32 = 1; +// Trùng giá trị với `mcp_bridge.rs`'s `CALL_TIMEOUT` (và với 30s, một phần của +// phép tính 35s ở `devtool-mcp-server.rs`) một cách có chủ đích, không phải +// hằng số dùng chung — mỗi module timeout một loại chờ khác nhau (gọi service +// sidecar ở đây, chờ webview trả lời ở mcp_bridge.rs), tình cờ cùng độ dài. +// Đổi một chỗ không cần kéo theo chỗ kia, nhưng nếu định nghĩa lại "độ dài +// timeout hợp lý cho một lời gọi qua IPC cục bộ" thì nên soát cả ba. const CALL_TIMEOUT: Duration = Duration::from_secs(30); -/// Binary sidecar được phép chạy. Mỗi mục PHẢI có một dòng tương ứng trong -/// `bundle.externalBin` của tauri.conf.json, nếu không nó sẽ không được đóng -/// gói cùng app và mọi lời gọi sẽ báo "không tìm thấy". Cũng phải có mặt trong -/// danh sách của `scripts/prepare-service-sidecars.mjs` — hai chỗ khai tay -/// song song vì Rust và Node không chia sẻ được hằng số qua ranh giới ngôn -/// ngữ; `allowlist_khop_voi_external_bin` dưới đây khoá vế phía tauri.conf.json. +/// Binary sidecar được phép chạy — ranh giới tin cậy sống Ở ĐÂY, không phải +/// ở manifest (webview đọc được manifest, không đọc được hằng số Rust này). +/// KHÔNG phải mọi mục ở đây được ĐÓNG GÓI SẴN — xem `BUNDLED_SERVICES` dưới, +/// tách riêng đúng vì lý do đó. /// /// `devtool-svc-echo` là plugin ví dụ tối giản (`ping`/`echo`/`tick-stream`, /// không có giá trị người dùng) — nó tồn tại thuần để chứng minh đường /// end-to-end thật của tier B, cả một-lần lẫn stream, trước khi có plugin thật /// cần tới cơ chế này, xem `tests/service_echo.rs`. Chưa có `plugin.ts` nào /// khai `service` để gọi tới nó, nên nó không xuất hiện ở bất cứ đâu trong UI. -const ALLOWED_SERVICES: &[&str] = &["devtool-svc-echo", "devtool-svc-redis", "devtool-svc-container", "devtool-svc-rabbit"]; +/// +/// `devtool-svc-redis`/`-container`/`-rabbit`/`-kafka`: nguồn đã chuyển sang +/// repo `developer-desktop-util-plugin` (không phải ai cũng cần Redis/Docker/ +/// RabbitMQ/Kafka) — xem docs/decisions/architecture/optional-broker-plugins.md. +/// Tên vẫn ở lại đây có chủ đích: `05-external-install.md` nói rõ "cài qua +/// URL KHÔNG tự cấp quyền chạy cho một tên bin mới" — cây quyết định "bin nào +/// được phép chạy" là của TÁC GIẢ lúc build app, không phải của người cài. Bỏ +/// tên khỏi danh sách này thì việc cài sidecar tương ứng từ URL sẽ luôn bị +/// allowlist từ chối, vĩnh viễn, không chỉ tới lúc cài xong. +const ALLOWED_SERVICES: &[&str] = &["devtool-svc-echo", "devtool-svc-redis", "devtool-svc-container", "devtool-svc-rabbit", "devtool-svc-kafka"]; + +/// Tập con của `ALLOWED_SERVICES` được đóng gói SẴN cùng app (có dòng tương +/// ứng trong `bundle.externalBin` của tauri.conf.json và trong danh sách của +/// `scripts/prepare-service-sidecars.mjs` — hai chỗ khai tay song song vì +/// Rust và Node không chia sẻ được hằng số qua ranh giới ngôn ngữ). +/// +/// Một mục nằm trong `ALLOWED_SERVICES` nhưng KHÔNG ở đây là sidecar chỉ cài +/// được qua URL (Settings → Extensions, `kind: "service"`) — người dùng +/// không cần nó thì không tải bytes của nó về máy. `allowlist_khop_voi_external_bin` +/// dưới đây chỉ khoá phần TẬP CON này khớp `bundle.externalBin`, không khoá +/// toàn bộ `ALLOWED_SERVICES` như trước. +#[cfg(test)] +const BUNDLED_SERVICES: &[&str] = &["devtool-svc-echo"]; #[derive(Debug, Deserialize)] pub struct ServiceRequest { @@ -351,7 +377,7 @@ fn spawn_process( /// mình, không có gì ở tầng hệ thống ngăn một lỗi gõ nhầm tên đọc/ghi nhầm file /// của tool khác. Sidecar mới port sang (bắt đầu từ Redis) sửa đúng khoảng /// trống này; các Tier A còn lại tự động được sửa khi tới lượt chúng port sang -/// Tier B ở các bước sau của Phase 2 (xem docs/decisions/architecture/platform-plugin-architecture.md). +/// Tier B ở các bước sau của Phase 2 (xem docs/decisions/architecture/optional-broker-plugins.md). fn service_data_dir(app_data_dir: &std::path::Path, bin: &str) -> std::path::PathBuf { app_data_dir.join("service-data").join(bin) } @@ -571,11 +597,14 @@ mod tests { #[test] fn allowlist_khop_voi_external_bin() { - // Một mục trong allowlist mà không được đóng gói thì mọi lời gọi tới nó - // báo "không tìm thấy" trên máy người dùng, còn ở máy dev thì chạy tốt - // — kiểu lệch chỉ lộ ra sau khi phát hành. + // Một mục trong BUNDLED_SERVICES mà không được đóng gói thì mọi lời gọi + // tới nó báo "không tìm thấy" trên máy người dùng, còn ở máy dev thì + // chạy tốt — kiểu lệch chỉ lộ ra sau khi phát hành. Chỉ kiểm tập con + // ĐÓNG GÓI SẴN — một mục trong ALLOWED_SERVICES nhưng ngoài + // BUNDLED_SERVICES CỐ Ý không có mặt trong bundle.externalBin (cài qua + // URL), xem comment của cả hai hằng số ở trên. let conf = include_str!("../tauri.conf.json"); - for bin in ALLOWED_SERVICES { + for bin in BUNDLED_SERVICES { assert!( conf.contains(&format!("binaries/{bin}")), "\"{bin}\" nằm trong ALLOWED_SERVICES nhưng thiếu trong bundle.externalBin" diff --git a/src-tauri/tauri.canary.conf.json b/src-tauri/tauri.canary.conf.json deleted file mode 100644 index 1472fe6f..00000000 --- a/src-tauri/tauri.canary.conf.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "$schema": "https://schema.tauri.app/config/2", - "productName": "DevTool Canary", - "identifier": "com.desktop-devtool-app.canary", - "app": { - "windows": [ - { - "fullscreen": false, - "resizable": true, - "title": "DevTool Canary", - "width": 1200, - "height": 800, - "minWidth": 800, - "minHeight": 600, - "titleBarStyle": "Overlay", - "hiddenTitle": true, - "trafficLightPosition": { "x": 12, "y": 20 } - } - ] - }, - "plugins": { - "updater": { - "endpoints": [ - "https://github.com/DianaSensei/developer-desktop-utils/releases/download/canary-latest/latest.json" - ], - "dialog": false, - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFFQjFGRDU1QUUzQTEzRDMKUldUVEV6cXVWZjJ4SGhCVm03YmdSUVN4VVZhQyt6bU0yakhRY3k5eUFpU1JHOXgvbTRoT1pSaFgK" - } - } -} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5810e258..4697202f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -32,7 +32,7 @@ "bundle": { "createUpdaterArtifacts": true, "active": true, - "externalBin": ["binaries/devtool-mcp-server", "binaries/devtool-svc-echo", "binaries/devtool-svc-redis", "binaries/devtool-svc-container", "binaries/devtool-svc-rabbit"], + "externalBin": ["binaries/devtool-mcp-server", "binaries/devtool-svc-echo"], "icon": [ "icons/32x32.png", "icons/128x128.png", @@ -53,6 +53,11 @@ } }, "plugins": { + "deep-link": { + "desktop": { + "schemes": ["desktop-devtool-app"] + } + }, "updater": { "endpoints": [ "https://github.com/DianaSensei/developer-desktop-utils/releases/latest/download/latest.json" diff --git a/src-tauri/tests/rabbit_it.rs b/src-tauri/tests/rabbit_it.rs deleted file mode 100644 index 5a1adff3..00000000 --- a/src-tauri/tests/rabbit_it.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! RabbitMQ broker integration tests. -//! -//! These exercise the same AMQP flows the RabbitMQ tool relies on (publish with -//! confirms, mandatory-return, consume, and direct reply-to) against a real -//! broker. They are `#[ignore]`d so normal `cargo test` stays broker-free. -//! -//! Run against the docker-compose broker (see testing/rabbitmq): -//! RABBIT_IT_AMQP="amqp://guest:guest@localhost:5672/%2f" \ -//! cargo test --test rabbit_it -- --ignored -//! -//! Each test uses unique, auto-deleting resources so the broker stays clean. - -use std::time::Duration; - -use futures_util::StreamExt; -use lapin::options::{ - BasicConsumeOptions, BasicPublishOptions, ConfirmSelectOptions, QueueDeclareOptions, -}; -use lapin::types::FieldTable; -use lapin::{BasicProperties, Connection, ConnectionProperties}; -use uuid::Uuid; - -fn amqp_url() -> String { - std::env::var("RABBIT_IT_AMQP") - .unwrap_or_else(|_| "amqp://guest:guest@localhost:5672/%2f".to_string()) -} - -async fn connect() -> Connection { - // lapin 4 manages the tokio runtime itself (default feature). - Connection::connect(&amqp_url(), ConnectionProperties::default()) - .await - .expect("connect to broker (is the docker-compose stack up?)") -} - -/// Publish to a temp queue with publisher confirms, then consume it back. -#[tokio::test] -#[ignore] -async fn publish_then_consume_roundtrip() { - let conn = connect().await; - let ch = conn.create_channel().await.unwrap(); - ch.confirm_select(ConfirmSelectOptions::default()).await.unwrap(); - - let queue = format!("devtool.it.{}", Uuid::new_v4()); - ch.queue_declare( - queue.as_str().into(), - QueueDeclareOptions { auto_delete: true, ..Default::default() }, - FieldTable::default(), - ) - .await - .unwrap(); - - let body = b"hello-roundtrip"; - let confirm = ch - .basic_publish("".into(), queue.as_str().into(), BasicPublishOptions::default(), body, BasicProperties::default()) - .await - .unwrap() - .await - .unwrap(); - assert!(confirm.is_ack(), "broker should confirm the publish"); - - let mut consumer = ch - .basic_consume(queue.as_str().into(), "it".into(), BasicConsumeOptions { no_ack: true, ..Default::default() }, FieldTable::default()) - .await - .unwrap(); - let delivery = tokio::time::timeout(Duration::from_secs(5), consumer.next()) - .await - .expect("a message should arrive") - .unwrap() - .unwrap(); - assert_eq!(delivery.data, body); -} - -/// A mandatory publish to a routing key with no binding must be returned (unroutable). -#[tokio::test] -#[ignore] -async fn mandatory_unroutable_is_returned() { - let conn = connect().await; - let ch = conn.create_channel().await.unwrap(); - ch.confirm_select(ConfirmSelectOptions::default()).await.unwrap(); - - let nowhere = format!("devtool.it.nowhere.{}", Uuid::new_v4()); - let confirmation = ch - .basic_publish( - "".into(), - nowhere.as_str().into(), - BasicPublishOptions { mandatory: true, ..Default::default() }, - b"orphan", - BasicProperties::default(), - ) - .await - .unwrap() - .await - .unwrap(); - - // Confirmed by the broker, but carries the returned (unroutable) message. - assert!(confirmation.take_message().is_some(), "unroutable mandatory publish should return the message"); -} - -/// Direct reply-to round-trip: a responder echoes requests back to reply_to. -#[tokio::test] -#[ignore] -async fn direct_reply_to_rpc() { - let conn = connect().await; - - // Responder: consume a temp request queue and reply to each message's reply_to. - let req_queue = format!("devtool.it.rpc.{}", Uuid::new_v4()); - let responder = conn.create_channel().await.unwrap(); - responder - .queue_declare(req_queue.as_str().into(), QueueDeclareOptions { auto_delete: true, ..Default::default() }, FieldTable::default()) - .await - .unwrap(); - let mut requests = responder - .basic_consume(req_queue.as_str().into(), "responder".into(), BasicConsumeOptions { no_ack: true, ..Default::default() }, FieldTable::default()) - .await - .unwrap(); - let responder_ch = responder.clone(); - tokio::spawn(async move { - while let Some(Ok(req)) = requests.next().await { - if let Some(reply_to) = req.properties.reply_to().as_ref() { - let mut up = String::from_utf8_lossy(&req.data).to_string(); - up.make_ascii_uppercase(); - let props = BasicProperties::default() - .with_correlation_id(req.properties.correlation_id().clone().unwrap_or_default()); - let _ = responder_ch - .basic_publish("".into(), reply_to.as_str().into(), BasicPublishOptions::default(), up.as_bytes(), props) - .await; - } - } - }); - - // Client: consume direct reply-to, publish a request, await the reply. - let client = conn.create_channel().await.unwrap(); - let mut replies = client - .basic_consume("amq.rabbitmq.reply-to".into(), "client".into(), BasicConsumeOptions { no_ack: true, ..Default::default() }, FieldTable::default()) - .await - .unwrap(); - let corr = Uuid::new_v4().to_string(); - client - .basic_publish( - "".into(), - req_queue.as_str().into(), - BasicPublishOptions::default(), - b"ping", - BasicProperties::default() - .with_reply_to("amq.rabbitmq.reply-to".into()) - .with_correlation_id(corr.clone().into()), - ) - .await - .unwrap() - .await - .unwrap(); - - let reply = tokio::time::timeout(Duration::from_secs(5), replies.next()) - .await - .expect("reply should arrive") - .unwrap() - .unwrap(); - assert_eq!(reply.data, b"PING"); - assert_eq!(reply.properties.correlation_id().as_ref().map(|s| s.to_string()), Some(corr)); -} diff --git a/src/App.tsx b/src/App.tsx index c331cdd2..d7d20603 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,7 @@ import { allTools, toolPath } from '@/lib/toolRegistry'; import { buildNavEntries, countFavoriteEntries, GROUP_OF_TOOL, type NavEntry } from '@/lib/toolGroups'; import { toolMatchesQuery } from '@/lib/toolSearch'; import { useLiveConnections } from '@/lib/liveConnections'; +import { initDeepLinkHandling } from '@/lib/deepLink'; import { CommandPalette } from '@/components/CommandPalette'; import { OpenToolsStrip } from '@/components/OpenToolsStrip'; import { McpBackgroundBridge } from '@/components/McpBackgroundBridge'; @@ -37,10 +38,6 @@ import { McpManageBridge } from '@/components/McpManageBridge'; import { McpUtilityBridge } from '@/components/McpUtilityBridge'; import { ApiClientRuntimeProvider } from '@/components/tools/apiclient/mcpRuntimeContext'; import { MockServerRuntimeProvider } from '@/components/tools/mockserver/mcpRuntimeContext'; -import { RedisRuntimeProvider } from '@/components/tools/redis/mcpRuntimeContext'; -import { KafkaRuntimeProvider } from '@/components/tools/kafka/mcpRuntimeContext'; -import { RabbitRuntimeProvider } from '@/components/tools/rabbit/mcpRuntimeContext'; -import { ContainerRuntimeProvider } from '@/components/tools/container/mcpRuntimeContext'; import { ExperimentalGate } from '@/components/ExperimentalGate'; import { ExperimentalDot, ExperimentalMark } from '@/components/ExperimentalBadge'; import { Button } from '@/components/ui/button'; @@ -49,6 +46,7 @@ import { FeatureProvider, useFeatures } from '@/contexts/FeatureContext'; import { LocaleProvider, useLocale } from '@/contexts/LocaleContext'; import type { TranslationKey } from '@/lib/i18n'; import { UpdateProvider, useUpdate } from '@/contexts/UpdateContext'; +import { ExtensionUpdateProvider, useExtensionUpdates } from '@/contexts/ExtensionUpdateContext'; import { AppConfigProvider } from '@/contexts/AppConfigContext'; import { MeetingsProvider } from '@/lib/meetings'; import { UpdateDialog } from '@/components/UpdateDialog'; @@ -455,6 +453,7 @@ function Sidebar({ const location = useLocation(); const { isFeatureEnabled, toggleFeature, toolOrder, favorites, toggleFavorite, isFavorite } = useFeatures(); const { updateAvailable } = useUpdate(); + const { updates: extensionUpdates } = useExtensionUpdates(); const { t } = useLocale(); const navigate = useNavigate(); const [query, setQuery] = useState(''); @@ -683,7 +682,7 @@ function Sidebar({ > - {updateAvailable && ( + {(updateAvailable || extensionUpdates.length > 0) && ( )} @@ -706,6 +705,7 @@ function Sidebar({ function AppContent() { const location = useLocation(); + const navigate = useNavigate(); const { isFeatureEnabled, toolOrder } = useFeatures(); const liveIds = useLiveConnections(); const { t } = useLocale(); @@ -746,6 +746,13 @@ function AppContent() { storageSet('devtool-sidebar-collapsed', isCollapsed.toString()); }, [isCollapsed]); + // `desktop-devtool-app://install?manifest=...` từ trang plugin (starlight-site) — chỉ + // gắn một lần cho cả vòng đời app (guard `started` bên trong), nên effect + // này an toàn khi chạy lại nếu `navigate` đổi identity. + useEffect(() => { + void initDeepLinkHandling(navigate); + }, [navigate]); + const changeTheme = (value: ThemePreference) => { triggerThemeTransition(); setThemePreference(value); @@ -1248,29 +1255,23 @@ function App() { - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + diff --git a/src/components/McpBackgroundBridge.tsx b/src/components/McpBackgroundBridge.tsx index dcd077ac..37de88d9 100644 --- a/src/components/McpBackgroundBridge.tsx +++ b/src/components/McpBackgroundBridge.tsx @@ -3,10 +3,18 @@ // (`useMcpBackgroundBridge`, off by default per the "no silent network // calls" rule). Mounted once at the app root (App.tsx), inside every // runtime provider so it shares the exact same store/state instances the -// tool components read when they're open — see apiclient/mcpRuntimeContext.tsx, -// mockserver/mcpRuntimeContext.tsx, redis/mcpRuntimeContext.tsx, -// kafka/mcpRuntimeContext.tsx, rabbit/mcpRuntimeContext.tsx, and -// container/mcpRuntimeContext.tsx for why that sharing matters. +// tool components read when they're open — see apiclient/mcpRuntimeContext.tsx +// and mockserver/mcpRuntimeContext.tsx for why that sharing matters. +// +// redis-client/rabbit-client/container-manager/kafka-explorer moved to +// developer-desktop-util-plugin (see +// docs/decisions/architecture/optional-broker-plugins.md) and no longer +// have a background bridge here: an externally-installed plugin only mounts +// when its own route is visited, so there's no compile-time runtime context +// to share while it's off-screen. Their MCP tool definitions still exist in +// devtool-mcp-server.rs (bundled with those plugins, registered dynamically +// when installed); calls to them simply go unanswered unless the plugin's +// own route is open. // // The stateless utility tools (Encode·Hash·Encrypt, JWT Debugger, JSON // Formatter) do NOT need this — see McpUtilityBridge.tsx, mounted @@ -23,14 +31,6 @@ import { useMcpBridge as useApiClientMcpBridge } from './tools/apiclient/mcpBrid import { useApiClientRuntime } from './tools/apiclient/mcpRuntimeContext'; import { useMcpBridge as useMockServerMcpBridge } from './tools/mockserver/mcpBridge'; import { useMockServerRuntime } from './tools/mockserver/mcpRuntimeContext'; -import { useMcpBridge as useRedisMcpBridge } from './tools/redis/mcpBridge'; -import { useRedisRuntime } from './tools/redis/mcpRuntimeContext'; -import { useMcpBridge as useKafkaMcpBridge } from './tools/kafka/mcpBridge'; -import { useKafkaRuntime } from './tools/kafka/mcpRuntimeContext'; -import { useMcpBridge as useRabbitMcpBridge } from './tools/rabbit/mcpBridge'; -import { useRabbitRuntime } from './tools/rabbit/mcpRuntimeContext'; -import { useMcpBridge as useContainerMcpBridge } from './tools/container/mcpBridge'; -import { useContainerRuntime } from './tools/container/mcpRuntimeContext'; import { useMcpBackgroundBridge } from '@/hooks/useMcpBackgroundBridge'; import { useMcpToolEnabledMap } from '@/hooks/useMcpToolEnabled'; @@ -39,17 +39,9 @@ export function McpBackgroundBridge() { const { isEnabled } = useMcpToolEnabledMap(); const { store, runRequest } = useApiClientRuntime(); const mockServer = useMockServerRuntime(); - const redisState = useRedisRuntime(); - const kafkaState = useKafkaRuntime(); - const rabbitState = useRabbitRuntime(); - const containerState = useContainerRuntime(); useApiClientMcpBridge(store, runRequest, enabled && isEnabled('api-client')); useMockServerMcpBridge(mockServer, enabled && isEnabled('mock-server')); - useRedisMcpBridge(redisState, enabled && isEnabled('redis-client')); - useKafkaMcpBridge(kafkaState, enabled && isEnabled('kafka-explorer')); - useRabbitMcpBridge(rabbitState, enabled && isEnabled('rabbit-client')); - useContainerMcpBridge(containerState, enabled && isEnabled('container-manager')); return null; } diff --git a/src/components/McpManageBridge.tsx b/src/components/McpManageBridge.tsx index 14439c0b..dd1457ee 100644 --- a/src/components/McpManageBridge.tsx +++ b/src/components/McpManageBridge.tsx @@ -2,8 +2,10 @@ // // A third `mcp:call` listener, mounted unconditionally at the app root // (App.tsx) alongside McpBackgroundBridge — unlike the API Client / Mock -// Server / Redis / Kafka bridges, it is never gated by the "Background MCP -// bridge" setting OR the per-tool toggles, since its whole purpose is to +// Server bridges (the only two compiled-in tools the Background MCP bridge +// setting still applies to; every other tool is an externally-installed +// plugin now, see McpBackgroundBridge.tsx), it is never gated by that +// setting OR the per-tool toggles, since its whole purpose is to // let an MCP client query and control those settings (and the rest of the // MCP surface) itself, including turning background mode on or re-enabling // a tool it accidentally switched off, without anyone touching Settings → @@ -22,6 +24,11 @@ import { isTauri } from '@/lib/platform'; import { useMcpBackgroundBridge } from '@/hooks/useMcpBackgroundBridge'; import { useMcpToolEnabledMap, MCP_TOOL_IDS, type McpToolId } from '@/hooks/useMcpToolEnabled'; import { useMockServerRuntime } from '@/components/tools/mockserver/mcpRuntimeContext'; +import { META_MCP_TOOLS } from './mcpMetaTools'; + +// Registered under this synthetic id — this bridge isn't a plugin (no +// manifest, no SDK), just the platform's own always-on management surface. +const META_PLUGIN_ID = 'devtool-platform'; interface McpCallEvent { id: string; @@ -49,7 +56,7 @@ function buildHandlers(deps: Deps): Record { backgroundBridgeEnabled: deps.backgroundEnabled, toolsEnabled: deps.toolsEnabled, note: deps.backgroundEnabled - ? 'API Client, Mock Server, Redis Client, and Kafka Explorer tools all answer MCP calls regardless of which tool is on screen — except any tool switched off in toolsEnabled, which never answers.' + ? 'API Client and Mock Server tools answer MCP calls regardless of which tool is on screen (every other tool is an installable plugin — its calls only answer while its own route is open, background bridge or not) — except any tool switched off in toolsEnabled, which never answers.' : 'Each tool only answers MCP calls while it is the one on screen (and only if enabled in toolsEnabled). Call devtool_mcp_set_background with enabled:true to lift the on-screen requirement.', mockServer: { running: deps.mockServer.running, @@ -111,12 +118,16 @@ export function McpManageBridge(): null { (async () => { const { listen } = await import('@tauri-apps/api/event'); const { invoke } = await import('@tauri-apps/api/core'); + // Unconditional, never unregistered — this bridge IS the platform's own + // management surface, mounted for the app's whole lifetime (see the + // file-level comment on why it has no `enabled` prop). + await invoke('mcp_register_tools', { pluginId: META_PLUGIN_ID, tools: META_MCP_TOOLS }); const fn = await listen('mcp:call', async (event) => { const { id, tool, args } = event.payload; const handler = buildHandlers(depsRef.current)[tool]; - // Not one of this bridge's tools — leave it for the API Client / Mock - // Server / Redis / Kafka bridges, which may also be listening on the - // same event. + // Not one of this bridge's tools — leave it for whichever tool's own + // bridge owns it (compiled-in or an installed plugin), which may + // also be listening on the same event. if (!handler) return; try { const result = await handler(args ?? {}); diff --git a/src/components/McpUtilityBridge.tsx b/src/components/McpUtilityBridge.tsx index de03b0e7..250fe851 100644 --- a/src/components/McpUtilityBridge.tsx +++ b/src/components/McpUtilityBridge.tsx @@ -25,6 +25,9 @@ import { buildCodecHandlers } from './tools/codecMcpBridge'; import { buildJwtHandlers } from './tools/jwtMcpBridge'; import { buildJsonHandlers } from './tools/jsonMcpBridge'; import { useMcpToolEnabledMap } from '@/hooks/useMcpToolEnabled'; +import { CODEC_MCP_TOOLS, JWT_MCP_TOOLS, JSON_MCP_TOOLS } from './mcpUtilityTools'; + +const UTILITY_PLUGIN_ID = 'devtool-utility'; interface McpCallEvent { id: string; @@ -52,6 +55,18 @@ export function McpUtilityBridge(): null { (async () => { const { listen } = await import('@tauri-apps/api/event'); const { invoke } = await import('@tauri-apps/api/core'); + // Registers only the currently-enabled subset — re-runs (replacing the + // prior registration wholesale, see mcp_bridge.rs) whenever one of the + // three toggles flips, same as this effect already re-listens for + // that. A disabled tool shouldn't appear in list_tools at all, since + // devtool_mcp_set_tool_enabled says it "never answers ... at all" for + // these three, not just "on screen". + const tools = [ + ...(codecEnabled ? CODEC_MCP_TOOLS : []), + ...(jwtEnabled ? JWT_MCP_TOOLS : []), + ...(jsonEnabled ? JSON_MCP_TOOLS : []), + ]; + await invoke('mcp_register_tools', { pluginId: UTILITY_PLUGIN_ID, tools }); const fn = await listen('mcp:call', async (event) => { const { id, tool, args } = event.payload; const handlers: Record | null = diff --git a/src/components/Settings.routing.test.tsx b/src/components/Settings.routing.test.tsx new file mode 100644 index 00000000..77e2052f --- /dev/null +++ b/src/components/Settings.routing.test.tsx @@ -0,0 +1,32 @@ +import { describe, expect, it, afterEach } from 'vitest'; +import { screen, cleanup } from '@testing-library/react'; +import { renderSettings } from '@/testSupport/renderSettings'; + +/** + * `desktop-devtool-app://install?...` (src/lib/deepLink.ts) điều hướng tới `/settings` + * với `{ state: { section: 'plugins' } }` để mở thẳng màn cài đặt tiện ích — + * xác nhận Settings thực sự đọc `location.state.section` này, chứ không rơi + * về mục mặc định (Appearance). Bug này chính là finding pullfrog nêu trên + * PR #126: điều hướng tới `/settings` không tự chọn mục nào cả. + */ + +afterEach(() => { + cleanup(); +}); + +describe('Settings — mở thẳng vào một mục qua location.state.section', () => { + it('không có state → mặc định mở Appearance, không phải Plugin', () => { + renderSettings([{ pathname: '/settings' }]); + expect(screen.queryByRole('heading', { name: 'Plugins', level: 2 })).toBeNull(); + }); + + it('state.section = "plugins" → mở thẳng mục Plugin (màn cài đặt tiện ích)', () => { + renderSettings([{ pathname: '/settings', state: { section: 'plugins' } }]); + expect(screen.getByRole('heading', { name: 'Plugins', level: 2 })).toBeTruthy(); + }); + + it('state.section không hợp lệ → bỏ qua, vẫn mở Appearance', () => { + renderSettings([{ pathname: '/settings', state: { section: 'not-a-real-section' } }]); + expect(screen.queryByRole('heading', { name: 'Plugins', level: 2 })).toBeNull(); + }); +}); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 81e65c71..3dfef690 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,4 +1,5 @@ import { useRef, useCallback, useState, useEffect, type CSSProperties } from 'react'; +import { useLocation } from 'react-router-dom'; import { useFeatures } from '@/contexts/FeatureContext'; import { cn } from '@/lib/utils'; import { @@ -41,6 +42,8 @@ import { GROUP_OF_TOOL, clusterToolOrder } from '@/lib/toolGroups'; import { useAppConfig } from '@/contexts/AppConfigContext'; import { CONFIG_FIELDS, SECTION_LABELS, type ConfigSection } from '@/config/appConfig'; import { useUpdate } from '@/contexts/UpdateContext'; +import { useExtensionUpdates } from '@/contexts/ExtensionUpdateContext'; +import { Badge } from '@/components/ui/badge'; import { AppLogo } from '@/components/AppLogo'; import { ExperimentalBadge } from '@/components/ExperimentalBadge'; import { useOnboarding } from '@/contexts/OnboardingContext'; @@ -301,10 +304,15 @@ const SETTINGS_SECTIONS = [ type SettingsSectionId = (typeof SETTINGS_SECTIONS)[number]['id']; +function isSettingsSectionId(value: unknown): value is SettingsSectionId { + return typeof value === 'string' && SETTINGS_SECTIONS.some((s) => s.id === value); +} + export function Settings() { const { features, toggleFeature, resetToDefaults, toolOrder, reorderTools, isFavorite, toggleFavorite } = useFeatures(); const { open: openOnboarding } = useOnboarding(); const { status: updateStatus, updateInfo, updateAvailable, error: updateError, downloadProgress, autoCheckEnabled, checkHour, setCheckHour, toggleAutoCheck, checkForUpdates, installUpdate, cancelInstall, openUpdateDialog } = useUpdate(); + const { updates: extensionUpdates } = useExtensionUpdates(); const { config, setField, resetConfig } = useAppConfig(); const { locale, setLocale, t } = useLocale(); const { enabled: mcpBackgroundEnabled, setEnabled: setMcpBackgroundEnabled } = useMcpBackgroundBridge(); @@ -312,7 +320,21 @@ export function Settings() { // Một mục đang mở, không phải bốn cờ accordion rời — điều hướng ở nav trái // TỰ LÀ sự "mở/đóng" rồi, nên bỏ hẳn kiểu accordion lồng bên trong từng mục. // Xem SETTINGS_SECTIONS + nav trái ở cuối file cho danh sách đầy đủ. - const [activeSection, setActiveSection] = useState('appearance'); + const location = useLocation(); + // `desktop-devtool-app://install?...` (deepLink.ts) điều hướng tới đây với + // `{ state: { section: 'plugins' } }` để mở thẳng màn cài đặt tiện ích + // thay vì rơi vào mục mặc định (Appearance). Đọc cả lúc khởi tạo (mở lạnh) + // lẫn qua effect bên dưới (app đã đứng sẵn ở Settings, một link thứ hai + // chỉ đổi `location.state` chứ không mount lại component này). + const [activeSection, setActiveSection] = useState(() => + isSettingsSectionId((location.state as { section?: unknown } | null)?.section) + ? (location.state as { section: SettingsSectionId }).section + : 'appearance', + ); + useEffect(() => { + const section = (location.state as { section?: unknown } | null)?.section; + if (isSettingsSectionId(section)) setActiveSection(section); + }, [location.state]); const [currentVersion, setCurrentVersion] = useState(''); const [dataDir, setDataDir] = useState(''); // Khởi tạo từ storage — main.tsx đã áp tone này lên trước khi React @@ -473,13 +495,16 @@ export function Settings() { onClick={() => setActiveSection(s.id)} aria-current={activeSection === s.id ? 'true' : undefined} className={cn( - 'w-full rounded-sm border px-2.5 py-2 text-left text-sm transition-colors', + 'flex w-full items-center justify-between gap-2 rounded-sm border px-2.5 py-2 text-left text-sm transition-colors', activeSection === s.id ? 'border-line bg-card font-medium text-fg' : 'border-transparent text-fg-mute hover:bg-card/60 hover:text-fg', )} > - {t(s.label)} + {t(s.label)} + {s.id === 'plugins' && extensionUpdates.length > 0 && ( + {extensionUpdates.length} + )} ))} diff --git a/src/components/SettingsAppearance.test.tsx b/src/components/SettingsAppearance.test.tsx index d5516b45..a4325dc4 100644 --- a/src/components/SettingsAppearance.test.tsx +++ b/src/components/SettingsAppearance.test.tsx @@ -1,11 +1,6 @@ import { describe, expect, it, afterEach } from 'vitest'; -import { render, screen, fireEvent, cleanup } from '@testing-library/react'; -import { Settings } from '@/components/Settings'; -import { FeatureProvider } from '@/contexts/FeatureContext'; -import { LocaleProvider } from '@/contexts/LocaleContext'; -import { OnboardingProvider } from '@/contexts/OnboardingContext'; -import { AppConfigProvider } from '@/contexts/AppConfigContext'; -import { UpdateProvider } from '@/contexts/UpdateContext'; +import { screen, fireEvent, cleanup } from '@testing-library/react'; +import { renderSettings } from '@/testSupport/renderSettings'; import { ACCENT_TONES } from '@/lib/accentPreference'; import { storageGet, storageRemove } from '@/lib/persistentStore'; @@ -16,22 +11,6 @@ import { storageGet, storageRemove } from '@/lib/persistentStore'; * "có render không" (build đã bắt việc đó). */ -function renderSettings() { - return render( - - - - - - - - - - - , - ); -} - afterEach(() => { cleanup(); storageRemove('devtool-accent'); diff --git a/src/components/SettingsExtensionInstaller.test.tsx b/src/components/SettingsExtensionInstaller.test.tsx index 830f1ca3..fa0a26d8 100644 --- a/src/components/SettingsExtensionInstaller.test.tsx +++ b/src/components/SettingsExtensionInstaller.test.tsx @@ -89,22 +89,43 @@ function installedServiceRaw(overrides: Partial> = {}) { }; } -async function renderInTauri() { +/** + * `resetModules()` + import + render, dùng chung cho MỌI test cần một lần nạp + * module riêng (isTauri đọc một lần lúc `@/lib/platform` được nạp — xem ghi + * chú ở đầu file). `beforeRender` là hook cho những test cần chen thêm một + * bước NGAY TRƯỚC `render()` nhưng vẫn trong cùng epoch module vừa reset (vd + * enqueue vào `pendingInstall` trước khi component này mount) — không thể + * tách các bước ra một hàm ngoài vì mỗi bước phải cùng một lần `import()` + * (sau `resetModules()`, không có `resetModules()` nào xen giữa). + */ +async function renderInstaller(opts?: { tauri?: boolean; beforeRender?: () => void | Promise }) { vi.resetModules(); - (window as unknown as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = {}; + if (opts?.tauri ?? true) { + (window as unknown as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = {}; + } else { + delete (window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; + } // Cả `LocaleProvider` lẫn component đều phải đến từ CÙNG một lần nạp module // (sau `resetModules()`) — nạp `LocaleProvider` từ một graph module khác thì // `useContext` bên trong component không thấy đúng Provider, ném "must be // used within a LocaleProvider" dù JSX nhìn đúng. const { LocaleProvider } = await import('@/contexts/LocaleContext'); + const { ExtensionUpdateProvider } = await import('@/contexts/ExtensionUpdateContext'); const { SettingsExtensionInstaller } = await import('@/components/SettingsExtensionInstaller'); + await opts?.beforeRender?.(); return render( - + + + , ); } +async function renderInTauri() { + return renderInstaller({ tauri: true }); +} + afterEach(() => { cleanup(); invokeMock.mockReset(); @@ -115,15 +136,7 @@ afterEach(() => { describe('SettingsExtensionInstaller — bản web (không phải Tauri)', () => { it('hiện cảnh báo, không gọi invoke', async () => { - vi.resetModules(); - delete (window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; - const { LocaleProvider } = await import('@/contexts/LocaleContext'); - const { SettingsExtensionInstaller } = await import('@/components/SettingsExtensionInstaller'); - render( - - - , - ); + await renderInstaller({ tauri: false }); expect(screen.getByText(/only works in the desktop app|chỉ hoạt động trên bản desktop/)).toBeTruthy(); expect(invokeMock).not.toHaveBeenCalled(); @@ -311,3 +324,56 @@ describe('SettingsExtensionInstaller — service đòi xác nhận trước khi expect(invokeMock).not.toHaveBeenCalledWith('artifact_installer_uninstall', expect.anything()); }); }); + +describe('SettingsExtensionInstaller — hàng đợi desktop-devtool-app://install (pendingInstall)', () => { + it('URL đã xếp sẵn TRƯỚC khi mount thì tự điền ngay vào ô URL', async () => { + invokeMock.mockResolvedValueOnce([]); // list lúc mount + // Không mock kết quả `fetch_manifest` ở đây: mount này tự bắn NHIỀU lời + // gọi invoke cùng lúc trong cùng một nhịp đồng bộ (refresh() của chính + // component, refresh() của ExtensionUpdateProvider, VÀ auto-preview của + // hàng đợi) — đúng kịch bản race đã ghi ở đầu file ("bắn thêm một lời gọi + // invoke NGAY LẬP TỨC... có thể đua với chính lần import đó"), nhưng ở + // đây là BA lời gọi đồng thời thay vì hai, và (khác các chỗ khác) race + // này KHÔNG ngẫu nhiên — lời gọi thứ hai/ba luôn thua, luôn nhận bản + // `@tauri-apps/api/core` thật thay vì bản mock. Bài test này vì vậy chỉ + // xác nhận phần chắc chắn không phụ thuộc invoke: ô URL được điền đúng + // giá trị đã xếp hàng. Hành vi "tự xem trước sau khi điền" được test đủ + // ở case "xếp SAU khi đã mount" bên dưới, nơi enqueue xảy ra SAU khi + // component đã ổn định (không đua với lời gọi invoke nào khác). + await renderInstaller({ + tauri: true, + beforeRender: async () => { + const { pendingInstall } = await import('@/lib/pendingInstall'); + pendingInstall.enqueue(['https://example.com/queued.json']); + }, + }); + await settle(); + + expect((screen.getByPlaceholderText('https://example.com/extension.json') as HTMLInputElement).value).toBe( + 'https://example.com/queued.json', + ); + }); + + it('URL xếp SAU khi đã mount (link thứ hai trong lúc đang đứng ở đây) vẫn tự xem trước, không cần remount', async () => { + invokeMock.mockResolvedValueOnce([]); // list lúc mount + await renderInTauri(); + await settle(); + + // Cùng lần nạp module renderInTauri() vừa reset — lấy pendingInstall từ + // đó để enqueue "trong lúc component đã mount", đúng kịch bản pullfrog + // nêu: single-instance chuyển tiếp một link desktop-devtool-app:// thứ hai trong khi + // người dùng đang đứng sẵn ở Settings → Plugin. + const { pendingInstall } = await import('@/lib/pendingInstall'); + invokeMock.mockResolvedValueOnce(pluginManifestRaw({ label: 'Second Link Plugin' })); + act(() => { + pendingInstall.enqueue(['https://example.com/second.json']); + }); + + await waitFor(() => + expect((screen.getByPlaceholderText('https://example.com/extension.json') as HTMLInputElement).value).toBe( + 'https://example.com/second.json', + ), + ); + await waitFor(() => expect(screen.getByText('Second Link Plugin')).toBeTruthy()); + }); +}); diff --git a/src/components/SettingsExtensionInstaller.tsx b/src/components/SettingsExtensionInstaller.tsx index 6e1ed35d..76460baf 100644 --- a/src/components/SettingsExtensionInstaller.tsx +++ b/src/components/SettingsExtensionInstaller.tsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, useState } from 'react'; import { Download, RefreshCw, Trash2 } from 'lucide-react'; import { useLocale } from '@/contexts/LocaleContext'; +import { useExtensionUpdates } from '@/contexts/ExtensionUpdateContext'; import { isTauri } from '@/lib/platform'; +import { pendingInstall } from '@/lib/pendingInstall'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Callout } from '@/components/ui/callout'; @@ -50,6 +52,7 @@ type PendingServiceAction = 'update' | 'uninstall'; export function SettingsExtensionInstaller() { const { t } = useLocale(); + const { updates: autoUpdates } = useExtensionUpdates(); const [url, setUrl] = useState(''); const [preview, setPreview] = useState(null); const [previewTriple, setPreviewTriple] = useState(null); @@ -82,19 +85,55 @@ export function SettingsExtensionInstaller() { void refresh(); }, [refresh]); + // `desktop-devtool-app://install?manifest=...&service=...` (starlight-site) xếp URL + // vào hàng đợi này rồi điều hướng tới Settings → Plugin — nhưng nếu người + // dùng ĐÃ đứng sẵn ở đây khi link thứ hai tới (single-instance chuyển tiếp + // sang tiến trình đang chạy), điều hướng tới path đang đứng sẵn không + // remount component, nên không thể chỉ trông chờ effect chạy một lần lúc + // mount — phải nghe `pendingInstall.subscribe` để tự kéo hàng đợi mỗi khi + // có URL mới, không riêng gì lúc mount. `drain` được khai báo là closure + // trong cùng thân hàm nên tham chiếu đúng `runPreview` hiện tại dù effect + // đăng ký subscribe trước khi `runPreview` được gán ở dưới — cùng lý do đã + // giải thích ở đầu `pendingInstall.ts`. Người dùng vẫn phải tự bấm "Cài + // đặt" để xác nhận; đây chỉ tự điền + xem trước thay cho copy/dán tay. + useEffect(() => { + const drain = () => { + const next = pendingInstall.dequeue(); + if (!next) return; + setUrl(next); + void runPreview(next); + }; + drain(); // bắt URL đã xếp sẵn trước khi component này mount + return pendingInstall.subscribe(drain); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ExtensionUpdateContext đã tự kiểm mọi artifact lúc app khởi động — dùng + // kết quả đó để hiện sẵn nút "Update" ngay khi mở trang này, thay vì bắt + // người dùng bấm "Check for update" từng dòng trước (nút đó vẫn còn, cho + // trường hợp một artifact đổi version SAU lần kiểm lúc khởi động). + useEffect(() => { + if (autoUpdates.length === 0) return; + setRowUpdateVersion((prev) => { + const next = { ...prev }; + for (const u of autoUpdates) next[u.key] = u.remoteVersion; + return next; + }); + }, [autoUpdates]); + if (!isTauri) { return {t('settings.plugins.install.webWarning')}; } - const handlePreview = async () => { + const runPreview = async (targetUrl: string) => { setPreview(null); setPreviewTriple(null); setPreviewError(null); setInstallError(null); - if (!url.trim()) return; + if (!targetUrl.trim()) return; setPreviewing(true); try { - const manifest = await fetchArtifactManifestPreview(url.trim()); + const manifest = await fetchArtifactManifestPreview(targetUrl.trim()); setPreview(manifest); if (manifest.kind === 'service') { // Hiển thị cho người dùng — TÍNH Ở RUST, không đoán ở phía webview @@ -108,6 +147,8 @@ export function SettingsExtensionInstaller() { } }; + const handlePreview = () => runPreview(url); + const handleInstall = async () => { setInstallError(null); setInstalling(true); @@ -118,6 +159,14 @@ export function SettingsExtensionInstaller() { setPreviewTriple(null); setNeedsRestart(true); await refresh(); + // Deep link cài cả plugin lẫn sidecar service của nó xếp lần lượt hai + // URL vào hàng đợi (xem deepLink.ts) — vừa cài xong cái đầu thì tự xem + // trước luôn cái kế tiếp, vẫn chờ người dùng bấm "Cài đặt" riêng cho nó. + const next = pendingInstall.dequeue(); + if (next) { + setUrl(next); + await runPreview(next); + } } catch (e) { setInstallError(String(e instanceof Error ? e.message : e)); } finally { diff --git a/src/components/SettingsPlugins.test.tsx b/src/components/SettingsPlugins.test.tsx index 36ea8cf2..cc517042 100644 --- a/src/components/SettingsPlugins.test.tsx +++ b/src/components/SettingsPlugins.test.tsx @@ -3,6 +3,7 @@ import { render, screen, cleanup, within } from '@testing-library/react'; import { SettingsPlugins } from '@/components/SettingsPlugins'; import { FeatureProvider } from '@/contexts/FeatureContext'; import { LocaleProvider } from '@/contexts/LocaleContext'; +import { ExtensionUpdateProvider } from '@/contexts/ExtensionUpdateContext'; import { PLUGINS, createPluginSdk, pluginAudit } from '@/platform'; import { storageRemove } from '@/lib/persistentStore'; @@ -17,7 +18,9 @@ function renderPanel() { return render( - + + + , ); @@ -44,15 +47,15 @@ describe('Settings — Plugins', () => { it('quyền hiển thị lấy thẳng từ manifest, không phải bảng chép tay', () => { const { container } = renderPanel(); - const redis = PLUGINS.find((p) => p.id === 'redis-client')!; + const apiClient = PLUGINS.find((p) => p.id === 'api-client')!; // Khoá theo `data-plugin` thay vì dò cây DOM: nhiều plugin cùng có quyền // 'storage', bắt nhầm hàng sẽ khiến test xanh trong khi hàng thật sai. - const row = container.querySelector('[data-plugin="redis-client"]')!; + const row = container.querySelector('[data-plugin="api-client"]')!; expect(row).toBeTruthy(); - for (const perm of redis.permissions) { + for (const perm of apiClient.permissions) { expect(within(row).getByText(perm), perm).toBeTruthy(); } - expect(within(row).getByText(/mcp_respond/)).toBeTruthy(); + expect(within(row).getByText(/mcp_/)).toBeTruthy(); }); it('lời gọi bị từ chối hiện trong nhật ký, kèm quyền còn thiếu', () => { diff --git a/src/components/mcpMetaTools.ts b/src/components/mcpMetaTools.ts new file mode 100644 index 00000000..77089351 --- /dev/null +++ b/src/components/mcpMetaTools.ts @@ -0,0 +1,38 @@ +// MCP tool catalogue for this bridge — see mcpBridge.ts convention notes. +// Bundled with McpManageBridge.tsx (the platform's own management surface, +// mounted unconditionally at the app root) — kept in sync BY HAND with the +// `devtool_mcp_*` handlers in that file's `buildHandlers()`. +export interface McpToolDef { + name: string; + description: string; + inputSchema: Record; +} + +export const META_MCP_TOOLS: McpToolDef[] = [ + { + "name": "devtool_mcp_status", + "description": "Get DevTool's current MCP integration state: whether the Background MCP bridge is on, the per-tool enabled/disabled map (toolsEnabled: api-client/mock-server/redis-client/kafka-explorer/rabbit-client/container-manager/base64/jwt/json), and the mock server's running status. Call this first if a tool's calls unexpectedly time out — a disabled tool times out exactly like \"wrong tool on screen\" does, since it never registers a listener either (except base64/jwt/json, which have no \"on screen\" requirement at all — a disabled one there just never answers).", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "devtool_mcp_set_background", + "description": "Turn DevTool's Background MCP bridge on or off (mirrors the Settings → MCP toggle). When on, every enabled tool's MCP tools answer regardless of which tool is on screen or whether the app window is focused — call this with enabled:true instead of asking the user to click it manually.", + "inputSchema": { + "type": "object", + "properties": { "enabled": { "type": "boolean" } }, + "required": ["enabled"] + } + }, + { + "name": "devtool_mcp_set_tool_enabled", + "description": "Turn one tool's MCP access on or off (mirrors Settings → MCP → Per-tool MCP access). A disabled tool never answers any of its MCP tool calls — for the three stateless utility tools (base64/jwt/json) that means at all; for the rest, on screen or in the background. This is a separate, stricter switch than devtool_mcp_set_background. `tool` is one of: api-client, mock-server, redis-client, kafka-explorer, rabbit-client, container-manager, base64, jwt, json.", + "inputSchema": { + "type": "object", + "properties": { + "tool": { "type": "string", "enum": ["api-client", "mock-server", "redis-client", "kafka-explorer", "rabbit-client", "container-manager", "base64", "jwt", "json"] }, + "enabled": { "type": "boolean" } + }, + "required": ["tool", "enabled"] + } + }, +]; diff --git a/src/components/mcpUtilityTools.ts b/src/components/mcpUtilityTools.ts new file mode 100644 index 00000000..3c11851f --- /dev/null +++ b/src/components/mcpUtilityTools.ts @@ -0,0 +1,144 @@ +// MCP tool catalogues for McpUtilityBridge.tsx — split by which per-tool +// toggle gates each (base64/jwt/json in useMcpToolEnabled.ts), since that +// bridge registers only the currently-enabled subset (see its own +// registration effect). Kept in sync BY HAND with buildCodecHandlers/ +// buildJwtHandlers/buildJsonHandlers in ./tools/*McpBridge.ts. +export interface McpToolDef { + name: string; + description: string; + inputSchema: Record; +} + +export const CODEC_MCP_TOOLS: McpToolDef[] = [ + { + "name": "codec_encode", + "description": "Encode text with a codec. `algorithm` is one of: base64, base62, rot13, url, html, quoted-printable, huffman, rle, morse, punycode, hex, octal, binary, decimal.", + "inputSchema": { + "type": "object", + "properties": { + "text": { "type": "string" }, + "algorithm": { "type": "string", "enum": ["base64", "base62", "rot13", "url", "html", "quoted-printable", "huffman", "rle", "morse", "punycode", "hex", "octal", "binary", "decimal"] } + }, + "required": ["text", "algorithm"] + } + }, + { + "name": "codec_decode", + "description": "Decode text with a codec — same `algorithm` list as codec_encode.", + "inputSchema": { + "type": "object", + "properties": { + "text": { "type": "string" }, + "algorithm": { "type": "string", "enum": ["base64", "base62", "rot13", "url", "html", "quoted-printable", "huffman", "rle", "morse", "punycode", "hex", "octal", "binary", "decimal"] } + }, + "required": ["text", "algorithm"] + } + }, + { + "name": "hash_compute", + "description": "Hash text. Omit `algorithm` to get every algorithm at once (md5, ripemd160, sha1, sha224, sha256, sha384, sha512, sha3-256, sha3-512), as { algorithm: hash }; pass one to get just that hash as { algorithm, hash }.", + "inputSchema": { + "type": "object", + "properties": { + "text": { "type": "string" }, + "algorithm": { "type": "string", "enum": ["md5", "ripemd160", "sha1", "sha224", "sha256", "sha384", "sha512", "sha3-256", "sha3-512"] }, + "upperHex": { "type": "boolean" } + }, + "required": ["text"] + } + }, + { + "name": "hash_hmac", + "description": "Compute an HMAC. `algorithm` is one of: md5, ripemd160, sha1, sha224, sha256, sha384, sha512 (SHA-3 has no dedicated per-length HMAC function, so it's excluded here, same as the UI).", + "inputSchema": { + "type": "object", + "properties": { + "text": { "type": "string" }, + "key": { "type": "string" }, + "algorithm": { "type": "string", "enum": ["md5", "ripemd160", "sha1", "sha224", "sha256", "sha384", "sha512"] }, + "upperHex": { "type": "boolean" } + }, + "required": ["text", "key", "algorithm"] + } + }, + { + "name": "encrypt_text", + "description": "Encrypt text with a passphrase. `algorithm`: \"aes-gcm\" is recommended (PBKDF2-SHA256 600k-round key stretching, authenticated, random salt+IV) — the rest (aes-cbc/ctr/ecb/cfb/ofb, tripledes, rabbit) exist for crypto-js interop only (weak key stretching, unauthenticated; aes-ecb also leaks plaintext structure). `key` is supplied by the caller — never read from the app's own saved passphrase.", + "inputSchema": { + "type": "object", + "properties": { + "text": { "type": "string" }, + "key": { "type": "string" }, + "algorithm": { "type": "string", "enum": ["aes-gcm", "aes-cbc", "aes-ctr", "aes-ecb", "aes-cfb", "aes-ofb", "tripledes", "rabbit"] } + }, + "required": ["text", "key", "algorithm"] + } + }, + { + "name": "decrypt_text", + "description": "Decrypt text encrypted with encrypt_text (or, for the crypto-js algorithms, anything crypto-js itself produced). `algorithm` must match what encrypted it.", + "inputSchema": { + "type": "object", + "properties": { + "ciphertext": { "type": "string" }, + "key": { "type": "string" }, + "algorithm": { "type": "string", "enum": ["aes-gcm", "aes-cbc", "aes-ctr", "aes-ecb", "aes-cfb", "aes-ofb", "tripledes", "rabbit"] } + }, + "required": ["ciphertext", "key", "algorithm"] + } + }, +]; + +export const JWT_MCP_TOOLS: McpToolDef[] = [ + // ── JWT Debugger — stateless, same McpUtilityBridge.tsx as above, + // gated by the `jwt` tool id. + { + "name": "jwt_decode", + "description": "Decode a JWT's header and payload. Decode-only — does NOT verify the signature (no verification key available).", + "inputSchema": { "type": "object", "properties": { "token": { "type": "string" } }, "required": ["token"] } + }, +]; + +export const JSON_MCP_TOOLS: McpToolDef[] = [ + // ── JSON Formatter — stateless, same McpUtilityBridge.tsx as above, + // gated by the `json` tool id. All four accept lenient input: single + // quotes, unquoted object keys, trailing commas, // and /* */ + // comments, and a JSON-string-literal wrapper — same parser the UI + // itself uses. + { + "name": "json_format", + "description": "Pretty-print JSON (or JSON-ish input — see tool description). `indent` is \"2\" (default), \"4\", or \"tab\"; `quote` is `\"` (default) or `'`.", + "inputSchema": { + "type": "object", + "properties": { + "text": { "type": "string" }, + "indent": { "type": "string", "enum": ["2", "4", "tab"] }, + "quote": { "type": "string", "enum": ["\"", "'"] } + }, + "required": ["text"] + } + }, + { + "name": "json_minify", + "description": "Minify JSON (or JSON-ish input) to a single line, no whitespace.", + "inputSchema": { + "type": "object", + "properties": { "text": { "type": "string" }, "quote": { "type": "string", "enum": ["\"", "'"] } }, + "required": ["text"] + } + }, + { + "name": "json_to_string", + "description": "Minify JSON, then re-encode the result as a single escaped string literal — for embedding a JSON payload inside another string (a shell command, a source file, …).", + "inputSchema": { + "type": "object", + "properties": { "text": { "type": "string" }, "quote": { "type": "string", "enum": ["\"", "'"] } }, + "required": ["text"] + } + }, + { + "name": "json_validate", + "description": "Check whether input parses (leniently — see tool description). Returns { valid: true } or { valid: false, error }.", + "inputSchema": { "type": "object", "properties": { "text": { "type": "string" } }, "required": ["text"] } + }, +]; diff --git a/src/components/tools/NetworkTools.tsx b/src/components/tools/NetworkTools.tsx index dc717565..b0fadfdf 100644 --- a/src/components/tools/NetworkTools.tsx +++ b/src/components/tools/NetworkTools.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react'; +import { useCallback, useEffect, useMemo, useState, type Dispatch, type SetStateAction } from 'react'; import { Spinner } from '@/components/ui/spinner'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; @@ -986,20 +986,29 @@ function PortsView() { (e.command ?? '').toLowerCase().includes(q) || (e.pid != null && String(e.pid).includes(q)); - const scanned = (entries ?? []).filter(matchesFilter); + // Filtering/grouping/sorting below used to run inline in the render body — + // recomputed on EVERY render, including ones triggered by unrelated state + // (typing in the "add favorite" input, e.g.) even though `entries`/`filter`/ + // `favorites`/`sortCol`/`sortDir` hadn't changed. Memoized so each stage only + // redoes work when its own inputs actually change. + const scanned = useMemo(() => (entries ?? []).filter(matchesFilter), [entries, q]); // Favourites mode is a port-centric watchlist: list every favourite port with // its live socket(s), or a synthetic "free" row when nothing is bound to it. - const favRows: PortRow[] = favorites - .filter((port) => !q || String(port).includes(q)) - .flatMap((port): PortRow[] => { - const matches = scanned.filter((e) => e.localPort === port); - return matches.length ? matches.map((entry) => ({ kind: 'socket', entry })) : [{ kind: 'free', port }]; - }); + const favRows: PortRow[] = useMemo( + () => + favorites + .filter((port) => !q || String(port).includes(q)) + .flatMap((port): PortRow[] => { + const matches = scanned.filter((e) => e.localPort === port); + return matches.length ? matches.map((entry) => ({ kind: 'socket', entry })) : [{ kind: 'free', port }]; + }), + [favorites, q, scanned] + ); // Default mode groups sockets by owning process (one row per process), so a // server listening on several ports shows once with all its ports as chips. - const groups: ProcGroup[] = (() => { + const groups: ProcGroup[] = useMemo(() => { const map = new Map(); for (const e of scanned) { const key = e.pid != null @@ -1024,7 +1033,7 @@ function PortsView() { const arr = [...map.values()]; for (const g of arr) g.ports.sort((a, b) => a.port - b.port || a.protocol.localeCompare(b.protocol)); return arr; - })(); + }, [scanned]); // Sort by the active column for both views. Missing numeric values (e.g. a // socket with no resolvable process) always sink to the bottom regardless of @@ -1038,30 +1047,40 @@ function PortsView() { }; const cmpStr = (a: string, b: string) => a.toLowerCase().localeCompare(b.toLowerCase()) * dir; - const sortedGroups = [...groups].sort((a, b) => { - switch (sortCol) { - case 'pid': return cmpNum(a.pid, b.pid); - case 'mem': return cmpNum(a.memBytes, b.memBytes); - case 'uptime': return cmpNum(a.uptimeSecs, b.uptimeSecs); - case 'process': return cmpStr(a.project ?? a.processName ?? '', b.project ?? b.processName ?? ''); - case 'port': - default: return ((a.ports[0]?.port ?? 0) - (b.ports[0]?.port ?? 0)) * dir; - } - }); + const sortedGroups = useMemo( + () => + [...groups].sort((a, b) => { + switch (sortCol) { + case 'pid': return cmpNum(a.pid, b.pid); + case 'mem': return cmpNum(a.memBytes, b.memBytes); + case 'uptime': return cmpNum(a.uptimeSecs, b.uptimeSecs); + case 'process': return cmpStr(a.project ?? a.processName ?? '', b.project ?? b.processName ?? ''); + case 'port': + default: return ((a.ports[0]?.port ?? 0) - (b.ports[0]?.port ?? 0)) * dir; + } + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [groups, sortCol, dir] + ); // Flat view: one row per socket, sorted by the same active column. - const sortedSockets = [...scanned].sort((a, b) => { - switch (sortCol) { - case 'pid': return cmpNum(a.pid, b.pid); - case 'mem': return cmpNum(a.memBytes, b.memBytes); - case 'uptime': return cmpNum(a.uptimeSecs, b.uptimeSecs); - case 'process': return cmpStr(a.processName ?? '', b.processName ?? ''); - case 'proto': return cmpStr(a.protocol, b.protocol) || (a.localPort - b.localPort) * dir; - case 'address': return cmpStr(a.localAddress, b.localAddress) || (a.localPort - b.localPort) * dir; - case 'port': - default: return (a.localPort - b.localPort) * dir; - } - }); + const sortedSockets = useMemo( + () => + [...scanned].sort((a, b) => { + switch (sortCol) { + case 'pid': return cmpNum(a.pid, b.pid); + case 'mem': return cmpNum(a.memBytes, b.memBytes); + case 'uptime': return cmpNum(a.uptimeSecs, b.uptimeSecs); + case 'process': return cmpStr(a.processName ?? '', b.processName ?? ''); + case 'proto': return cmpStr(a.protocol, b.protocol) || (a.localPort - b.localPort) * dir; + case 'address': return cmpStr(a.localAddress, b.localAddress) || (a.localPort - b.localPort) * dir; + case 'port': + default: return (a.localPort - b.localPort) * dir; + } + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [scanned, sortCol, dir] + ); // Numeric columns default to descending (biggest/longest first) on first click. const DEFAULT_DIR: Record = { diff --git a/src/components/tools/apiclient/mcpBridge.ts b/src/components/tools/apiclient/mcpBridge.ts index 396b0767..a1683203 100644 --- a/src/components/tools/apiclient/mcpBridge.ts +++ b/src/components/tools/apiclient/mcpBridge.ts @@ -28,6 +28,7 @@ import { usePluginSdkFor } from '@/platform'; import { isTauri } from '@/lib/platform'; import type { ApiStore } from './store'; import type { ApiRequest, Auth, Environment, KeyValue, LogEntry, RequestBody, RequestScript, RequestSettings, TreeItem } from './types'; +import { API_CLIENT_MCP_TOOLS } from './mcpTools'; import { newAuth, newEnvironment, newRequest, uid } from './types'; import type { ExecResult } from './engine'; @@ -496,6 +497,11 @@ export function useMcpBridge(store: ApiStore, runRequest: RunRequestFn, enabled let unlisten: (() => void) | null = null; (async () => { + // Bundled with THIS tool, not the platform — devtool-mcp-server.rs has + // no compiled-in tool list of its own (see that file), so registering + // here is what makes its tools show up in `list_tools` at all. Same + // 'native' + allowlist gate as `mcp_respond` below. + await sdk.native.invoke('mcp_register_tools', { pluginId: sdk.id, tools: API_CLIENT_MCP_TOOLS }); // Qua SDK: sự kiện `mcp:call` và lệnh `mcp_respond` đều nằm trong quyền // 'native' + allowlist của plugin, nên cầu nối này cũng hiện trong nhật ký // như mọi lời gọi khác thay vì là một đường đi vòng. @@ -521,6 +527,10 @@ export function useMcpBridge(store: ApiStore, runRequest: RunRequestFn, enabled return () => { cancelled = true; unlisten?.(); + // Best-effort — this tool's tools stop answering the moment + // `mcp:call` is unlistened anyway; unregistering just keeps + // `list_tools` honest about what will actually answer right now. + sdk.native.invoke('mcp_unregister_tools', { pluginId: sdk.id }).catch(() => {}); }; }, [enabled, sdk]); } diff --git a/src/components/tools/apiclient/mcpTools.ts b/src/components/tools/apiclient/mcpTools.ts new file mode 100644 index 00000000..2567f4a0 --- /dev/null +++ b/src/components/tools/apiclient/mcpTools.ts @@ -0,0 +1,267 @@ +// MCP tool catalogue for this plugin — bundled with it, not with the +// platform. Registered once (mcp_register_tools) when this bridge mounts +// its `mcp:call` listener, so devtool-mcp-server.rs's dynamic +// `list_tools()` (no compiled-in tool list of its own) can advertise these +// to an MCP client. Kept in sync BY HAND with `buildHandlers()` in +// ./mcpBridge.ts. +export interface McpToolDef { + name: string; + description: string; + inputSchema: Record; +} + +const whereSchema = { type: 'string', enum: ['before', 'after', 'inside'], default: 'inside' }; + +function kvArraySchema() { + return { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + key: { type: 'string' }, + value: { type: 'string' }, + enabled: { type: 'boolean' }, + }, + required: ['id', 'key', 'value', 'enabled'], + }, + }; +} + +function nullableString() { + return { type: ['string', 'null'] }; +} + +export const API_CLIENT_MCP_TOOLS: McpToolDef[] = [ + { + "name": "list_collections", + "description": "List every collection open in DevTool's API Client, with their folder/request tree (id, name, method, url — no bodies/scripts/secrets).", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "get_collection", + "description": "Get one collection by id, including its own script/auth/headers/variables (not its requests' bodies — use get_request for those).", + "inputSchema": { "type": "object", "properties": { "collectionId": { "type": "string" } }, "required": ["collectionId"] } + }, + { + "name": "get_request", + "description": "Get the full definition of one request by id: method, url, params, headers, body, auth, pre/post-request script, tests, assertions, settings.", + "inputSchema": { "type": "object", "properties": { "requestId": { "type": "string" } }, "required": ["requestId"] } + }, + { + "name": "update_request", + "description": "Patch a request in place. `patch` is a partial ApiRequest — only included top-level fields change (e.g. { \"url\", \"method\" }). For the nested object fields — script ({req, res}, auth, body, settings — you may pass either the full object or just the part you're changing: patch.script = { \"req\": \"...\" } alone updates only the pre-request script and leaves the post-response script (res) untouched, same for auth.apiKey/.oauth2 and body.graphql. Array fields (params/headers/pathParams/assertions) still fully replace — pass every row you want kept.", + "inputSchema": { + "type": "object", + "properties": { "requestId": { "type": "string" }, "patch": { "type": "object" } }, + "required": ["requestId", "patch"] + } + }, + { + "name": "create_request", + "description": "Create a new request in a collection (optionally inside a folder) and return it. `request` is a partial ApiRequest used as the initial values.", + "inputSchema": { + "type": "object", + "properties": { "collectionId": { "type": "string" }, "folderId": { "type": "string" }, "request": { "type": "object" } }, + "required": ["collectionId"] + } + }, + { + "name": "run_request", + "description": "Send a request through DevTool (same engine as Send): pre-request script → send → post-response script → tests/assertions → History. Returns response (body capped), tests, logs, and any error. Pass environmentId to override the active environment (null = \"No Environment\").", + "inputSchema": { + "type": "object", + "properties": { "requestId": { "type": "string" }, "environmentId": nullableString() }, + "required": ["requestId"] + } + }, + { + "name": "add_folder", + "description": "Create a folder in a collection (optionally nested inside another folder) and return its id.", + "inputSchema": { + "type": "object", + "properties": { "collectionId": { "type": "string" }, "name": { "type": "string" }, "parentId": { "type": "string" } }, + "required": ["collectionId"] + } + }, + { + "name": "rename_item", + "description": "Rename a request or folder by id.", + "inputSchema": { "type": "object", "properties": { "itemId": { "type": "string" }, "name": { "type": "string" } }, "required": ["itemId", "name"] } + }, + { + "name": "delete_item", + "description": "Delete a request or a folder (and everything inside it) by id.", + "inputSchema": { "type": "object", "properties": { "itemId": { "type": "string" } }, "required": ["itemId"] } + }, + { + "name": "clone_item", + "description": "Duplicate a request or folder as a new sibling right after it.", + "inputSchema": { "type": "object", "properties": { "itemId": { "type": "string" } }, "required": ["itemId"] } + }, + { + "name": "move_item", + "description": "Move (cut) a request or folder to a new spot. `targetId` may be a collection id (moves to its root), or a request/folder id combined with `where`: \"before\"/\"after\" that sibling, or \"inside\" it (folders only).", + "inputSchema": { + "type": "object", + "properties": { "sourceId": { "type": "string" }, "targetId": { "type": "string" }, "where": whereSchema }, + "required": ["sourceId", "targetId"] + } + }, + { + "name": "copy_item", + "description": "Copy (not cut) a request or folder to a new spot — same targeting as move_item, but the source is left in place.", + "inputSchema": { + "type": "object", + "properties": { "sourceId": { "type": "string" }, "targetId": { "type": "string" }, "where": whereSchema }, + "required": ["sourceId", "targetId"] + } + }, + { + "name": "add_collection", + "description": "Create a new, empty collection and return its id.", + "inputSchema": { "type": "object", "properties": { "name": { "type": "string" } } } + }, + { + "name": "rename_collection", + "description": "Rename a collection by id.", + "inputSchema": { "type": "object", "properties": { "collectionId": { "type": "string" }, "name": { "type": "string" } }, "required": ["collectionId", "name"] } + }, + { + "name": "delete_collection", + "description": "Delete a collection (and everything inside it) by id. Also drops any environments scoped to it.", + "inputSchema": { "type": "object", "properties": { "collectionId": { "type": "string" } }, "required": ["collectionId"] } + }, + { + "name": "clone_collection", + "description": "Duplicate a whole collection (deep copy, fresh ids for everything inside) right after the original.", + "inputSchema": { "type": "object", "properties": { "collectionId": { "type": "string" } }, "required": ["collectionId"] } + }, + { + "name": "set_collection_variables", + "description": "Replace a collection's Collection Variables (shared defaults available to every request in it, regardless of active environment). Pass the full array you want it to end up with.", + "inputSchema": { + "type": "object", + "properties": { "collectionId": { "type": "string" }, "variables": kvArraySchema() }, + "required": ["collectionId", "variables"] + } + }, + { + "name": "set_node_script", + "description": "Set the pre/post-request script inherited by every request under a collection/folder. nodeId=null (or omitted) = collection root; a folder id = that folder. `script` may be the full { req, res } object or just one of them — e.g. { \"req\": \"...\" } alone updates only the pre-request script and leaves the existing post-response script (res) untouched. A request's own script is set via update_request's patch.script instead.", + "inputSchema": { + "type": "object", + "properties": { + "collectionId": { "type": "string" }, + "nodeId": nullableString(), + "script": { + "type": "object", + "properties": { "req": { "type": "string" }, "res": { "type": "string" } } + } + }, + "required": ["collectionId", "script"] + } + }, + { + "name": "set_node_auth", + "description": "Set the auth inherited by requests with auth.type=\"inherit\" under a collection/folder. nodeId=null (or omitted) = collection root; a folder id = that folder. `auth` may be the full Auth object or just the fields you're changing (including a nested partial apiKey/oauth2) — merges onto the node's existing auth rather than replacing it outright. A request's own auth is set via update_request's patch.auth instead.", + "inputSchema": { + "type": "object", + "properties": { "collectionId": { "type": "string" }, "nodeId": nullableString(), "auth": { "type": "object" } }, + "required": ["collectionId", "auth"] + } + }, + { + "name": "set_node_headers", + "description": "Set the headers added to every request under a collection/folder (a request's own header of the same name overrides it). nodeId=null (or omitted) = collection root; a folder id = that folder. A request's own headers are set via update_request's patch.headers instead.", + "inputSchema": { + "type": "object", + "properties": { "collectionId": { "type": "string" }, "nodeId": nullableString(), "headers": kvArraySchema() }, + "required": ["collectionId", "headers"] + } + }, + { + "name": "list_environments", + "description": "List every environment (global and collection-scoped) with id, name, and owning collectionId (null = global).", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "get_environment", + "description": "Get one environment by id, including its variables (values are returned as stored — a variable marked secret is not masked here, unlike the UI's quick-view).", + "inputSchema": { "type": "object", "properties": { "environmentId": { "type": "string" } }, "required": ["environmentId"] } + }, + { + "name": "update_environment", + "description": "Patch an environment. `patch` is a partial Environment object — { \"variables\": [...] } replaces the ENTIRE variables array (pass every row you want kept, not just changed ones), { \"name\": ... } renames it, { \"collectionId\": ... } moves it between global (null) and a collection's scope. To add/edit/remove one or a few variables without resending the rest, use set_environment_variable / delete_environment_variable instead.", + "inputSchema": { + "type": "object", + "properties": { "environmentId": { "type": "string" }, "patch": { "type": "object" } }, + "required": ["environmentId", "patch"] + } + }, + { + "name": "set_environment_variable", + "description": "Add or update ONE variable in an environment by key, leaving every other variable untouched — cheaper and safer than update_environment when only a few variables need to change. Creates the variable (enabled by default) if no row with that key exists yet; otherwise patches only the fields you pass (value/enabled/secret). Returns the resulting variable row.", + "inputSchema": { + "type": "object", + "properties": { + "environmentId": { "type": "string" }, + "key": { "type": "string" }, + "value": { "type": "string" }, + "enabled": { "type": "boolean" }, + "secret": { "type": "boolean", "description": "Masks the value in the editor and excludes it from generated code/cURL export/history, like the Vault." } + }, + "required": ["environmentId", "key"] + } + }, + { + "name": "delete_environment_variable", + "description": "Remove one variable from an environment by key, leaving every other variable untouched. No-op (deleted:false) if the key isn't present.", + "inputSchema": { + "type": "object", + "properties": { "environmentId": { "type": "string" }, "key": { "type": "string" } }, + "required": ["environmentId", "key"] + } + }, + { + "name": "set_active_environment", + "description": "Activate an environment. scope=\"global\" sets the active Global env; scope=\"collection\" (default) sets it for one collection (pass collectionId). environmentId=null clears it (\"No Environment\").", + "inputSchema": { + "type": "object", + "properties": { + "scope": { "type": "string", "enum": ["global", "collection"], "default": "collection" }, + "collectionId": { "type": "string" }, + "environmentId": nullableString() + }, + "required": ["environmentId"] + } + }, + { + "name": "add_environment", + "description": "Create a new environment and return its id. Omit collectionId for a global environment (available everywhere); pass one to scope it to that collection (Bruno-style — only available while working inside that collection). A collection can have any number of scoped environments (e.g. \"Local\"/\"Staging\"/\"Prod\"), same as the global list.", + "inputSchema": { + "type": "object", + "properties": { "collectionId": { "type": "string" }, "name": { "type": "string" }, "variables": kvArraySchema() } + } + }, + { + "name": "duplicate_environment", + "description": "Clone an environment (same scope, \" copy\", fresh ids for every variable row) and return the new id.", + "inputSchema": { "type": "object", "properties": { "environmentId": { "type": "string" } }, "required": ["environmentId"] } + }, + { + "name": "delete_environment", + "description": "Delete an environment by id. Clears it from wherever it was the active choice.", + "inputSchema": { "type": "object", "properties": { "environmentId": { "type": "string" } }, "required": ["environmentId"] } + }, + { + "name": "import_environment", + "description": "Create an environment with a name, scope, and full variable set in one call (e.g. importing one from another tool). Returns the new id.", + "inputSchema": { + "type": "object", + "properties": { "name": { "type": "string" }, "collectionId": nullableString(), "variables": kvArraySchema() }, + "required": ["name"] + } + }, + +]; diff --git a/src/components/tools/container/ComposeView.tsx b/src/components/tools/container/ComposeView.tsx deleted file mode 100644 index 4e6358f7..00000000 --- a/src/components/tools/container/ComposeView.tsx +++ /dev/null @@ -1,382 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { FileStack, RefreshCw, Play, Square, RotateCw, Trash2, FileText, PowerOff, Info, Cpu, ChevronDown } from 'lucide-react'; -import { Button, buttonVariants } from '@/components/ui/button'; -import { ViewHeader } from '@/components/ui/view-header'; -import { Callout } from '@/components/ui/callout'; -import { LoadingRow } from '@/components/ui/spinner'; -import { DataTable, Thead, Tbody, Tr, Th, Td, type SortDirection } from '@/components/ui/data-table'; -import { Badge, type BadgeTone } from '@/components/ui/badge'; -import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; -import { IconButton } from '@/components/ui/icon-button'; -import { groupByComposeProject, COMPOSE_LABELS, type ContainerConnection, type ContainerSummary, type ComposeProjectGroup } from './types'; -import { useContainerApi } from './api_sdk'; -import { cn } from '@/lib/utils'; -import { ContainerLogsDialog } from './ContainerLogsDialog'; -import { ContainerDetailsDialog } from './ContainerDetailsDialog'; -import { ContainerResourcesDialog } from './ContainerResourcesDialog'; - -/** Same three the Networks view refuses to remove — compose never creates - * them, so a project's teardown must never touch them either. */ -const BUILTIN_NETWORKS = new Set(['bridge', 'host', 'none']); - -function stateTone(state?: string): BadgeTone { - switch (state) { - case 'running': return 'success'; - case 'paused': return 'warning'; - case 'exited': - case 'dead': return 'danger'; - default: return 'neutral'; - } -} - -function containerName(c: ContainerSummary): string { - return c.Names?.[0]?.replace(/^\//, '') ?? c.Id.slice(0, 12); -} - -/** - * Compose projects are not a Docker Engine API object — there is no daemon - * endpoint for "list compose projects", so this view is deliberately - * grouping-only: it groups the containers container_list already returns by - * the com.docker.compose.project label docker compose stamps on them (the - * same technique tools like OrbStack use for their compose grouping), and - * every action (start/stop/restart/remove/logs) reuses the plain per- - * container bollard commands from devtool-svc-container.rs. - * - * The project-level actions mirror the compose CLI verbs that need no YAML: - * Start / Stop / Restart map onto every container in the group, and "Down" - * reproduces `docker compose down` — stop and force-remove the containers, - * then remove the networks carrying this project's label — with the optional - * `-v` variant additionally removing the project's named volumes. Since a - * real `down` removes the containers, the project stops being listed here - * afterwards: this view is built purely out of container labels, so a project - * with no containers has nothing left to group. Use Stop when the project - * should stay listed and startable. - * - * What's intentionally missing: `up`/`build`/`pull` for a project. Those - * require parsing and orchestrating a compose YAML file client-side — logic - * the Docker Engine API has no equivalent for — so they are out of scope - * until that's built natively (see conversation/ADR for the shell-vs-native - * tradeoff). - */ -export function ComposeView({ connection, refreshKey, onRefresh }: { - connection: ContainerConnection; - refreshKey: number; - onRefresh: () => void; -}) { - const containerApi = useContainerApi(); - const [containers, setContainers] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [busyId, setBusyId] = useState(null); - const [busyProject, setBusyProject] = useState(null); - const [logsTarget, setLogsTarget] = useState(null); - const [detailsTarget, setDetailsTarget] = useState(null); - const [removeTarget, setRemoveTarget] = useState(null); - const [downTarget, setDownTarget] = useState<{ project: ComposeProjectGroup; removeVolumes: boolean } | null>(null); - // One entry for a single service, every service in the group for the - // project-level button — the resource dialog handles both shapes. - const [limitsTargets, setLimitsTargets] = useState<{ id: string; name: string }[] | null>(null); - const [sortKey, setSortKey] = useState<'service' | 'state' | 'status' | null>(null); - const [sortDir, setSortDir] = useState('asc'); - - const load = () => { - setLoading(true); - setError(null); - containerApi.list(connection, true) - .then(setContainers) - .catch((e) => { setContainers([]); setError(String(e instanceof Error ? e.message : e)); }) - .finally(() => setLoading(false)); - }; - useEffect(() => { load(); }, [connection, refreshKey]); // eslint-disable-line - - const projects = useMemo(() => groupByComposeProject(containers ?? []), [containers]); - - const runAction = async (id: string, action: () => Promise) => { - setBusyId(id); - setError(null); - try { - await action(); - load(); - } catch (e) { - setError(String(e instanceof Error ? e.message : e)); - } finally { - setBusyId(null); - } - }; - - /** Runs one per-container action across a whole project — the Start / Stop - * / Restart buttons in the project header. */ - const runProject = async (project: ComposeProjectGroup, action: (c: ContainerSummary) => Promise) => { - setBusyProject(project.name); - setError(null); - const results = await Promise.allSettled(project.containers.map(action)); - const failed = results.filter((r) => r.status === 'rejected') as PromiseRejectedResult[]; - if (failed.length > 0) { - const first = failed[0].reason; - setError(`${failed.length} of ${project.containers.length} failed — ${String(first instanceof Error ? first.message : first)}`); - } - setBusyProject(null); - load(); - }; - - /** - * `docker compose down` without the YAML: stop and force-remove every - * container in the project, then remove the networks compose created for it - * (matched by the same project label the grouping uses — built-in networks - * are never touched). `removeVolumes` adds the `-v` behaviour, removing the - * project's named volumes and the data in them. - * - * Networks and volumes are only removed after the containers are gone, since - * the daemon refuses to remove either while something is still attached. - */ - const runDown = async (project: ComposeProjectGroup, removeVolumes: boolean) => { - setBusyProject(project.name); - setError(null); - const problems: string[] = []; - const note = (e: unknown) => problems.push(String(e instanceof Error ? e.message : e)); - - const containerResults = await Promise.allSettled(project.containers.map(async (c) => { - if (c.State === 'running' || c.State === 'paused') { - await containerApi.stop(connection, c.Id).catch(() => {}); - } - await containerApi.remove(connection, c.Id, true); - })); - containerResults.forEach((r) => { if (r.status === 'rejected') note(r.reason); }); - - // Only worth attempting once the containers are actually gone; otherwise - // every removal would fail with "network has active endpoints" anyway. - if (containerResults.every((r) => r.status === 'fulfilled')) { - try { - const networks = await containerApi.networkList(connection); - const projectNetworks = networks.filter( - (n) => n.Labels?.[COMPOSE_LABELS.project] === project.name && !BUILTIN_NETWORKS.has(n.Name), - ); - const netResults = await Promise.allSettled(projectNetworks.map((n) => containerApi.networkRemove(connection, n.Name))); - netResults.forEach((r) => { if (r.status === 'rejected') note(r.reason); }); - } catch (e) { - note(e); - } - - if (removeVolumes) { - try { - const volumes = await containerApi.volumeList(connection); - const projectVolumes = volumes.filter((v) => v.Labels?.[COMPOSE_LABELS.project] === project.name); - const volResults = await Promise.allSettled(projectVolumes.map((v) => containerApi.volumeRemove(connection, v.Name, true))); - volResults.forEach((r) => { if (r.status === 'rejected') note(r.reason); }); - } catch (e) { - note(e); - } - } - } - - if (problems.length > 0) { - setError(`Down finished with ${problems.length} error(s) — ${problems[0]}`); - } - setBusyProject(null); - load(); - }; - - const toggleSort = (key: 'service' | 'state' | 'status') => { - if (key === sortKey) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); - else { setSortKey(key); setSortDir('asc'); } - }; - const directionFor = (key: 'service' | 'state' | 'status'): SortDirection | null => (sortKey === key ? sortDir : null); - const sortValue = (c: ContainerSummary, key: 'service' | 'state' | 'status'): string => { - if (key === 'service') return c.Labels?.[COMPOSE_LABELS.service] ?? containerName(c); - if (key === 'state') return c.State ?? ''; - return c.Status ?? ''; - }; - const sortContainers = (list: ContainerSummary[]): ContainerSummary[] => { - if (!sortKey) return list; - const copy = [...list]; - copy.sort((a, b) => { - const cmp = sortValue(a, sortKey).localeCompare(sortValue(b, sortKey)); - return sortDir === 'asc' ? cmp : -cmp; - }); - return copy; - }; - - return ( -
- Refresh} - /> - -
- {loading && !containers && } - {error && {error}} - - {containers && !error && projects.length === 0 && ( -

- No containers with a com.docker.compose.project label found — - start a project with docker compose up from your terminal to see it here. -

- )} - - {projects.map((p) => ( -
-
-
-

{p.name}

- {p.workingDir &&

{p.workingDir}

} -
-
- runProject(p, (c) => containerApi.start(connection, c.Id))} - > - - - runProject(p, (c) => containerApi.stop(connection, c.Id))} - > - - - runProject(p, (c) => containerApi.restart(connection, c.Id))} - > - - - - - - Down - - - setDownTarget({ project: p, removeVolumes: false })}> - Down — remove containers + networks - - setDownTarget({ project: p, removeVolumes: true })}> - Down + remove volumes - - - -
-
- - - - toggleSort('service')}>Service - toggleSort('state')}>State - toggleSort('status')}>Status - - - - - {sortContainers(p.containers).map((c) => ( - setDetailsTarget(c)}> - {c.Labels?.[COMPOSE_LABELS.service] ?? containerName(c)} - {c.State ?? 'unknown'} - {c.Status} - e.stopPropagation()}> -
- setDetailsTarget(c)}> - - - {c.State === 'running' ? ( - runAction(c.Id, () => containerApi.stop(connection, c.Id))}> - - - ) : ( - runAction(c.Id, () => containerApi.start(connection, c.Id))}> - - - )} - runAction(c.Id, () => containerApi.restart(connection, c.Id))}> - - - setLogsTarget(c)}> - - - setLimitsTargets([{ id: c.Id, name: containerName(c) }])}> - - - setRemoveTarget(c)}> - - -
- - - ))} - -
-
- ))} -
- - { if (!o) setLogsTarget(null); }} - connection={connection} - container={logsTarget} - onAction={runAction} - onRemove={setRemoveTarget} - /> - - { if (!o) setRemoveTarget(null); }} - title="Remove container?" - description={removeTarget ? `Remove "${containerName(removeTarget)}". This cannot be undone.` : ''} - confirmLabel="Remove" - onConfirm={async () => { - if (!removeTarget) return; - await runAction(removeTarget.Id, () => containerApi.remove(connection, removeTarget.Id, true)); - setRemoveTarget(null); - }} - /> - - { if (!o) setDownTarget(null); }} - title={downTarget?.removeVolumes ? 'Down and remove volumes?' : 'Down this project?'} - description={downTarget - ? `Stop and remove all ${downTarget.project.containers.length} container(s) in "${downTarget.project.name}", ` - + `plus the networks compose created for it` - + `${downTarget.removeVolumes ? ', plus its named volumes and all data in them' : ''}. ` - + `The project stops being listed here once its containers are gone — use Stop instead to keep it listed. This cannot be undone.` - : ''} - confirmLabel={downTarget?.removeVolumes ? 'Down + volumes' : 'Down'} - onConfirm={async () => { - if (!downTarget) return; - await runDown(downTarget.project, downTarget.removeVolumes); - setDownTarget(null); - }} - /> - - { if (!o) setLimitsTargets(null); }} - connection={connection} - targets={limitsTargets ?? []} - onApplied={load} - /> - - { if (!o) setDetailsTarget(null); }} - connection={connection} - container={detailsTarget} - onEditLimits={(c) => { setDetailsTarget(null); setLimitsTargets([{ id: c.Id, name: containerName(c) }]); }} - /> -
- ); -} diff --git a/src/components/tools/container/ConnectionForm.tsx b/src/components/tools/container/ConnectionForm.tsx deleted file mode 100644 index ce61c2f1..00000000 --- a/src/components/tools/container/ConnectionForm.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { useEffect, useState } from 'react'; -import { ModalShell } from '@/components/ui/modal-shell'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { CheckCircle2, Search } from 'lucide-react'; -import { Spinner } from '@/components/ui/spinner'; -import { Callout } from '@/components/ui/callout'; -import { EMPTY_CONNECTION, type ContainerConnection, type DetectedSocket } from './types'; -import { useContainerApi } from './api_sdk'; - -interface ConnectionFormProps { - initial?: ContainerConnection | null; - onSave: (config: ContainerConnection) => Promise; - onCancel: () => void; -} - -export function ConnectionForm({ initial, onSave, onCancel }: ConnectionFormProps) { - const containerApi = useContainerApi(); - const [form, setForm] = useState(initial ?? EMPTY_CONNECTION); - const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(false); - const [tested, setTested] = useState<'ok' | null>(null); - const [error, setError] = useState(''); - const [detected, setDetected] = useState(null); - const [detecting, setDetecting] = useState(false); - - useEffect(() => { - setForm(initial ?? EMPTY_CONNECTION); - setError(''); - setTested(null); - }, [initial]); - - useEffect(() => { - if (initial) return; // only auto-detect for a brand new connection - setDetecting(true); - containerApi.detectSockets().then(setDetected).catch(() => setDetected([])).finally(() => setDetecting(false)); - }, [initial]); - - const set = (k: keyof ContainerConnection, v: string) => { - setForm((f) => ({ ...f, [k]: v })); - setTested(null); - }; - - const validate = (): string | null => { - if (!form.name.trim()) return 'Name is required'; - if (!form.socketPath.trim()) return 'Socket path is required'; - return null; - }; - - const handleTest = async () => { - const v = validate(); - if (v) { setError(v); return; } - setTesting(true); - setError(''); - setTested(null); - try { - await containerApi.testConnection(form); - setTested('ok'); - } catch (e) { - setError(String(e instanceof Error ? e.message : e)); - } finally { - setTesting(false); - } - }; - - const handleSubmit = async () => { - const v = validate(); - if (v) { setError(v); return; } - setSaving(true); - setError(''); - try { - await onSave(form); - } catch (e) { - setError(String(e)); - } finally { - setSaving(false); - } - }; - - return ( - - -
- - -
- - } - > -
-
- - set('name', e.target.value)} - placeholder="Colima (default)" - className="mt-1" - /> -
- -
- - set('socketPath', e.target.value)} - placeholder="/Users/me/.colima/default/docker.sock" - className="mt-1 font-mono text-sm" - /> -

- Unix socket path (or Windows named pipe, e.g. \\.\pipe\docker_engine) — not a host:port. -
- ~ expands to your home directory, e.g. ~/.colima/default/docker.sock. -

-
- - {!initial && ( -
-
- - {detecting ? 'Detecting sockets…' : detected && detected.length > 0 ? 'Detected on this machine' : 'No sockets detected'} -
- {detected && detected.length > 0 && ( -
- {detected.map((d) => ( - - ))} -
- )} -
- )} - - {error && {error}} - {tested === 'ok' && Connection successful} -
-
- ); -} diff --git a/src/components/tools/container/ContainerDetailsDialog.tsx b/src/components/tools/container/ContainerDetailsDialog.tsx deleted file mode 100644 index da1b362c..00000000 --- a/src/components/tools/container/ContainerDetailsDialog.tsx +++ /dev/null @@ -1,271 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { Cpu } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Callout } from '@/components/ui/callout'; -import { LoadingRow } from '@/components/ui/spinner'; -import { CollapsibleSection } from '@/components/ui/collapsible-section'; -import { Badge, type BadgeTone } from '@/components/ui/badge'; -import { DataTable, Thead, Tbody, Tr, Th, Td } from '@/components/ui/data-table'; -import { type ContainerConnection, type ContainerDetails, type ContainerResources, type ContainerStreamSubscription, type ContainerSummary, type StatsFrame } from './types'; -import { useContainerApi } from './api_sdk'; -import { DetailField, DetailGrid, KeyValueTable, envEntries, labelEntries } from './DetailRows'; -import { formatBytes } from './format'; - -function stateTone(state?: string): BadgeTone { - switch (state) { - case 'running': return 'success'; - case 'paused': return 'warning'; - case 'exited': - case 'dead': return 'danger'; - default: return 'neutral'; - } -} - -function healthTone(status?: string): BadgeTone { - switch (status) { - case 'healthy': return 'success'; - case 'unhealthy': return 'danger'; - case 'starting': return 'warning'; - default: return 'neutral'; - } -} - -export function ContainerDetailsDialog({ open, onOpenChange, connection, container, onEditLimits }: { - open: boolean; - onOpenChange: (o: boolean) => void; - connection: ContainerConnection; - container: ContainerSummary | null; - /** Opens the resource editor for this container — the dialog only renders - * the "Edit limits" button when the parent view can handle it. */ - onEditLimits?: (container: ContainerSummary) => void; -}) { - const containerApi = useContainerApi(); - const [details, setDetails] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (!open || !container) { setDetails(null); return; } - setLoading(true); - setError(null); - containerApi.details(connection, container.Id) - .then(setDetails) - .catch((e) => setError(String(e instanceof Error ? e.message : e))) - .finally(() => setLoading(false)); - }, [open, container, connection]); - - const title = details?.name || container?.Names?.[0]?.replace(/^\//, '') || container?.Id.slice(0, 12) || ''; - - return ( - - - - {title} - -
- {loading && } - {error && {error}} - {details && !loading && ( - <> - - - {details.status ?? 'unknown'} - {details.healthStatus && details.healthStatus !== 'none' && ( - {details.healthStatus} - )} - - )} /> - - - - - {details.running ? ( - - ) : ( - <> - - - - )} - - - - {(details.entrypoint.length > 0 || details.command) && ( - - {details.entrypoint.length > 0 && ( - - )} - {details.command && ( - - )} - - )} - - {details.running && ( - - - - )} - - -
- - - - - - - - 0 ? details.resources.pidsLimit : 'unlimited'} /> - - - {onEditLimits && container && ( - - )} -
-
- - {details.ports.length > 0 && ( - - - ContainerHost - - {details.ports.map((p, i) => ( - - {p.containerPort} - {p.hostIp && p.hostPort ? `${p.hostIp}:${p.hostPort}` : p.hostPort || '—'} - - ))} - - - - )} - - {details.networks.length > 0 && ( - - - NameIP addressGatewayMAC - - {details.networks.map((n) => ( - - {n.name} - {n.ipAddress || '—'} - {n.gateway || '—'} - {n.macAddress || '—'} - - ))} - - - - )} - - {details.mounts.length > 0 && ( - - - TypeSourceDestinationMode - - {details.mounts.map((m, i) => ( - - {m.type || '—'} - {m.name || m.source || '—'} - {m.destination || '—'} - {m.rw === false ? 'ro' : m.mode || 'rw'} - - ))} - - - - )} - - - - - - - - - - )} -
-
-
- ); -} - -/** One-line summary of what is actually capped, for the section header. */ -function describeLimits(r: ContainerResources): string { - const parts: string[] = []; - if (r.nanoCpus) parts.push(`${Number((r.nanoCpus / 1_000_000_000).toFixed(3))} CPU`); - if (r.memoryBytes) parts.push(formatBytes(r.memoryBytes)); - return parts.length > 0 ? parts.join(' · ') : 'unlimited'; -} - -/** Live CPU / memory / network for a running container, over the streaming - * stats endpoint (one open stream for the one container on screen — the - * table's columns use the cheaper polled snapshot instead). */ -function LiveStats({ connection, containerId }: { connection: ContainerConnection; containerId: string }) { - const containerApi = useContainerApi(); - const [frame, setFrame] = useState(null); - const [error, setError] = useState(null); - const subscriptionRef = useRef(null); - - useEffect(() => { - let cancelled = false; - // Đăng ký qua SDK nên phải chờ — effect vì thế bọc trong một IIFE async. - // `cancelled` vẫn là chốt duy nhất quyết định việc dọn dẹp, y như trước. - void (async () => { - try { - const sub = await containerApi.statsStart(connection, containerId, (f) => { if (!cancelled) setFrame(f); }); - // Unmounted while the stream was starting — stop it right away rather - // than leaving an orphaned task running against the daemon. - if (cancelled) { void sub.stop(); return; } - subscriptionRef.current = sub; - } catch (e) { - if (!cancelled) setError(String(e instanceof Error ? e.message : e)); - } - })(); - return () => { - cancelled = true; - const sub = subscriptionRef.current; - subscriptionRef.current = null; - if (sub) void sub.stop(); - }; - }, [connection, containerId, containerApi]); - - if (error) return {error}; - if (!frame) return

Waiting for the first sample…

; - - const memPct = frame.memLimitBytes > 0 ? (frame.memUsageBytes / frame.memLimitBytes) * 100 : 0; - return ( - - - 0 ? ` / ${formatBytes(frame.memLimitBytes)} (${memPct.toFixed(0)}%)` : ''}`} - mono - /> - - - ); -} diff --git a/src/components/tools/container/ContainerLogsDialog.tsx b/src/components/tools/container/ContainerLogsDialog.tsx deleted file mode 100644 index b4cf8880..00000000 --- a/src/components/tools/container/ContainerLogsDialog.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Play, Square, RotateCw, Pause, PlayCircle, Trash2 } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Badge, type BadgeTone } from '@/components/ui/badge'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { type ContainerConnection, type ContainerSummary } from './types'; -import { useContainerApi } from './api_sdk'; -import { LogsPanel } from './LogsPanel'; - -function stateTone(state?: string): BadgeTone { - switch (state) { - case 'running': return 'success'; - case 'paused': return 'warning'; - case 'exited': - case 'dead': return 'danger'; - default: return 'neutral'; - } -} - -function containerName(c: ContainerSummary): string { - return c.Names?.[0]?.replace(/^\//, '') ?? c.Id.slice(0, 12); -} - -/** - * The log window, shared by the containers list and the compose view — both - * had their own copy of this dialog, which is how compose ended up without the - * lifecycle buttons the containers list had. - * - * Sized deliberately larger than the app's other dialogs: log reading is the - * one task here where more visible lines is the whole point, and the previous - * `max-w-3xl` × `50vh` showed about twenty of them. - */ -export function ContainerLogsDialog({ open, onOpenChange, connection, container, onAction, onRemove }: { - open: boolean; - onOpenChange: (o: boolean) => void; - connection: ContainerConnection; - container: ContainerSummary | null; - /** Runs a lifecycle action and refreshes the owning list. */ - onAction: (id: string, action: () => Promise) => void | Promise; - /** Opens the owning view's remove confirmation. Omit to hide the button. */ - onRemove?: (container: ContainerSummary) => void; -}) { - const containerApi = useContainerApi(); - // Kept one render behind `container` so the Dialog can stay mounted and play - // its exit animation instead of the subtree vanishing the instant the target - // clears — the same trick the details dialog uses. - const [shown, setShown] = useState(container); - useEffect(() => { if (container) setShown(container); }, [container]); - - const target = shown; - const running = target?.State === 'running'; - - return ( - - - {target && ( - <> - - - {containerName(target)} - {target.State ?? 'unknown'} - {target.Image} - - -
-
- {running ? ( - - ) : ( - - )} - - {running ? ( - - ) : target.State === 'paused' ? ( - - ) : null} - {onRemove && ( - - )} -
- {/* Tall by design, but never taller than the window minus the - dialog's own chrome — otherwise the toolbar scrolls off on a - short laptop screen. */} -
- - containerApi.logsStart(connection, target.Id, tail, since, until, timestamps, onLine)} - /> -
-
- - )} -
-
- ); -} diff --git a/src/components/tools/container/ContainerManager.tsx b/src/components/tools/container/ContainerManager.tsx deleted file mode 100644 index 1da24e80..00000000 --- a/src/components/tools/container/ContainerManager.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useLiveConnection, usePluginMcpBridgeActive, usePluginSdkFor, usePluginState } from '@/platform'; -import { Container, Plug } from 'lucide-react'; -import { Callout } from '@/components/ui/callout'; -import { Spinner } from '@/components/ui/spinner'; -import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; -import { type ContainerConnection } from './types'; -import { useContainerApi } from './api_sdk'; -import { useContainerRuntime } from './mcpRuntimeContext'; -import { useMcpBridge } from './mcpBridge'; -import { LeftPanel } from './LeftPanel'; -import { OverviewView } from './OverviewView'; -import { ContainersView } from './ContainersView'; -import { ImagesView } from './ImagesView'; -import { VolumesView } from './VolumesView'; -import { NetworksView } from './NetworksView'; -import { ComposeView } from './ComposeView'; - -const LEFT_MIN = 200; -const LEFT_MAX = 480; -const LEFT_DEFAULT = 220; - -// Cached at module scope so a remount (the tool is lazy-loaded and remounts on -// every tab switch) renders instantly instead of flashing an empty state while -// the connection list reloads — same pattern as Redis/Rabbit/Kafka. -let cachedConnections: ContainerConnection[] | null = null; - -export function ContainerManager() { - const containerApi = useContainerApi(); - const containerState = useContainerRuntime(); - const { - selectedConnId, setSelectedConnId, connectedConnId, setConnectedConnId, - view, showOverview, showContainers, showImages, showVolumes, showNetworks, showCompose, - refreshKey, refresh, - } = containerState; - - const sdk = usePluginSdkFor('container-manager'); - const mcpBridgeActive = usePluginMcpBridgeActive(sdk); - // Skipped while the background bridge (Settings → MCP) is on — that one - // instance, mounted once at the app root, already answers for this exact - // state regardless of which tool is on screen (see mcpRuntimeContext.tsx). - // Also skipped outright when the per-tool MCP toggle is off for Containers. - useMcpBridge(containerState, mcpBridgeActive); - - const [connections, setConnections] = useState(cachedConnections ?? []); - const [connLoading, setConnLoading] = useState(cachedConnections === null); - const [connecting, setConnecting] = useState(false); - const [connectError, setConnectError] = useState(null); - - const loadConnections = useCallback(() => { - containerApi.listConfigs() - .then((c) => { cachedConnections = c; setConnections(c); }) - .catch(() => { cachedConnections = cachedConnections ?? []; }) - .finally(() => setConnLoading(false)); - }, []); - - useEffect(() => { loadConnections(); }, [loadConnections]); - - const conn = connections.find((c) => c.id === selectedConnId) ?? null; - const isConnected = !!conn && connectedConnId === conn.id; - - useLiveConnection(sdk, isConnected); - - const handleConnect = useCallback(async () => { - if (!conn) return; - setConnecting(true); - setConnectError(null); - try { - await containerApi.testConnection(conn); - setConnectedConnId(conn.id); - } catch (e) { - setConnectError(String(e instanceof Error ? e.message : e)); - } finally { - setConnecting(false); - } - }, [conn, setConnectedConnId]); - - const handleDisconnect = useCallback(() => { - setConnectError(null); - setConnectedConnId(''); - }, [setConnectedConnId]); - - const [leftWidth, setLeftWidth] = usePluginState(sdk, 'leftPanelWidth', LEFT_DEFAULT, { legacyKey: 'devtool:container:leftPanelWidth' }); - const [isResizing, setIsResizing] = useState(false); - - const handleResizeStart = (e: React.MouseEvent) => { - e.preventDefault(); - const startX = e.clientX; - const startWidth = leftWidth; - setIsResizing(true); - const onMouseMove = (ev: MouseEvent) => { - setLeftWidth(Math.min(Math.max(startWidth + ev.clientX - startX, LEFT_MIN), LEFT_MAX)); - }; - const onMouseUp = () => { - setIsResizing(false); - window.removeEventListener('mousemove', onMouseMove); - window.removeEventListener('mouseup', onMouseUp); - }; - window.addEventListener('mousemove', onMouseMove); - window.addEventListener('mouseup', onMouseUp); - }; - - return ( -
-
- -
- -
setLeftWidth(LEFT_DEFAULT)} - title="Drag to resize · Double-click to reset" - > -
-
- -
- {!conn ? ( - connLoading ? ( -
- ) : ( -
- -

Add a connection to get started

-
- ) - ) : !isConnected ? ( - - ) : view === 'containers' ? ( - - ) : view === 'images' ? ( - - ) : view === 'volumes' ? ( - - ) : view === 'networks' ? ( - - ) : view === 'compose' ? ( - - ) : ( - - )} -
-
- ); -} - -function DisconnectedPanel({ conn, connecting, error, onConnect }: { - conn: ContainerConnection; connecting: boolean; error: string | null; onConnect: () => void; -}) { - return ( -
-
- -
-
-

{conn.name}

-

{conn.socketPath}

-
- - {error && {error}} -
- ); -} diff --git a/src/components/tools/container/ContainerResourcesDialog.tsx b/src/components/tools/container/ContainerResourcesDialog.tsx deleted file mode 100644 index 4b5859b1..00000000 --- a/src/components/tools/container/ContainerResourcesDialog.tsx +++ /dev/null @@ -1,333 +0,0 @@ -import { useEffect, useMemo, useState, type ReactNode } from 'react'; -import { Cpu } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Callout } from '@/components/ui/callout'; -import { LoadingRow } from '@/components/ui/spinner'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; -import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select'; -import { RowCheckbox } from './SelectionBar'; -import { type ContainerConnection, type ContainerResourceUpdate, type ContainerResources } from './types'; -import { useContainerApi } from './api_sdk'; - -// Editor for the cgroup limits `docker update` can change on an existing -// container — CPU, memory, block IO, pids and restart policy. Works on one -// container (fields pre-filled from the daemon, only edited fields are sent) -// or on a whole selection (fields start blank, and each one is only applied -// once explicitly ticked, so a bulk CPU change can't wipe per-container -// memory limits). - -const MIB = 1024 * 1024; -const NANO = 1_000_000_000; - -const RESTART_POLICIES = ['no', 'always', 'unless-stopped', 'on-failure'] as const; - -type FieldKey = - | 'cpus' | 'cpuShares' | 'cpusetCpus' - | 'memory' | 'memoryReservation' | 'memorySwap' - | 'pidsLimit' | 'blkioWeight' | 'restartPolicy'; - -type FormState = Record; - -const EMPTY_FORM: FormState = { - cpus: '', cpuShares: '', cpusetCpus: '', - memory: '', memoryReservation: '', memorySwap: '', - pidsLimit: '', blkioWeight: '', restartPolicy: '', -}; - -/** Round-trips a value through the same formatting the inputs show, so a - * field the user never touched compares equal and is left off the wire. */ -function formStateFrom(r: ContainerResources): FormState { - const mib = (bytes?: number | null) => (bytes && bytes > 0 ? String(Math.round(bytes / MIB)) : ''); - return { - cpus: r.nanoCpus ? String(Number((r.nanoCpus / NANO).toFixed(3))) : '', - cpuShares: r.cpuShares ? String(r.cpuShares) : '', - cpusetCpus: r.cpusetCpus ?? '', - memory: mib(r.memoryBytes), - memoryReservation: mib(r.memoryReservationBytes), - // -1 is docker's "unlimited swap" sentinel — shown verbatim rather than - // as a nonsense negative megabyte count. - memorySwap: r.memorySwapBytes === -1 ? '-1' : mib(r.memorySwapBytes), - pidsLimit: r.pidsLimit ? String(r.pidsLimit) : '', - blkioWeight: r.blkioWeight ? String(r.blkioWeight) : '', - restartPolicy: r.restartPolicy && r.restartPolicy !== '' ? r.restartPolicy : 'no', - }; -} - -function parseNumber(raw: string): number | null { - const n = Number(raw.trim()); - return raw.trim() === '' || Number.isNaN(n) ? null : n; -} - -/** Field-by-field validation with docker's own accepted ranges. Returns the - * first problem so the dialog can refuse to send a request the daemon would - * reject anyway. */ -function validate(form: FormState, active: Set): string | null { - const num = (key: FieldKey) => parseNumber(form[key]); - if (active.has('cpus')) { - const v = num('cpus'); - if (form.cpus.trim() !== '' && (v === null || v < 0)) return 'CPU limit must be a non-negative number of cores.'; - } - for (const key of ['cpuShares', 'memory', 'memoryReservation', 'pidsLimit'] as FieldKey[]) { - if (!active.has(key) || form[key].trim() === '') continue; - const v = num(key); - if (v === null || v < 0 || !Number.isInteger(v)) return `${LABELS[key]} must be a non-negative whole number.`; - } - if (active.has('memory') && form.memory.trim() !== '') { - const v = num('memory')!; - if (v > 0 && v < 6) return 'Memory limit must be at least 6 MB (docker minimum).'; - } - if (active.has('memorySwap') && form.memorySwap.trim() !== '') { - const v = num('memorySwap'); - if (v === null || !Number.isInteger(v) || v < -1) return 'Memory + swap must be a whole number of MB, or -1 for unlimited.'; - } - if (active.has('blkioWeight') && form.blkioWeight.trim() !== '') { - const v = num('blkioWeight'); - if (v === null || !Number.isInteger(v) || v < 10 || v > 1000) return 'Block IO weight must be between 10 and 1000.'; - } - if (active.has('cpusetCpus') && form.cpusetCpus.trim() !== '' && !/^[0-9]+([,-][0-9]+)*$/.test(form.cpusetCpus.trim())) { - return 'CPU set must look like "0", "0-3" or "0,2,4".'; - } - return null; -} - -const LABELS: Record = { - cpus: 'CPU limit', - cpuShares: 'CPU shares', - cpusetCpus: 'CPU set', - memory: 'Memory limit', - memoryReservation: 'Memory reservation', - memorySwap: 'Memory + swap', - pidsLimit: 'PIDs limit', - blkioWeight: 'Block IO weight', - restartPolicy: 'Restart policy', -}; - -/** Maps the form's human units (cores, MB) onto the daemon's wire units - * (nano-CPUs, bytes). An empty field means "clear this limit", which docker - * spells as `0` for every limit except swap, where `-1` is unlimited. */ -function toPayload(form: FormState, keys: FieldKey[]): ContainerResourceUpdate { - const payload: ContainerResourceUpdate = {}; - const mbToBytes = (raw: string) => { - const v = parseNumber(raw); - if (v === null) return 0; - return v === -1 ? -1 : Math.round(v * MIB); - }; - for (const key of keys) { - const raw = form[key]; - switch (key) { - case 'cpus': { - const nano = Math.round((parseNumber(raw) ?? 0) * NANO); - payload.nanoCpus = nano; - // The daemon rejects a config carrying both NanoCpus and a CPU - // quota/period pair ("Conflicting options"), and a container started - // with --cpu-quota still has that pair in its HostConfig — which the - // update would merge with. Clearing them is exactly what the CLI's - // `--cpus` does, so the same limit is expressed one way only. - if (nano > 0) { payload.cpuQuota = 0; payload.cpuPeriod = 0; } - break; - } - case 'cpuShares': payload.cpuShares = parseNumber(raw) ?? 0; break; - case 'cpusetCpus': payload.cpusetCpus = raw.trim(); break; - case 'memory': payload.memoryBytes = mbToBytes(raw); break; - case 'memoryReservation': payload.memoryReservationBytes = mbToBytes(raw); break; - case 'memorySwap': payload.memorySwapBytes = mbToBytes(raw); break; - case 'pidsLimit': payload.pidsLimit = parseNumber(raw) ?? 0; break; - case 'blkioWeight': payload.blkioWeight = parseNumber(raw) ?? 0; break; - case 'restartPolicy': payload.restartPolicy = raw || 'no'; break; - } - } - return payload; -} - -export function ContainerResourcesDialog({ open, onOpenChange, connection, targets, onApplied }: { - open: boolean; - onOpenChange: (o: boolean) => void; - connection: ContainerConnection; - /** One entry edits that container's current limits; several applies the - * ticked fields to every one of them. */ - targets: { id: string; name: string }[]; - onApplied: () => void; -}) { - const containerApi = useContainerApi(); - const bulk = targets.length > 1; - const [form, setForm] = useState(EMPTY_FORM); - const [initial, setInitial] = useState(EMPTY_FORM); - const [enabled, setEnabled] = useState>(new Set()); - const [loading, setLoading] = useState(false); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - const singleId = !bulk ? targets[0]?.id : undefined; - - useEffect(() => { - if (!open) return; - setError(null); - setEnabled(new Set()); - if (!singleId) { setForm(EMPTY_FORM); setInitial(EMPTY_FORM); return; } - setLoading(true); - containerApi.resources(connection, singleId) - .then((r) => { const f = formStateFrom(r); setForm(f); setInitial(f); }) - .catch((e) => setError(String(e instanceof Error ? e.message : e))) - .finally(() => setLoading(false)); - }, [open, singleId, connection]); - - const set = (key: FieldKey, value: string) => setForm((f) => ({ ...f, [key]: value })); - const toggleEnabled = (key: FieldKey) => setEnabled((prev) => { - const next = new Set(prev); - if (next.has(key)) next.delete(key); else next.add(key); - return next; - }); - - // Single: whatever the user actually changed. Bulk: whatever they ticked. - const activeKeys = useMemo(() => { - if (bulk) return Array.from(enabled); - return (Object.keys(form) as FieldKey[]).filter((k) => form[k] !== initial[k]); - }, [bulk, enabled, form, initial]); - - const apply = async () => { - if (activeKeys.length === 0) return; - const problem = validate(form, new Set(activeKeys)); - if (problem) { setError(problem); return; } - setBusy(true); - setError(null); - const payload = toPayload(form, activeKeys); - const results = await Promise.allSettled( - targets.map((t) => containerApi.updateResources(connection, t.id, payload)), - ); - const failed = results.filter((r) => r.status === 'rejected') as PromiseRejectedResult[]; - setBusy(false); - if (failed.length > 0) { - const first = failed[0].reason; - setError(`${failed.length} of ${targets.length} failed — ${String(first instanceof Error ? first.message : first)}`); - // Partial success still changed something on the daemon, so refresh. - if (failed.length < targets.length) onApplied(); - return; - } - onApplied(); - onOpenChange(false); - }; - - const title = bulk - ? `Resource limits · ${targets.length} containers` - : `Resource limits · ${targets[0]?.name ?? ''}`; - - const field = (key: FieldKey, hint: string, input: ReactNode) => ( - toggleEnabled(key)} - changed={!bulk && form[key] !== initial[key]} - > - {input} - - ); - - const numberInput = (key: FieldKey, placeholder: string) => ( - set(key, e.target.value)} - className="h-ctl font-mono text-sm" - /> - ); - - return ( - { if (!busy) onOpenChange(o); }}> - - - - {title} - - - -
- {loading && } - {!loading && ( - <> -

- {bulk - ? 'Tick a limit to apply it to every selected container. Un-ticked limits are left exactly as they are.' - : 'Applied live — no restart needed. Leave a field empty to remove that limit.'} -

- -
- {field('cpus', 'Cores, e.g. 1.5. Empty = unlimited.', numberInput('cpus', '1.5'))} - {field('cpuShares', 'Relative weight vs other containers. Default 1024.', numberInput('cpuShares', '1024'))} - {field('memory', 'Hard limit in MB. Min 6.', numberInput('memory', '512'))} - {field('memoryReservation', 'Soft limit in MB, kept under pressure.', numberInput('memoryReservation', '256'))} - {field('memorySwap', 'Memory + swap in MB. -1 = unlimited swap.', numberInput('memorySwap', '1024'))} - {field('cpusetCpus', 'Pin to specific cores, e.g. 0-3.', ( - set('cpusetCpus', e.target.value)} - className="h-ctl font-mono text-sm" - /> - ))} - {field('pidsLimit', 'Max processes. Empty or -1 = unlimited.', numberInput('pidsLimit', '512'))} - {field('blkioWeight', 'Block IO weight, 10–1000.', numberInput('blkioWeight', '500'))} - {field('restartPolicy', 'What the daemon does when the container exits.', ( - - ))} -
- - {error && {error}} - - )} -
- - - - {activeKeys.length === 0 - ? bulk ? 'Nothing ticked yet' : 'No changes' - : `${activeKeys.length} limit(s) → ${targets.length} container(s)`} - - - - -
-
- ); -} - -function ResourceField({ label, hint, bulk, enabled, onToggleEnabled, changed, children }: { - label: string; - hint: string; - bulk: boolean; - enabled: boolean; - onToggleEnabled: () => void; - changed: boolean; - children: ReactNode; -}) { - return ( -
-
- {bulk && } -

- {label} - {changed && •} -

-
-
{children}
-

{hint}

-
- ); -} diff --git a/src/components/tools/container/ContainersView.tsx b/src/components/tools/container/ContainersView.tsx deleted file mode 100644 index 771db3ad..00000000 --- a/src/components/tools/container/ContainersView.tsx +++ /dev/null @@ -1,433 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Box, RefreshCw, MoreHorizontal, Play, Square, RotateCw, Trash2, Info, Cpu } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { ViewHeader } from '@/components/ui/view-header'; -import { SearchInput } from '@/components/ui/search-input'; -import { Callout } from '@/components/ui/callout'; -import { LoadingRow } from '@/components/ui/spinner'; -import { DataTable, Thead, Tbody, Tr, Th, Td, DataTableStatus } from '@/components/ui/data-table'; -import { Badge, type BadgeTone } from '@/components/ui/badge'; -import { Switch } from '@/components/ui/switch'; -import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'; -import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { IconButton } from '@/components/ui/icon-button'; -import { cn } from '@/lib/utils'; -import { type ContainerConnection, type ContainerSummary, type StatsFrame } from './types'; -import { useContainerApi } from './api_sdk'; -import { ContainerLogsDialog } from './ContainerLogsDialog'; -import { useSort } from './useSort'; -import { useRowSelection } from './useRowSelection'; -import { RowCheckbox, SelectionBar } from './SelectionBar'; -import { PruneButton } from './PruneButton'; -import { ContainerDetailsDialog } from './ContainerDetailsDialog'; -import { ContainerResourcesDialog } from './ContainerResourcesDialog'; -import { formatBytes } from './format'; - -/** How often the live CPU/memory columns re-sample. Each tick is one - * `container_stats_snapshot` call covering every running container, and the - * daemon needs ~1s per sample internally, so anything faster would just - * overlap itself. */ -const STATS_POLL_MS = 5000; - -function stateTone(state?: string): BadgeTone { - switch (state) { - case 'running': return 'success'; - case 'paused': return 'warning'; - case 'exited': - case 'dead': return 'danger'; - default: return 'neutral'; - } -} - -function containerName(c: ContainerSummary): string { - return c.Names?.[0]?.replace(/^\//, '') ?? c.Id.slice(0, 12); -} - -function formatPorts(c: ContainerSummary): string { - if (!c.Ports || c.Ports.length === 0) return '—'; - return c.Ports - .filter((p) => p.PublicPort) - .map((p) => `${p.PublicPort}:${p.PrivatePort}/${p.Type}`) - .join(', ') || '—'; -} - -export function ContainersView({ connection, refreshKey, onRefresh }: { - connection: ContainerConnection; - refreshKey: number; - onRefresh: () => void; -}) { - const containerApi = useContainerApi(); - const [filter, setFilter] = useState(''); - const [showAll, setShowAll] = useState(true); - const [liveStats, setLiveStats] = useState(false); - const [containers, setContainers] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [busyIds, setBusyIds] = useState>(new Set()); - const [logsTarget, setLogsTarget] = useState(null); - const [detailsTarget, setDetailsTarget] = useState(null); - const [removeTarget, setRemoveTarget] = useState(null); - const [bulkRemoveOpen, setBulkRemoveOpen] = useState(false); - // Single-container edit; the bulk edit reuses the same dialog with every - // selected container as its target. - const [limitsTarget, setLimitsTarget] = useState(null); - const [bulkLimitsOpen, setBulkLimitsOpen] = useState(false); - const [stats, setStats] = useState>({}); - /** Result line from the last prune — informational, not an error. */ - const [notice, setNotice] = useState(null); - - const load = useCallback(() => { - setLoading(true); - setError(null); - containerApi.list(connection, showAll) - .then((cs) => setContainers(cs)) - .catch((e) => { setContainers([]); setError(String(e instanceof Error ? e.message : e)); }) - .finally(() => setLoading(false)); - }, [connection, showAll]); - - const f = filter.trim().toLowerCase(); - const filtered = useMemo( - () => (containers ?? []).filter((c) => containerName(c).toLowerCase().includes(f) || (c.Image ?? '').toLowerCase().includes(f)), - [containers, f], - ); - const { sorted: rows, toggleSort, directionFor } = useSort(filtered, { - name: (c) => containerName(c), - image: (c) => c.Image ?? '', - state: (c) => c.State ?? '', - status: (c) => c.Status ?? '', - ports: (c) => formatPorts(c), - cpu: (c) => stats[c.Id]?.cpuPercent ?? -1, - mem: (c) => stats[c.Id]?.memUsageBytes ?? -1, - }); - - const selection = useRowSelection(rows, useCallback((c: ContainerSummary) => c.Id, [])); - const { prune, clear } = selection; - - useEffect(() => { load(); clear(); }, [load, refreshKey, clear]); - useEffect(() => { if (containers) prune(containers.map((c) => c.Id)); }, [containers, prune]); - - // Live usage sampling. `runningIds` is recomputed from the list rather than - // captured, so containers stopped/started between ticks drop in and out on - // their own; a tick that overlaps the previous one is skipped instead of - // queueing up more work against the daemon. - const runningIds = useMemo( - () => (containers ?? []).filter((c) => c.State === 'running').map((c) => c.Id), - [containers], - ); - const runningKey = runningIds.join(','); - const inFlight = useRef(false); - - useEffect(() => { - if (!liveStats) { setStats({}); return; } - if (runningIds.length === 0) { setStats({}); return; } - let cancelled = false; - const sample = async () => { - if (inFlight.current) return; - inFlight.current = true; - try { - const frames = await containerApi.statsSnapshot(connection, runningIds); - if (!cancelled) setStats(frames); - } catch { - // A failed sample just leaves the previous numbers on screen — the - // list itself is still valid, so this isn't worth an error banner. - } finally { - inFlight.current = false; - } - }; - sample(); - const timer = window.setInterval(sample, STATS_POLL_MS); - return () => { cancelled = true; window.clearInterval(timer); }; - }, [liveStats, connection, runningKey]); // eslint-disable-line react-hooks/exhaustive-deps - - const runAction = async (id: string, action: () => Promise) => { - setBusyIds((s) => new Set(s).add(id)); - setError(null); - try { - await action(); - load(); - } catch (e) { - setError(String(e instanceof Error ? e.message : e)); - } finally { - setBusyIds((s) => { const n = new Set(s); n.delete(id); return n; }); - } - }; - - const runBulk = async (ids: string[], action: (id: string) => Promise) => { - setBusyIds((s) => { const n = new Set(s); ids.forEach((id) => n.add(id)); return n; }); - setError(null); - const results = await Promise.allSettled(ids.map(action)); - const failed = results.filter((r) => r.status === 'rejected') as PromiseRejectedResult[]; - if (failed.length > 0) { - const first = failed[0].reason; - setError(`${failed.length} of ${ids.length} failed — ${String(first instanceof Error ? first.message : first)}`); - } - setBusyIds((s) => { const n = new Set(s); ids.forEach((id) => n.delete(id)); return n; }); - load(); - }; - - const selectedContainers = useMemo( - () => (containers ?? []).filter((c) => selection.isSelected(c.Id)), - [containers, selection], - ); - - return ( - // `relative` anchors the floating SelectionBar (see SelectionBar.tsx). -
- - containerApi.prune(connection), - }]} - onDone={(m) => { setNotice(m); setError(null); load(); }} - onError={(m) => { setNotice(null); setError(m); }} - /> - - - )} - /> - -
- - - -
- - - - - - - - - -
0 && 'pb-20')}> - {loading && !containers && } - {error && {error}} - {notice && !error && {notice}} - {containers && !error && ( - rows.length === 0 - ?

{f ? 'No matching containers.' : 'No containers.'}

- : ( -
- - - - - - - toggleSort('name')}>Name - toggleSort('image')} sub="repo:tag" subTone="string">Image - toggleSort('state')}>State - toggleSort('status')}>Status - {liveStats && ( - <> - toggleSort('cpu')} sub="%" subTone="number">CPU - toggleSort('mem')} sub="bytes" subTone="number">Memory - - )} - toggleSort('ports')} sub="host→ctr" subTone="number">Ports - - - - - {rows.map((c, index) => ( - setDetailsTarget(c)}> - e.stopPropagation()}> - selection.toggle(c.Id, index, e.shiftKey)} - title="Select container" - /> - - {containerName(c)} - {c.Image} - {c.State ?? 'unknown'} - {c.Status} - {liveStats && ( - <> - - - - )} - {formatPorts(c)} - e.stopPropagation()}> -
- setDetailsTarget(c)}> - - - {c.State === 'running' ? ( - runAction(c.Id, () => containerApi.stop(connection, c.Id))}> - - - ) : ( - runAction(c.Id, () => containerApi.start(connection, c.Id))}> - - - )} - - - - - - setLogsTarget(c)}>Logs - setLimitsTarget(c)}>Resource limits… - runAction(c.Id, () => containerApi.restart(connection, c.Id))}>Restart - {c.State === 'running' ? ( - runAction(c.Id, () => containerApi.pause(connection, c.Id))}>Pause - ) : c.State === 'paused' ? ( - runAction(c.Id, () => containerApi.unpause(connection, c.Id))}>Unpause - ) : null} - - setRemoveTarget(c)}>Remove - - -
- - - ))} - -
- {/* Tiêu đề trang ghi TỔNG số, còn bảng thì đang LỌC — hai con số khác - nhau và trước đây không chỗ nào nói ra. Dòng này nói cả hai. */} - -
- ) - )} -
- - { if (!o) setLogsTarget(null); }} - connection={connection} - container={logsTarget} - onAction={runAction} - onRemove={setRemoveTarget} - /> - - { if (!o) setRemoveTarget(null); }} - title="Remove container?" - description={removeTarget ? `Remove "${containerName(removeTarget)}". This cannot be undone.` : ''} - confirmLabel="Remove" - onConfirm={async () => { - if (!removeTarget) return; - await runAction(removeTarget.Id, () => containerApi.remove(connection, removeTarget.Id, true)); - setLogsTarget(null); - setRemoveTarget(null); - }} - /> - - { - const ids = selection.keys; - await runBulk(ids, (id) => containerApi.remove(connection, id, true)); - selection.clear(); - }} - /> - - { if (!o) setLimitsTarget(null); }} - connection={connection} - targets={limitsTarget ? [{ id: limitsTarget.Id, name: containerName(limitsTarget) }] : []} - onApplied={load} - /> - - ({ id: c.Id, name: containerName(c) }))} - onApplied={load} - /> - - { if (!o) setDetailsTarget(null); }} - connection={connection} - container={detailsTarget} - onEditLimits={(c) => { setDetailsTarget(null); setLimitsTarget(c); }} - /> -
- ); -} - -/** CPU / memory cell for the live-usage columns: the number plus a bar - * showing it against the container's own limit, so a container pinned at its - * cap is visible at a glance instead of needing the two numbers compared. */ -function UsageCell({ frame, kind, running }: { frame?: StatsFrame; kind: 'cpu' | 'mem'; running: boolean }) { - if (!running) return —; - if (!frame) return …; - - const pct = kind === 'cpu' - ? frame.cpuPercent - : frame.memLimitBytes > 0 ? (frame.memUsageBytes / frame.memLimitBytes) * 100 : 0; - const label = kind === 'cpu' - ? `${frame.cpuPercent.toFixed(1)}%` - : formatBytes(frame.memUsageBytes); - const clamped = Math.max(0, Math.min(100, pct)); - - return ( - - {label} - - = 90 ? 'bg-bad' : clamped >= 70 ? 'bg-warn' : 'bg-acc')} - style={{ width: `${clamped}%` }} - /> - - - ); -} diff --git a/src/components/tools/container/DetailRows.tsx b/src/components/tools/container/DetailRows.tsx deleted file mode 100644 index 723b41b8..00000000 --- a/src/components/tools/container/DetailRows.tsx +++ /dev/null @@ -1,68 +0,0 @@ -// Small label/value building blocks shared by ContainerDetailsDialog and -// ImageDetailsDialog — both show the same shape of data (a handful of scalar -// fields, then a few key/value or tabular lists), so the layout primitives -// are factored out once instead of duplicated per dialog. - -import type { ReactNode } from 'react'; -import { CopyButton } from '@/components/ui/copy-button'; -import { DataTable, Thead, Tbody, Tr, Th, Td } from '@/components/ui/data-table'; -import { cn } from '@/lib/utils'; - -/** One label/value pair in the overview grid. `mono` for ids/paths/commands. */ -export function DetailField({ label, value, mono, copy, className }: { - label: string; - value: ReactNode; - mono?: boolean; - /** Full string to copy — pass when `value` is a truncated/formatted display. */ - copy?: string; - className?: string; -}) { - return ( -
-

{label}

- {/* `div`, not `p` — some callers pass block-level content (a Badge list - for "All tags"/"Exposed ports"), which a `

` can't legally contain; - the browser would silently close the `

` early and corrupt the - surrounding layout. */} -

-
{value}
- {copy && } -
-
- ); -} - -/** Responsive grid of DetailFields — the dialog's "Overview" section. */ -export function DetailGrid({ children }: { children: ReactNode }) { - return
{children}
; -} - -/** Key/value table for env vars, labels, driver options — anything that's a - * flat string map. Splits "KEY=value" pairs (env) when given as a string[]. */ -export function KeyValueTable({ entries }: { entries: [string, string][] }) { - if (entries.length === 0) return

None

; - return ( - - KeyValue - - {entries.map(([k, v]) => ( - - {k} - {v} - - ))} - - - ); -} - -export function envEntries(env: string[]): [string, string][] { - return env.map((line) => { - const i = line.indexOf('='); - return i === -1 ? [line, ''] : [line.slice(0, i), line.slice(i + 1)]; - }); -} - -export function labelEntries(labels: Record): [string, string][] { - return Object.entries(labels).sort(([a], [b]) => a.localeCompare(b)); -} diff --git a/src/components/tools/container/ImageDetailsDialog.tsx b/src/components/tools/container/ImageDetailsDialog.tsx deleted file mode 100644 index 9ea64082..00000000 --- a/src/components/tools/container/ImageDetailsDialog.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Callout } from '@/components/ui/callout'; -import { LoadingRow } from '@/components/ui/spinner'; -import { CollapsibleSection } from '@/components/ui/collapsible-section'; -import { Badge } from '@/components/ui/badge'; -import { type ContainerConnection, type ImageDetails, type ImageSummary } from './types'; -import { useContainerApi } from './api_sdk'; -import { DetailField, DetailGrid, KeyValueTable, envEntries, labelEntries } from './DetailRows'; -import { formatBytes } from './format'; - -export function ImageDetailsDialog({ open, onOpenChange, connection, image }: { - open: boolean; - onOpenChange: (o: boolean) => void; - connection: ContainerConnection; - image: ImageSummary | null; -}) { - const containerApi = useContainerApi(); - const [details, setDetails] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (!open || !image) { setDetails(null); return; } - setLoading(true); - setError(null); - containerApi.imageDetails(connection, image.Id) - .then(setDetails) - .catch((e) => setError(String(e instanceof Error ? e.message : e))) - .finally(() => setLoading(false)); - }, [open, image, connection]); - - const title = (details?.repoTags[0]) ?? image?.Id.replace('sha256:', '').slice(0, 12) ?? ''; - - return ( - - - - {title} - -
- {loading && } - {error && {error}} - {details && !loading && ( - <> - - - - - - - - {details.workingDir && } - - - {details.repoTags.length > 1 && ( - - {details.repoTags.map((t) => {t})} -
- )} - /> - )} - - {(details.entrypoint.length > 0 || details.cmd.length > 0) && ( - - {details.entrypoint.length > 0 && ( - - )} - {details.cmd.length > 0 && ( - - )} - - )} - - {details.exposedPorts.length > 0 && ( - - {details.exposedPorts.map((p) => {p})} -
- )} - /> - )} - - - - - - - - - - {details.repoDigests.length > 0 && ( - -
- {details.repoDigests.map((d) => ( -

{d}

- ))} -
-
- )} - - )} -
- - - ); -} diff --git a/src/components/tools/container/ImagesView.test.ts b/src/components/tools/container/ImagesView.test.ts deleted file mode 100644 index 341d70df..00000000 --- a/src/components/tools/container/ImagesView.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { repoTagLabel } from './ImagesView'; - -// Xác nhận không cần màn hình cho bug vừa sửa: Docker API trả `RepoTags: []` -// (mảng RỖNG) cho image chưa gắn tag hoặc image trung gian — không phải -// `null`/`undefined` — nên `tags ?? fallback` không kích hoạt được và tên -// image hiện trống trơn. `repoTagLabel` phải kiểm tra `.length`, không dựa -// vào `??`. Đây là ca lỗi thật quan sát được: 48 image thật trên máy, nhiều -// dòng tên trống — xem commit fix trên cả preview/chrome-bands và -// claude/dbx-design-reference-b1fbof. -describe('repoTagLabel', () => { - it('dùng fallback khi RepoTags là mảng RỖNG (ca lỗi thật đã quan sát)', () => { - expect(repoTagLabel([], [':'])).toBe(':'); - }); - - it('dùng fallback khi RepoTags là undefined', () => { - expect(repoTagLabel(undefined, [':'])).toBe(':'); - }); - - it('giữ nguyên tag thật khi có', () => { - expect(repoTagLabel(['redis:7-alpine'], [':'])).toBe('redis:7-alpine'); - }); - - it('nối nhiều tag bằng ", "', () => { - expect(repoTagLabel(['a:1', 'a:latest'], [':'])).toBe('a:1, a:latest'); - }); - - it('fallback tuỳ biến được (hộp thoại xoá dùng Id thay vì ":")', () => { - expect(repoTagLabel([], ['sha256:abc123'])).toBe('sha256:abc123'); - }); -}); diff --git a/src/components/tools/container/ImagesView.tsx b/src/components/tools/container/ImagesView.tsx deleted file mode 100644 index 3f501413..00000000 --- a/src/components/tools/container/ImagesView.tsx +++ /dev/null @@ -1,397 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Layers, RefreshCw, Trash2, Download, Info, Tag } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { ViewHeader } from '@/components/ui/view-header'; -import { SearchInput } from '@/components/ui/search-input'; -import { Callout } from '@/components/ui/callout'; -import { LoadingRow, Spinner } from '@/components/ui/spinner'; -import { DataTable, Thead, Tbody, Tr, Th, Td, DataTableStatus } from '@/components/ui/data-table'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; -import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { IconButton } from '@/components/ui/icon-button'; -import { cn } from '@/lib/utils'; -import { Badge } from '@/components/ui/badge'; -import { type ContainerConnection, type ImageSummary } from './types'; -import { useContainerApi } from './api_sdk'; -import { PruneButton } from './PruneButton'; -import { useUsageIndex, describeUsers } from './usage'; -import { useSort } from './useSort'; -import { useRowSelection } from './useRowSelection'; -import { RowCheckbox, SelectionBar } from './SelectionBar'; -import { formatBytes } from './format'; -import { ImageDetailsDialog } from './ImageDetailsDialog'; - -// `RepoTags` from the Docker API is `[]` (an empty array) for untagged/ -// intermediate images, not `null`/`undefined` — so `img.RepoTags ?? fallback` -// never triggers and these rows rendered blank instead of falling back. -// Check `.length` instead of relying on nullish coalescing. -export function repoTagLabel(tags: string[] | undefined, fallback: string[]): string { - return (tags && tags.length > 0 ? tags : fallback).join(', '); -} - -export function ImagesView({ connection, refreshKey, onRefresh }: { - connection: ContainerConnection; - refreshKey: number; - onRefresh: () => void; -}) { - const containerApi = useContainerApi(); - const [filter, setFilter] = useState(''); - const [images, setImages] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [pullOpen, setPullOpen] = useState(false); - const [removeTarget, setRemoveTarget] = useState(null); - const [detailsTarget, setDetailsTarget] = useState(null); - const [bulkRemoveOpen, setBulkRemoveOpen] = useState(false); - const [bulkBusy, setBulkBusy] = useState(false); - const [tagTarget, setTagTarget] = useState(null); - /** Result line from the last prune — informational, not an error. */ - const [notice, setNotice] = useState(null); - const { usage, reload: reloadUsage } = useUsageIndex(connection, refreshKey); - - const load = useCallback(() => { - setLoading(true); - setError(null); - reloadUsage(); - containerApi.imageList(connection) - .then(setImages) - .catch((e) => { setImages([]); setError(String(e instanceof Error ? e.message : e)); }) - .finally(() => setLoading(false)); - }, [connection, reloadUsage]); - - const f = filter.trim().toLowerCase(); - const filtered = useMemo( - () => (images ?? []).filter((img) => (img.RepoTags ?? []).some((t) => t.toLowerCase().includes(f)) || img.Id.toLowerCase().includes(f)), - [images, f], - ); - const { sorted: rows, toggleSort, directionFor } = useSort(filtered, { - repo: (img) => repoTagLabel(img.RepoTags, [':']), - id: (img) => img.Id, - size: (img) => img.Size ?? 0, - used: (img) => usage.byImage.get(img.Id)?.length ?? 0, - }); - - const selection = useRowSelection(rows, useCallback((img: ImageSummary) => img.Id, [])); - const { prune, clear } = selection; - - useEffect(() => { load(); clear(); }, [load, refreshKey, clear]); - useEffect(() => { if (images) prune(images.map((img) => img.Id)); }, [images, prune]); - - const removeBulk = async (ids: string[]) => { - setBulkBusy(true); - setError(null); - const results = await Promise.allSettled(ids.map((id) => containerApi.imageRemove(connection, id, true))); - const failed = results.filter((r) => r.status === 'rejected') as PromiseRejectedResult[]; - if (failed.length > 0) { - const first = failed[0].reason; - setError(`${failed.length} of ${ids.length} failed — ${String(first instanceof Error ? first.message : first)}`); - } - setBulkBusy(false); - clear(); - load(); - }; - - return ( - // `relative` anchors the floating SelectionBar (see SelectionBar.tsx). -
- - - containerApi.imagePrune(connection, true), - }, - { - label: 'Prune all unused images', - danger: true, - description: 'Remove every image no container is using, tagged ones included (docker image prune -a). Anything not in a registry has to be rebuilt.', - run: () => containerApi.imagePrune(connection, false), - }, - ]} - onDone={(m) => { setNotice(m); setError(null); load(); }} - onError={(m) => { setNotice(null); setError(m); }} - /> - - - )} - /> - -
- -
- - - - - -
0 && 'pb-20')}> - {loading && !images && } - {error && {error}} - {notice && !error && {notice}} - {images && !error && ( - rows.length === 0 - ?

{f ? 'No matching images.' : 'No images.'}

- : ( -
- - - - - - - toggleSort('repo')}>Repository:Tag - toggleSort('id')} sub="sha256" subTone="binary">Image ID - toggleSort('used')}>In use - toggleSort('size')} sub="bytes" subTone="number">Size - - - - - {rows.map((img, index) => ( - setDetailsTarget(img)}> - e.stopPropagation()}> - selection.toggle(img.Id, index, e.shiftKey)} - title="Select image" - /> - - {repoTagLabel(img.RepoTags, [':'])} - {img.Id.replace('sha256:', '').slice(0, 12)} - - {formatBytes(img.Size)} - e.stopPropagation()}> -
- setDetailsTarget(img)}> - - - setTagTarget(img)}> - - - setRemoveTarget(img)}> - - -
- - - ))} - -
- {/* Tiêu đề trang ghi TỔNG số, còn bảng thì đang LỌC — hai con số khác - nhau và trước đây không chỗ nào nói ra. Dòng này nói cả hai. */} - -
- ) - )} -
- - - - { if (!o) setTagTarget(null); }} - connection={connection} - image={tagTarget} - onTagged={() => { setTagTarget(null); load(); }} - /> - - { if (!o) setDetailsTarget(null); }} - connection={connection} - image={detailsTarget} - /> - - { if (!o) setRemoveTarget(null); }} - title="Remove image?" - description={removeTarget ? `Remove "${repoTagLabel(removeTarget.RepoTags, [removeTarget.Id])}".` : ''} - confirmLabel="Remove" - onConfirm={async () => { - if (!removeTarget) return; - await containerApi.imageRemove(connection, removeTarget.Id, true); - setRemoveTarget(null); - load(); - }} - /> - - removeBulk(selection.keys)} - /> -
- ); -} - -function PullDialog({ open, onOpenChange, connection, onDone }: { - open: boolean; onOpenChange: (o: boolean) => void; connection: ContainerConnection; onDone: () => void; -}) { - const containerApi = useContainerApi(); - const [image, setImage] = useState(''); - const [tag, setTag] = useState('latest'); - const [pulling, setPulling] = useState(false); - const [status, setStatus] = useState(''); - const [error, setError] = useState(null); - - const pull = async () => { - if (!image.trim()) { setError('Image name is required'); return; } - setPulling(true); - setError(null); - setStatus('Starting…'); - try { - await containerApi.imagePull(connection, image.trim(), tag.trim() || 'latest', (p) => setStatus(p.status)); - onDone(); - onOpenChange(false); - setImage(''); - } catch (e) { - setError(String(e instanceof Error ? e.message : e)); - } finally { - setPulling(false); - } - }; - - return ( - { if (!pulling) onOpenChange(o); }}> - - - Pull image - -
-
- setImage(e.target.value)} disabled={pulling} className="font-mono text-sm" /> - setTag(e.target.value)} disabled={pulling} className="font-mono text-sm w-28" /> -
- {pulling && ( -
- {status} -
- )} - {error && {error}} -
- - - - -
-
- ); -} - -/** An image with no repo tag is a dangling layer — the thing `docker image - * prune` targets, and worth calling out since it can't be pulled back. */ -function isDangling(img: ImageSummary): boolean { - const tags = img.RepoTags ?? []; - return tags.length === 0 || tags.every((t) => t === ':'); -} - -function UsageBadge({ users, dangling }: { users?: string[]; dangling?: boolean }) { - if (users && users.length > 0) { - return ( - - {users.length} container{users.length > 1 ? 's' : ''} - - ); - } - return ( - - {dangling ? 'dangling' : 'unused'} - - ); -} - -/** `docker tag` — adds a second name to an existing image. Never removes the - * old one, so it's a safe, non-destructive rename-by-addition. */ -function TagDialog({ open, onOpenChange, connection, image, onTagged }: { - open: boolean; - onOpenChange: (o: boolean) => void; - connection: ContainerConnection; - image: ImageSummary | null; - onTagged: () => void; -}) { - const containerApi = useContainerApi(); - const [repo, setRepo] = useState(''); - const [tag, setTag] = useState('latest'); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (!open) return; - const first = image?.RepoTags?.find((t) => t !== ':'); - const [existingRepo, existingTag] = first ? splitTag(first) : ['', 'latest']; - setRepo(existingRepo); - setTag(existingTag); - setError(null); - }, [open, image]); - - const apply = async () => { - if (!image) return; - if (!repo.trim()) { setError('Repository is required'); return; } - setBusy(true); - setError(null); - try { - await containerApi.imageTag(connection, image.Id, repo.trim(), tag.trim() || 'latest'); - onTagged(); - } catch (e) { - setError(String(e instanceof Error ? e.message : e)); - } finally { - setBusy(false); - } - }; - - return ( - { if (!busy) onOpenChange(o); }}> - - Add tag -
-

- {image?.Id.replace('sha256:', '').slice(0, 20)} -

-
- setRepo(e.target.value)} disabled={busy} className="font-mono text-sm" /> - setTag(e.target.value)} disabled={busy} className="font-mono text-sm w-28" /> -
-

The existing tags are kept — this only adds another name.

- {error && {error}} -
- - - - -
-
- ); -} - -/** Splits `repo:tag` on the LAST colon — a registry host can carry a port - * (`localhost:5000/app:v1`), so splitting on the first would break it. */ -function splitTag(repoTag: string): [string, string] { - const i = repoTag.lastIndexOf(':'); - if (i === -1 || repoTag.slice(i + 1).includes('/')) return [repoTag, 'latest']; - return [repoTag.slice(0, i), repoTag.slice(i + 1)]; -} diff --git a/src/components/tools/container/LeftPanel.tsx b/src/components/tools/container/LeftPanel.tsx deleted file mode 100644 index 1af0cf6a..00000000 --- a/src/components/tools/container/LeftPanel.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { useState } from 'react'; -import { Plus, Pencil, Trash2, Gauge, Plug, PlugZap, Box, Layers, HardDrive, Network as NetworkIcon, FileStack } from 'lucide-react'; -import { Spinner } from '@/components/ui/spinner'; -import { Button } from '@/components/ui/button'; -import { - Select, SelectTrigger, SelectValue, SelectContent, SelectItem, -} from '@/components/ui/select'; -import { IconButton } from '@/components/ui/icon-button'; -import { StatusDot } from '@/components/ui/status-dot'; -import { cn } from '@/lib/utils'; -import type { ContainerConnection } from './types'; -import { useContainerApi } from './api_sdk'; -import { ConnectionForm } from './ConnectionForm'; -import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import type { ContainerView } from './useContainerState'; - -interface LeftPanelProps { - connections: ContainerConnection[]; - connectionsLoading: boolean; - selectedConnId: string; - onSelectConn: (id: string) => void; - onConnectionsChanged: () => void; - connected: boolean; - connecting: boolean; - onConnect: () => void; - onDisconnect: () => void; - view: ContainerView; - onShowOverview: () => void; - onShowContainers: () => void; - onShowImages: () => void; - onShowVolumes: () => void; - onShowNetworks: () => void; - onShowCompose: () => void; -} - -export function LeftPanel(props: LeftPanelProps) { - const containerApi = useContainerApi(); - const { - connections, connectionsLoading, selectedConnId, onSelectConn, onConnectionsChanged, - connected, connecting, onConnect, onDisconnect, - view, onShowOverview, onShowContainers, onShowImages, onShowVolumes, onShowNetworks, onShowCompose, - } = props; - - const [formOpen, setFormOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [deleteOpen, setDeleteOpen] = useState(false); - - const conn = connections.find((c) => c.id === selectedConnId) ?? null; - - const handleSave = async (config: ContainerConnection) => { - const saved = await containerApi.saveConfig(config); - setFormOpen(false); - setEditing(null); - onConnectionsChanged(); - onSelectConn(saved.id); - }; - - return ( -
-
-
- - -
- {conn && ( - <> -
- - - {conn.socketPath} - -
- { setEditing(conn); setFormOpen(true); }}> - - - setDeleteOpen(true)}> - - -
-
- {connected ? ( - - ) : ( - - )} - - )} -
- - {!conn ? ( -
- {!connectionsLoading && ( -

Add a connection to get started.

- )} -
- ) : ( -
- - - - - - -
- )} - - {formOpen && ( - { setFormOpen(false); setEditing(null); }} - /> - )} - - { - if (!conn) return; - await containerApi.deleteConfig(conn.id); - onConnectionsChanged(); - onSelectConn(''); - }} - /> -
- ); -} - -function NavItem({ icon: Icon, label, active, onClick }: { - icon: typeof Gauge; label: string; active: boolean; onClick: () => void; -}) { - return ( - - ); -} diff --git a/src/components/tools/container/LogsPanel.tsx b/src/components/tools/container/LogsPanel.tsx deleted file mode 100644 index 880c3a8d..00000000 --- a/src/components/tools/container/LogsPanel.tsx +++ /dev/null @@ -1,639 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; -import { - Search, Pause, Play, WrapText, X, Clock, Regex, CaseSensitive, - ChevronUp, ChevronDown, ArrowDownToLine, Eraser, Download, AlertTriangle, -} from 'lucide-react'; -import { Input } from '@/components/ui/input'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { DateTimePicker } from '@/components/ui/date-time-picker'; -import { CopyButton } from '@/components/ui/copy-button'; -import { Spinner } from '@/components/ui/spinner'; -import { saveTextFile } from '@/lib/fileio'; -import { usePluginSdkFor } from '@/platform'; -import { MOD_KEY } from '@/lib/platform'; -import { cn } from '@/lib/utils'; -import type { ContainerStreamSubscription, LogLine } from './types'; - -const MAX_LINES = 5000; -const TAIL_OPTIONS = ['100', '500', '1000', '5000', 'all'] as const; -/** Incoming lines are painted in batches on this cadence instead of one - * setState per line: a chatty container emits hundreds of lines a second, and - * a render per line locks the UI up (the search box stops accepting input). - * ~8 repaints a second still reads as live. */ -const FLUSH_MS = 120; -/** How close to the bottom still counts as "at the bottom" — a few pixels of - * slack so sub-pixel scroll heights don't read as "user scrolled away". */ -const BOTTOM_SLACK_PX = 24; - -type StreamFilter = 'all' | 'stdout' | 'stderr'; -/** Filter hides non-matching lines; highlight keeps them and steps between - * matches. Both are useful and neither replaces the other: filtering answers - * "how often does this happen", highlighting answers "what happened around - * it". */ -type SearchMode = 'filter' | 'highlight'; - -function toEpochSeconds(ms: number | null): number { - return ms === null ? 0 : Math.floor(ms / 1000); -} - -/** `2026-08-26T09:41:02.123456789Z` → `09:41:02.123`. The date is dropped: on - * screen every line is almost always the same day, and the full value stays - * in the row's `title` and in a copy/save export. */ -export function formatTimestamp(ts: string): string { - const t = ts.indexOf('T'); - if (t === -1) return ts; - const time = ts.slice(t + 1).replace('Z', ''); - const dot = time.indexOf('.'); - return dot === -1 ? time : time.slice(0, dot + 4); -} - -/** One matcher for both plain and regex search, so every consumer (filtering, - * match counting, highlighting) agrees on what a match is. Returns null for - * an empty query, and throws only through `error` — never at render time. */ -export function buildMatcher(query: string, regex: boolean, caseSensitive: boolean): { - test: ((s: string) => boolean) | null; - ranges: ((s: string) => [number, number][]) | null; - error: string | null; -} { - const q = query.trim(); - if (!q) return { test: null, ranges: null, error: null }; - - if (regex) { - try { - const re = new RegExp(q, caseSensitive ? 'g' : 'gi'); - return { - test: (s) => { re.lastIndex = 0; return re.test(s); }, - ranges: (s) => { - const out: [number, number][] = []; - re.lastIndex = 0; - let m = re.exec(s); - while (m) { - // A zero-length match (e.g. `a*`) would loop forever — step past it. - if (m[0].length === 0) { re.lastIndex++; m = re.exec(s); continue; } - out.push([m.index, m.index + m[0].length]); - m = re.exec(s); - } - return out; - }, - error: null, - }; - } catch (e) { - return { test: null, ranges: null, error: e instanceof Error ? e.message : 'Invalid regular expression' }; - } - } - - const needle = caseSensitive ? q : q.toLowerCase(); - const prep = (s: string) => (caseSensitive ? s : s.toLowerCase()); - return { - test: (s) => prep(s).includes(needle), - ranges: (s) => { - const out: [number, number][] = []; - const hay = prep(s); - let i = hay.indexOf(needle); - while (i !== -1) { - out.push([i, i + needle.length]); - i = hay.indexOf(needle, i + needle.length); - } - return out; - }, - error: null, - }; -} - -/** - * Streaming log viewer shared by container logs and compose logs — both back - * onto the same `LogLine { stream, message, timestamp }` shape and the same - * start(tail, since, until, timestamps, onLog)/stop(id) start-a-Channel/ - * stop-by-id lifecycle. - * - * `tail`/`since`/`until`/`timestamps` are the daemon-side window: changing any - * of them restarts the stream. Everything else — follow, stream filter, - * search, wrap — is client-side, so the socket keeps running and toggling - * them back never loses history or re-fetches. - */ -export function LogsPanel({ start, name }: { - start: ( - tail: string, since: number, until: number, timestamps: boolean, onLine: (line: LogLine) => void, - ) => Promise; - /** Used for the exported file name — e.g. `api-1` → `api-1-logs.log`. */ - name?: string; -}) { - const sdk = usePluginSdkFor('container-manager'); - const [tail, setTail] = useState('500'); - // Mốc thời gian giữ ở epoch-ms | null (null = không giới hạn) thay cho chuỗi - // "yyyy-MM-ddTHH:mm" của đã bỏ — xem khối - // picker bên dưới. - const [sinceMs, setSinceMs] = useState(null); - const [untilMs, setUntilMs] = useState(null); - const [timestamps, setTimestamps] = useState(false); - const [follow, setFollow] = useState(true); - const [keyword, setKeyword] = useState(''); - const [regex, setRegex] = useState(false); - const [caseSensitive, setCaseSensitive] = useState(false); - const [searchMode, setSearchMode] = useState('filter'); - const [streamFilter, setStreamFilter] = useState('all'); - const [wrap, setWrap] = useState(true); - - const [lines, setLines] = useState([]); - const [error, setError] = useState(null); - const [connecting, setConnecting] = useState(true); - /** Set once the buffer has wrapped, so the cap is stated rather than silent. */ - const [truncated, setTruncated] = useState(false); - const [atBottom, setAtBottom] = useState(true); - const [activeMatch, setActiveMatch] = useState(0); - - // Dòng log đến trong lúc TẠM DỪNG. Bản trước vứt thẳng chúng đi ("stop - // growing the view"), nên khoảng thời gian người dùng dừng lại để ĐỌC chính - // là khoảng bị mất log vĩnh viễn — không có dấu hiệu nào báo là đã hổng, và - // socket vẫn chạy nên bấm Live lại cũng không kéo về được. Giờ đệm lại rồi - // xả ra khi tiếp tục; `pendingCount` để nút Paused nói rõ đang giữ bao nhiêu. - const pendingRef = useRef([]); - const [pendingCount, setPendingCount] = useState(0); - /** Lines received since the last paint — flushed on the FLUSH_MS tick. */ - const incomingRef = useRef([]); - const scrollRef = useRef(null); - const searchRef = useRef(null); - const rootRef = useRef(null); - const startRef = useRef(start); - const followRef = useRef(follow); - startRef.current = start; - followRef.current = follow; - - const since = useMemo(() => toEpochSeconds(sinceMs), [sinceMs]); - const until = useMemo(() => toEpochSeconds(untilMs), [untilMs]); - - const appendCapped = useCallback((prev: LogLine[], incoming: LogLine[]): LogLine[] => { - const next = prev.length === 0 ? incoming : [...prev, ...incoming]; - if (next.length <= MAX_LINES) return next; - setTruncated(true); - return next.slice(next.length - MAX_LINES); - }, []); - - useEffect(() => { - setLines([]); - setError(null); - setConnecting(true); - setTruncated(false); - incomingRef.current = []; - let cancelled = false; - let subscription: ContainerStreamSubscription | null = null; - - // Đăng ký qua SDK nên phải chờ; phần khởi động stream vì thế nằm trong - // một IIFE async, còn `cancelled` vẫn là chốt duy nhất cho việc dọn dẹp. - const onLine = (line: LogLine) => { - // Tạm dừng: giữ socket mở và đệm dòng mới lại (cắt theo cùng trần - // MAX_LINES để việc dừng lâu không phình bộ nhớ), thay vì loại bỏ. - if (!followRef.current) { - pendingRef.current = pendingRef.current.length >= MAX_LINES - ? [...pendingRef.current.slice(pendingRef.current.length - MAX_LINES + 1), line] - : [...pendingRef.current, line]; - setPendingCount(pendingRef.current.length); - return; - } - incomingRef.current.push(line); - }; - - // One repaint per tick, however many lines arrived in between. - const flush = window.setInterval(() => { - if (incomingRef.current.length === 0) return; - const batch = incomingRef.current; - incomingRef.current = []; - setConnecting(false); - setLines((prev) => appendCapped(prev, batch)); - }, FLUSH_MS); - - void (async () => { - try { - const sub = await startRef.current(tail, since, until, timestamps, onLine); - if (cancelled) { sub.stop().catch(() => {}); return; } - subscription = sub; - // A container that is simply quiet never sends a first line; stop - // showing "Connecting…" once the stream itself is established. - setConnecting(false); - } catch (e) { - setConnecting(false); - setError(String(e instanceof Error ? e.message : e)); - } - })(); - - return () => { - cancelled = true; - window.clearInterval(flush); - // Đổi cửa sổ tail/since/until là nạp lại từ đầu — đệm cũ thuộc về stream - // trước, giữ lại chỉ tạo ra một khoảng lẫn lộn giữa hai lần truy vấn. - pendingRef.current = []; - incomingRef.current = []; - setPendingCount(0); - if (subscription) subscription.stop().catch(() => {}); - }; - }, [tail, since, until, timestamps, appendCapped]); - - // Tiếp tục chạy → xả đệm vào view theo đúng thứ tự đã đến. - useEffect(() => { - if (!follow || pendingRef.current.length === 0) return; - const buffered = pendingRef.current; - pendingRef.current = []; - setPendingCount(0); - setLines((prev) => appendCapped(prev, buffered)); - }, [follow, appendCapped]); - - const scrollToBottom = useCallback(() => { - const el = scrollRef.current; - if (el) el.scrollTop = el.scrollHeight; - }, []); - - useEffect(() => { - if (follow && atBottom) scrollToBottom(); - }, [lines, follow, atBottom, scrollToBottom]); - - /** Scrolling up while live pauses the tail — otherwise the next batch yanks - * the viewport back down and the line being read is gone. Scrolling back to - * the bottom resumes it, so no button press is needed either way. */ - const onScroll = () => { - const el = scrollRef.current; - if (!el) return; - const bottom = el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_SLACK_PX; - setAtBottom(bottom); - if (!bottom && followRef.current) setFollow(false); - }; - - const matcher = useMemo(() => buildMatcher(keyword, regex, caseSensitive), [keyword, regex, caseSensitive]); - const searchError = matcher.error; - - const streamFiltered = useMemo( - () => (streamFilter === 'all' ? lines : lines.filter((l) => l.stream === streamFilter)), - [lines, streamFilter], - ); - - /** Rows actually rendered, plus which of them match — filter mode drops the - * rest, highlight mode keeps them and only marks the matches. */ - const { rows, matchIndexes } = useMemo(() => { - if (!matcher.test) return { rows: streamFiltered, matchIndexes: [] as number[] }; - if (searchMode === 'filter') { - const kept = streamFiltered.filter((l) => matcher.test!(l.message)); - return { rows: kept, matchIndexes: kept.map((_, i) => i) }; - } - const idx: number[] = []; - streamFiltered.forEach((l, i) => { if (matcher.test!(l.message)) idx.push(i); }); - return { rows: streamFiltered, matchIndexes: idx }; - }, [streamFiltered, matcher, searchMode]); - - // Keep the active match in range as lines stream in or the query changes. - useEffect(() => { setActiveMatch(0); }, [keyword, regex, caseSensitive, searchMode, streamFilter]); - - const stepMatch = (delta: number) => { - // Only meaningful while every line is on screen — in filter mode the rows - // ARE the matches, so stepping would just scroll for no reason. - if (searchMode !== 'highlight' || matchIndexes.length === 0) return; - const next = (activeMatch + delta + matchIndexes.length) % matchIndexes.length; - setActiveMatch(next); - setFollow(false); - const el = scrollRef.current?.querySelector(`[data-row="${matchIndexes[next]}"]`); - el?.scrollIntoView({ block: 'center' }); - }; - - /** Exactly what is on screen, with the timestamps if they're shown — a copy - * that silently dropped the filters would be worse than useless. */ - const exportText = useCallback( - () => rows.map((l) => (l.timestamp ? `${l.timestamp} ${l.message}` : l.message)).join('\n'), - [rows], - ); - - // ⌘/Ctrl+F focuses the panel's own search rather than the webview's find - // bar, which cannot see virtualised or filtered-out lines anyway. - useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'f') { - if (!rootRef.current?.isConnected) return; - e.preventDefault(); - searchRef.current?.focus(); - searchRef.current?.select(); - } - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, []); - - const hasWindow = sinceMs !== null || untilMs !== null; - - return ( -
- {/* Row 1 — search and everything that acts on what is already loaded. */} -
-
- - setKeyword(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Escape') { setKeyword(''); e.currentTarget.blur(); } - if (e.key === 'Enter') stepMatch(e.shiftKey ? -1 : 1); - }} - placeholder={`Search logs… ${MOD_KEY}F`} - aria-label="Search logs" - aria-invalid={!!searchError} - className={cn('h-ctl w-full pl-7 pr-7 text-xs', searchError && 'border-bad focus-visible:border-bad focus-visible:ring-bad/30')} - /> - {keyword && ( - - )} -
- - setRegex((r) => !r)} title={regex ? 'Regular expression: on' : 'Regular expression: off'}> - - - setCaseSensitive((c) => !c)} - title={caseSensitive ? 'Case sensitive: on' : 'Case sensitive: off'} - > - - - setSearchMode((m) => (m === 'filter' ? 'highlight' : 'filter'))} - title={searchMode === 'filter' - ? 'Filtering to matching lines — click to keep every line and just highlight matches' - : 'Highlighting matches in place — click to hide non-matching lines'} - > - {searchMode === 'filter' ? 'Filter' : 'Highlight'} - - - {matchIndexes.length > 0 && keyword && searchMode === 'highlight' && ( - - stepMatch(-1)}> - - {activeMatch + 1}/{matchIndexes.length.toLocaleString()} - - stepMatch(1)}> - - )} -
- - {/* Row 2 — the daemon-side window (restarts the stream) and view options. */} -
- - - {/* Ba control này từng là - gốc của trình duyệt — thứ mà docs/design/DESIGN-SYSTEM.md cấm hẳn: - chúng vẽ khác nhau hoàn toàn giữa WKWebView (macOS), WebView2 - (Windows) và WebKitGTK (Linux) — riêng WebKitGTK còn không dựng nổi - lịch cho datetime-local, để lại một ô chữ trống người dùng phải tự - gõ đúng định dạng. Repo đã sẵn Select/DatePicker/TimePicker viết ra - đúng để thay chúng; đây là chỗ duy nhất còn sót. */} - - - – - - - - - { setFollow((f) => !f); if (!follow) scrollToBottom(); }} - title={follow - ? 'Live tailing — click to pause' - : `Paused — ${pendingCount.toLocaleString()} new line(s) held, click to resume and show them`} - > - {follow ? : } - {follow ? 'Live' : pendingCount > 0 ? `Paused · ${pendingCount.toLocaleString()} held` : 'Paused'} - - setTimestamps((t) => !t)} - title={timestamps ? 'Timestamps: on — click to hide (reloads)' : 'Timestamps: off — click to show (reloads)'} - > - - - setWrap((w) => !w)} title={wrap ? 'Wrap long lines: on' : 'Wrap long lines: off'}> - - - - - - void saveTextFile(sdk, `${name ?? 'container'}-logs.log`, exportText())} - title="Save the lines currently shown to a file" - > - Save - - { setLines([]); setTruncated(false); }} - title="Clear the view — the stream keeps running" - > - - - -
- -
- {searchError ? ( - - {searchError} - - ) : keyword ? ( - - {searchMode === 'filter' - ? `${rows.length.toLocaleString()} of ${streamFiltered.length.toLocaleString()} lines match` - : `${matchIndexes.length.toLocaleString()} matching line(s)`} - - ) : ( - {streamFiltered.length.toLocaleString()} line(s) - )} - {truncated && buffer capped at {MAX_LINES.toLocaleString()}} -
- -
-
- {error &&

{error}

} - {!error && connecting && rows.length === 0 && ( -

Connecting to the log stream…

- )} - {!error && !connecting && rows.length === 0 && ( -

- {keyword - ? 'No lines match this search.' - : streamFilter !== 'all' - ? `Nothing on ${streamFilter} in this window.` - : hasWindow - ? 'No logs in this time range — widen "since"/"until" to see more.' - : 'No output yet — new lines appear here as the container writes them.'} -

- )} - {rows.map((l, i) => ( -
- {l.timestamp && ( - {formatTimestamp(l.timestamp)} - )} - - {highlightMatch(l.message, matcher.ranges)} - -
- ))} -
- - {/* Only offered when it does something: the view is scrolled away from - the newest line. Doubles as the "you are not seeing live output" - indicator, which the Paused button alone did not make obvious. */} - {!atBottom && ( - - )} -
-
- ); -} - -/** Một mốc thời gian tuỳ chọn: chưa đặt thì chỉ là nút "Set…", đặt rồi thì hiện - * bộ chọn ngày+giờ kèm nút xoá để quay lại "không giới hạn". `DateTimePicker` - * bắt buộc có giá trị (epoch ms), nên trạng thái "chưa đặt" phải nằm ở đây. */ -function LogTimeBound({ label, value, onChange }: { - label: string; value: number | null; onChange: (ms: number | null) => void; -}) { - if (value === null) { - return ( - { const d = new Date(); d.setMinutes(0, 0, 0); onChange(d.getTime()); }} - title={label} - > - {label.includes('since') ? 'Since…' : 'Until…'} - - ); - } - return ( - - - - - ); -} - -function IconStep({ title, onClick, children }: { title: string; onClick: () => void; children: ReactNode }) { - return ( - - ); -} - -function LogToggleButton({ active, onClick, title, children }: { - active: boolean; onClick: () => void; title: string; children: ReactNode; -}) { - return ( - - ); -} - -// Wraps each match in so it's visible against the monospace log -// background without re-coloring the whole line. Ranges come from the shared -// matcher, so plain and regex search highlight identically. -function highlightMatch(message: string, ranges: ((s: string) => [number, number][]) | null): ReactNode { - if (!ranges) return message; - const found = ranges(message); - if (found.length === 0) return message; - const parts: ReactNode[] = []; - let i = 0; - for (const [from, to] of found) { - if (from > i) parts.push(message.slice(i, from)); - parts.push({message.slice(from, to)}); - i = to; - } - if (i < message.length) parts.push(message.slice(i)); - return parts; -} diff --git a/src/components/tools/container/NetworkDetailsDialog.tsx b/src/components/tools/container/NetworkDetailsDialog.tsx deleted file mode 100644 index 215954ab..00000000 --- a/src/components/tools/container/NetworkDetailsDialog.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Callout } from '@/components/ui/callout'; -import { LoadingRow } from '@/components/ui/spinner'; -import { CollapsibleSection } from '@/components/ui/collapsible-section'; -import { Badge } from '@/components/ui/badge'; -import { DataTable, Thead, Tbody, Tr, Th, Td } from '@/components/ui/data-table'; -import { type ContainerConnection, type NetworkDetails, type NetworkInfo } from './types'; -import { useContainerApi } from './api_sdk'; -import { DetailField, DetailGrid, KeyValueTable, labelEntries } from './DetailRows'; - -/** - * The subnet, gateway and attached containers are what anyone opens a network - * for — "why can't these two containers reach each other" is answered here or - * not at all. Attachments come from the caller's container-derived usage index - * rather than from the inspect payload; see network_details in - * devtool-svc-container.rs for why. - */ -export function NetworkDetailsDialog({ open, onOpenChange, connection, network, users }: { - open: boolean; - onOpenChange: (o: boolean) => void; - connection: ContainerConnection; - network: NetworkInfo | null; - users?: string[]; -}) { - const containerApi = useContainerApi(); - const [details, setDetails] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (!open || !network) { setDetails(null); return; } - setLoading(true); - setError(null); - containerApi.networkDetails(connection, network.Id) - .then(setDetails) - .catch((e) => setError(String(e instanceof Error ? e.message : e))) - .finally(() => setLoading(false)); - }, [open, network, connection]); - - return ( - - - - {details?.name ?? network?.Name ?? ''} - -
- {loading && } - {error && {error}} - {details && !loading && ( - <> - - - - - - - 0 ? `${users.length} container(s)` : 'nothing'} /> - - {details.internal && internal} - {details.attachable && attachable} - {details.ingress && ingress} - {details.ipv6 && IPv6} - {!details.internal && !details.attachable && !details.ingress && !details.ipv6 && ( - none - )} - - )} - /> - - - {details.ipamConfig.length > 0 && ( - - - SubnetGatewayIP range - - {details.ipamConfig.map((c, i) => ( - - {c.subnet || '—'} - {c.gateway || '—'} - {c.ipRange || '—'} - - ))} - - - - )} - - {users && users.length > 0 && ( - -
    - {users.map((u) =>
  • {u}
  • )} -
-
- )} - - - - - - - - - - )} -
-
-
- ); -} diff --git a/src/components/tools/container/NetworksView.tsx b/src/components/tools/container/NetworksView.tsx deleted file mode 100644 index 2aeb3d74..00000000 --- a/src/components/tools/container/NetworksView.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Network as NetworkIcon, RefreshCw, Trash2, Plus, Info } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { ViewHeader } from '@/components/ui/view-header'; -import { SearchInput } from '@/components/ui/search-input'; -import { Callout } from '@/components/ui/callout'; -import { LoadingRow } from '@/components/ui/spinner'; -import { DataTable, Thead, Tbody, Tr, Th, Td } from '@/components/ui/data-table'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; -import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { IconButton } from '@/components/ui/icon-button'; -import { cn } from '@/lib/utils'; -import { Badge } from '@/components/ui/badge'; -import { type ContainerConnection, type NetworkInfo } from './types'; -import { useContainerApi } from './api_sdk'; -import { PruneButton } from './PruneButton'; -import { NetworkDetailsDialog } from './NetworkDetailsDialog'; -import { useUsageIndex, describeUsers } from './usage'; -import { useSort } from './useSort'; -import { useRowSelection } from './useRowSelection'; -import { RowCheckbox, SelectionBar } from './SelectionBar'; - -const BUILTIN_NETWORKS = new Set(['bridge', 'host', 'none']); - -export function NetworksView({ connection, refreshKey, onRefresh }: { - connection: ContainerConnection; - refreshKey: number; - onRefresh: () => void; -}) { - const containerApi = useContainerApi(); - const [filter, setFilter] = useState(''); - const [networks, setNetworks] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [createOpen, setCreateOpen] = useState(false); - const [removeTarget, setRemoveTarget] = useState(null); - const [bulkRemoveOpen, setBulkRemoveOpen] = useState(false); - const [bulkBusy, setBulkBusy] = useState(false); - const [detailsTarget, setDetailsTarget] = useState(null); - const [notice, setNotice] = useState(null); - const { usage, reload: reloadUsage } = useUsageIndex(connection, refreshKey); - - const load = useCallback(() => { - setLoading(true); - setError(null); - reloadUsage(); - containerApi.networkList(connection) - .then(setNetworks) - .catch((e) => { setNetworks([]); setError(String(e instanceof Error ? e.message : e)); }) - .finally(() => setLoading(false)); - }, [connection, reloadUsage]); - - const f = filter.trim().toLowerCase(); - const filtered = useMemo(() => (networks ?? []).filter((n) => n.Name.toLowerCase().includes(f)), [networks, f]); - const { sorted: rows, toggleSort, directionFor } = useSort(filtered, { - name: (n) => n.Name, - driver: (n) => n.Driver ?? '', - scope: (n) => n.Scope ?? '', - used: (n) => usage.byNetwork.get(n.Name)?.length ?? 0, - }); - - // `bridge`/`host`/`none` can't be removed, so they're not selectable either - // — offering a checkbox that can only ever produce a daemon error would be - // worse than not offering one. - const selectableRows = useMemo(() => rows.filter((n) => !BUILTIN_NETWORKS.has(n.Name)), [rows]); - const selection = useRowSelection(selectableRows, useCallback((n: NetworkInfo) => n.Name, [])); - const { prune, clear } = selection; - - useEffect(() => { load(); clear(); }, [load, refreshKey, clear]); - useEffect(() => { if (networks) prune(networks.map((n) => n.Name)); }, [networks, prune]); - - const removeBulk = async (names: string[]) => { - setBulkBusy(true); - setError(null); - const results = await Promise.allSettled(names.map((name) => containerApi.networkRemove(connection, name))); - const failed = results.filter((r) => r.status === 'rejected') as PromiseRejectedResult[]; - if (failed.length > 0) { - const first = failed[0].reason; - setError(`${failed.length} of ${names.length} failed — ${String(first instanceof Error ? first.message : first)}`); - } - setBulkBusy(false); - clear(); - load(); - }; - - return ( - // `relative` anchors the floating SelectionBar (see SelectionBar.tsx). -
- - - containerApi.networkPrune(connection), - }]} - onDone={(m) => { setNotice(m); setError(null); load(); }} - onError={(m) => { setNotice(null); setError(m); }} - /> - - - )} - /> - -
- -
- - - - - -
0 && 'pb-20')}> - {loading && !networks && } - {error && {error}} - {notice && !error && {notice}} - {networks && !error && ( - rows.length === 0 - ?

{f ? 'No matching networks.' : 'No networks.'}

- : ( - - - - - - - toggleSort('name')}>Name - toggleSort('driver')}>Driver - toggleSort('scope')}>Scope - toggleSort('used')}>In use - - - - - {rows.map((n) => ( - setDetailsTarget(n)}> - e.stopPropagation()}> - {!BUILTIN_NETWORKS.has(n.Name) && ( - selection.toggle(n.Name, selectableRows.findIndex((r) => r.Name === n.Name), e.shiftKey)} - title="Select network" - /> - )} - - {n.Name} - {n.Driver} - {n.Scope} - - - {(() => { - const users = usage.byNetwork.get(n.Name); - return users && users.length > 0 - ? {users.length} container{users.length > 1 ? 's' : ''} - : unused; - })()} - - - e.stopPropagation()}> -
- setDetailsTarget(n)}> - - - {!BUILTIN_NETWORKS.has(n.Name) && ( - setRemoveTarget(n)}> - - - )} -
- - - ))} - -
- ) - )} -
- - - - { if (!o) setDetailsTarget(null); }} - connection={connection} - network={detailsTarget} - users={detailsTarget ? usage.byNetwork.get(detailsTarget.Name) : undefined} - /> - - { if (!o) setRemoveTarget(null); }} - title="Remove network?" - description={removeTarget ? `Remove "${removeTarget.Name}".` : ''} - confirmLabel="Remove" - onConfirm={async () => { - if (!removeTarget) return; - await containerApi.networkRemove(connection, removeTarget.Name); - setRemoveTarget(null); - load(); - }} - /> - - removeBulk(selection.keys)} - /> -
- ); -} - -function CreateNetworkDialog({ open, onOpenChange, connection, onCreated }: { - open: boolean; onOpenChange: (o: boolean) => void; connection: ContainerConnection; onCreated: () => void; -}) { - const containerApi = useContainerApi(); - const [name, setName] = useState(''); - const [driver, setDriver] = useState('bridge'); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - const create = async () => { - if (!name.trim()) { setError('Name is required'); return; } - setBusy(true); - setError(null); - try { - await containerApi.networkCreate(connection, name.trim(), driver.trim() || 'bridge'); - onCreated(); - onOpenChange(false); - setName(''); - } catch (e) { - setError(String(e instanceof Error ? e.message : e)); - } finally { - setBusy(false); - } - }; - - return ( - - - New network -
-
- setName(e.target.value)} disabled={busy} className="font-mono text-sm" /> - setDriver(e.target.value)} disabled={busy} className="font-mono text-sm w-28" /> -
- {error && {error}} -
- - - - -
-
- ); -} diff --git a/src/components/tools/container/OverviewView.tsx b/src/components/tools/container/OverviewView.tsx deleted file mode 100644 index d5f0ba3e..00000000 --- a/src/components/tools/container/OverviewView.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { Gauge, RefreshCw } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { ViewHeader } from '@/components/ui/view-header'; -import { Callout } from '@/components/ui/callout'; -import { LoadingRow } from '@/components/ui/spinner'; -import { Stat } from '@/components/ui/stat'; -import { SectionLabel } from '@/components/ui/section-label'; -import { DataTable, Thead, Tbody, Tr, Th, Td } from '@/components/ui/data-table'; -import { type ContainerConnection, type DiskUsageSummary, type SystemDataUsageResponse, type SystemInfo } from './types'; -import { useContainerApi } from './api_sdk'; -import { PruneButton, type PruneVariant } from './PruneButton'; -import { formatBytes } from './format'; - -export function OverviewView({ connection, refreshKey, onRefresh }: { - connection: ContainerConnection; - refreshKey: number; - onRefresh: () => void; -}) { - const containerApi = useContainerApi(); - const [info, setInfo] = useState(null); - // Disk usage is a separate, much slower call than system info (the daemon - // walks every image layer and container to compute it), so it loads on its - // own and the stats above it render immediately. - const [usage, setUsage] = useState(null); - const [usageLoading, setUsageLoading] = useState(false); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [notice, setNotice] = useState(null); - - const load = useCallback(() => { - setLoading(true); - setError(null); - containerApi.systemInfo(connection) - .then(setInfo) - .catch((e) => setError(String(e instanceof Error ? e.message : e))) - .finally(() => setLoading(false)); - - setUsageLoading(true); - containerApi.systemDf(connection) - .then(setUsage) - .catch(() => setUsage(null)) - .finally(() => setUsageLoading(false)); - }, [connection]); - - useEffect(() => { load(); }, [load, refreshKey]); - - const afterPrune = (message: string) => { setNotice(message); setError(null); load(); }; - const onPruneError = (message: string) => { setNotice(null); setError(message); }; - - // Deliberately not a single "prune everything" button: each of these - // destroys a different class of thing, and one of them (images -a) can cost - // a long re-pull, so they stay separate and separately confirmed. - const pruneVariants: PruneVariant[] = [ - { - label: 'Prune stopped containers', - description: 'Remove every container that is not running (docker container prune). Their writable layers and any data not on a volume are lost.', - run: () => containerApi.prune(connection), - }, - { - label: 'Prune dangling images', - description: 'Remove every untagged image layer left behind by rebuilds (docker image prune). Tagged images are kept.', - run: () => containerApi.imagePrune(connection, true), - }, - { - label: 'Prune unused networks', - description: 'Remove every user-defined network no container is attached to (docker network prune).', - run: () => containerApi.networkPrune(connection), - }, - { - label: 'Prune unused volumes', - danger: true, - description: 'Remove every volume no container is using, and all data in them (docker volume prune). This cannot be undone.', - run: () => containerApi.volumePrune(connection), - }, - ]; - - return ( -
- - - - - )} - /> -
- {loading && !info && } - {error && {error}} - {notice && !error && {notice}} - {info && !error && ( -
- - - - - - - - -
- )} - - {(usage || usageLoading) && ( -
- Disk usage - {usageLoading && !usage ? : usage && ( - - - - Type - Active - Total - Size - Reclaimable - - - - - - - - - - )} -
- )} -
-
- ); -} - -/** One row of `docker system df`. The daemon reports `Reclaimable` as bytes; - * the share it represents is what tells you whether pruning is worth it, so - * it's shown alongside rather than left to be worked out. */ -function UsageRow({ label, usage }: { label: string; usage?: DiskUsageSummary }) { - const total = usage?.TotalSize ?? 0; - const reclaimable = usage?.Reclaimable ?? 0; - const share = total > 0 ? Math.round((reclaimable / total) * 100) : 0; - return ( - - {label} - {usage?.ActiveCount ?? 0} - {usage?.TotalCount ?? 0} - {formatBytes(total)} - - {reclaimable > 0 ? `${formatBytes(reclaimable)} (${share}%)` : '—'} - - - ); -} diff --git a/src/components/tools/container/PruneButton.tsx b/src/components/tools/container/PruneButton.tsx deleted file mode 100644 index 44378574..00000000 --- a/src/components/tools/container/PruneButton.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { useState } from 'react'; -import { Trash2, ChevronDown } from 'lucide-react'; -import { Button, buttonVariants } from '@/components/ui/button'; -import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; -import { cn } from '@/lib/utils'; -import { formatBytes } from './format'; -import type { PruneResult } from './types'; - -/** - * The "reclaim disk" control shared by the four resource views. Each view's - * prune differs only in wording and in which endpoint runs, so the confirm - * step, the busy state and the "removed N, reclaimed X" reporting live here - * once. A single variant renders one button; several (images: dangling vs. - * every unused image) render a menu, since the two differ enough in - * destructiveness that they must not share one click target. - */ -export interface PruneVariant { - label: string; - /** Shown in the confirm dialog — say exactly what will be deleted. */ - description: string; - run: () => Promise; - danger?: boolean; -} - -/** "Removed 4 · reclaimed 1.2 GB" / "Nothing to remove" */ -export function describePruneResult(result: PruneResult, noun: string): string { - if (result.deleted === 0 && result.spaceReclaimed === 0) return `No unused ${noun} to remove.`; - const space = result.spaceReclaimed > 0 ? ` · reclaimed ${formatBytes(result.spaceReclaimed)}` : ''; - return `Removed ${result.deleted.toLocaleString()} ${noun}${space}.`; -} - -export function PruneButton({ variants, noun, onDone, onError }: { - variants: PruneVariant[]; - /** Plural noun for the result line: "images", "volumes", … */ - noun: string; - onDone: (message: string) => void; - onError: (message: string) => void; -}) { - const [pending, setPending] = useState(null); - const [busy, setBusy] = useState(false); - - const confirm = async () => { - if (!pending) return; - setBusy(true); - try { - onDone(describePruneResult(await pending.run(), noun)); - } catch (e) { - onError(String(e instanceof Error ? e.message : e)); - } finally { - setBusy(false); - setPending(null); - } - }; - - return ( - <> - {variants.length === 1 ? ( - - ) : ( - - - Prune - - - {variants.map((v) => ( - setPending(v)}> - {v.label} - - ))} - - - )} - - { if (!o && !busy) setPending(null); }} - title={pending?.label ?? ''} - description={pending?.description ?? ''} - confirmLabel="Prune" - onConfirm={confirm} - /> - - ); -} diff --git a/src/components/tools/container/SelectionBar.tsx b/src/components/tools/container/SelectionBar.tsx deleted file mode 100644 index 96345f72..00000000 --- a/src/components/tools/container/SelectionBar.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import type { ReactNode } from 'react'; -import { Check, Minus } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; - -/** - * Row / header checkbox for the container tool's tables. - * - * The unchecked box is outlined in `fg-mute/70` rather than `sunk`: `sunk` is - * 97% lightness on light and 11% on dark, i.e. within a hair of the surface it - * sits on, which made the box effectively invisible in both themes. This - * clears the 3:1 non-text contrast minimum against the card in both. - * - * The header instance takes `indeterminate` so a partial selection reads as a - * dash rather than as "nothing selected" — without it, clicking a half-filled - * header checkbox looks like it should clear when it actually selects. - */ -export function RowCheckbox({ checked, indeterminate, onToggle, title, disabled }: { - checked: boolean; - indeterminate?: boolean; - /** Receives the raw event so callers can honour shift-click range select. */ - onToggle: (e: React.MouseEvent) => void; - title: string; - disabled?: boolean; -}) { - const active = checked || !!indeterminate; - return ( - - ); -} - -/** - * Bulk-action bar for a selection. - * - * It floats over the bottom of the list instead of being inserted above the - * table: as an in-flow element it appeared and disappeared as rows were - * ticked and unticked, shoving the whole table down and back a row's height - * on every click. Absolutely positioned, ticking a row changes nothing about - * the table's geometry. The parent view must be `relative` for the anchor to - * resolve; the short fade/slide-in is the state transition (suppressed by the - * global prefers-reduced-motion rule). - */ -export function SelectionBar({ count, unselectedVisibleCount, onSelectAllVisible, onClear, children }: { - count: number; - unselectedVisibleCount: number; - onSelectAllVisible: () => void; - onClear: () => void; - children?: ReactNode; -}) { - if (count === 0) return null; - return ( -
-
-
- {count.toLocaleString()} selected - {unselectedVisibleCount > 0 && ( - - )} -
-
- {children} - -
-
-
- ); -} diff --git a/src/components/tools/container/VolumeDetailsDialog.tsx b/src/components/tools/container/VolumeDetailsDialog.tsx deleted file mode 100644 index 6dcc9180..00000000 --- a/src/components/tools/container/VolumeDetailsDialog.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Callout } from '@/components/ui/callout'; -import { LoadingRow } from '@/components/ui/spinner'; -import { CollapsibleSection } from '@/components/ui/collapsible-section'; -import { type ContainerConnection, type VolumeDetails } from './types'; -import { useContainerApi } from './api_sdk'; -import { DetailField, DetailGrid, KeyValueTable, labelEntries } from './DetailRows'; -import { formatBytes } from './format'; - -/** - * Volumes had no details view at all — the list shows name, driver, size and - * mountpoint, which leaves the two things you actually need when a volume is - * misbehaving unanswered: which containers have it mounted, and what driver - * options it was created with. The first comes from the caller (it already - * has the container-derived usage index, see usage.ts); the rest from - * `volume_details`. - */ -export function VolumeDetailsDialog({ open, onOpenChange, connection, name, users }: { - open: boolean; - onOpenChange: (o: boolean) => void; - connection: ContainerConnection; - name: string | null; - /** Names of the containers mounting this volume, from the usage index. */ - users?: string[]; -}) { - const containerApi = useContainerApi(); - const [details, setDetails] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (!open || !name) { setDetails(null); return; } - setLoading(true); - setError(null); - containerApi.volumeDetails(connection, name) - .then(setDetails) - .catch((e) => setError(String(e instanceof Error ? e.message : e))) - .finally(() => setLoading(false)); - }, [open, name, connection]); - - return ( - - - - {details?.name ?? name ?? ''} - -
- {loading && } - {error && {error}} - {details && !loading && ( - <> - - - - - - 0 ? `${users.length} container(s)` : 'nothing'} /> - - - - {users && users.length > 0 && ( - -
    - {users.map((u) =>
  • {u}
  • )} -
-
- )} - - - - - - - - - - )} -
-
-
- ); -} diff --git a/src/components/tools/container/VolumesView.tsx b/src/components/tools/container/VolumesView.tsx deleted file mode 100644 index 09b1cc4f..00000000 --- a/src/components/tools/container/VolumesView.tsx +++ /dev/null @@ -1,288 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { HardDrive, RefreshCw, Trash2, Plus, Info } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { ViewHeader } from '@/components/ui/view-header'; -import { SearchInput } from '@/components/ui/search-input'; -import { Callout } from '@/components/ui/callout'; -import { LoadingRow, Spinner } from '@/components/ui/spinner'; -import { DataTable, Thead, Tbody, Tr, Th, Td, DataTableStatus } from '@/components/ui/data-table'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; -import { ConfirmDialog } from '@/components/ui/confirm-dialog'; -import { IconButton } from '@/components/ui/icon-button'; -import { cn } from '@/lib/utils'; -import { Badge } from '@/components/ui/badge'; -import { type ContainerConnection, type VolumeInfo } from './types'; -import { useContainerApi } from './api_sdk'; -import { PruneButton } from './PruneButton'; -import { VolumeDetailsDialog } from './VolumeDetailsDialog'; -import { useUsageIndex, describeUsers } from './usage'; -import { useSort } from './useSort'; -import { useRowSelection } from './useRowSelection'; -import { RowCheckbox, SelectionBar } from './SelectionBar'; -import { formatBytes } from './format'; - -export function VolumesView({ connection, refreshKey, onRefresh }: { - connection: ContainerConnection; - refreshKey: number; - onRefresh: () => void; -}) { - const containerApi = useContainerApi(); - const [filter, setFilter] = useState(''); - const [volumes, setVolumes] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [createOpen, setCreateOpen] = useState(false); - const [removeTarget, setRemoveTarget] = useState(null); - const [bulkRemoveOpen, setBulkRemoveOpen] = useState(false); - const [bulkBusy, setBulkBusy] = useState(false); - const [detailsTarget, setDetailsTarget] = useState(null); - const [notice, setNotice] = useState(null); - const { usage, reload: reloadUsage } = useUsageIndex(connection, refreshKey); - // Volume size isn't part of the plain volume-list endpoint — the daemon has - // to walk every volume's mountpoint on disk to compute it, which is - // noticeably slower (`volume_sizes` — see devtool-svc-container.rs for why that - // isn't just `container_system_df`). Fetched separately so the volume list - // itself still renders instantly; the Size column just shows a spinner - // until this resolves. Empty on Windows (not wired up there). - const [sizesLoading, setSizesLoading] = useState(false); - const [sizeByName, setSizeByName] = useState>({}); - - const load = useCallback(() => { - setLoading(true); - setError(null); - reloadUsage(); - containerApi.volumeList(connection) - .then(setVolumes) - .catch((e) => { setVolumes([]); setError(String(e instanceof Error ? e.message : e)); }) - .finally(() => setLoading(false)); - - setSizesLoading(true); - containerApi.volumeSizes(connection) - .then(setSizeByName) - .catch(() => setSizeByName({})) - .finally(() => setSizesLoading(false)); - }, [connection, reloadUsage]); - - const f = filter.trim().toLowerCase(); - const filtered = useMemo(() => (volumes ?? []).filter((v) => v.Name.toLowerCase().includes(f)), [volumes, f]); - const { sorted: rows, toggleSort, directionFor } = useSort(filtered, { - name: (v) => v.Name, - driver: (v) => v.Driver, - mountpoint: (v) => v.Mountpoint, - size: (v) => sizeByName[v.Name] ?? -1, - used: (v) => usage.byVolume.get(v.Name)?.length ?? 0, - }); - - const selection = useRowSelection(rows, useCallback((v: VolumeInfo) => v.Name, [])); - const { prune, clear } = selection; - - useEffect(() => { load(); clear(); }, [load, refreshKey, clear]); - useEffect(() => { if (volumes) prune(volumes.map((v) => v.Name)); }, [volumes, prune]); - - const removeBulk = async (names: string[]) => { - setBulkBusy(true); - setError(null); - const results = await Promise.allSettled(names.map((name) => containerApi.volumeRemove(connection, name, true))); - const failed = results.filter((r) => r.status === 'rejected') as PromiseRejectedResult[]; - if (failed.length > 0) { - const first = failed[0].reason; - setError(`${failed.length} of ${names.length} failed — ${String(first instanceof Error ? first.message : first)}`); - } - setBulkBusy(false); - clear(); - load(); - }; - - return ( - // `relative` anchors the floating SelectionBar (see SelectionBar.tsx). -
- - - containerApi.volumePrune(connection), - }]} - onDone={(m) => { setNotice(m); setError(null); load(); }} - onError={(m) => { setNotice(null); setError(m); }} - /> - - - )} - /> - -
- -
- - - - - -
0 && 'pb-20')}> - {loading && !volumes && } - {error && {error}} - {notice && !error && {notice}} - {volumes && !error && ( - rows.length === 0 - ?

{f ? 'No matching volumes.' : 'No volumes.'}

- : ( -
- - - - - - - toggleSort('name')}>Name - toggleSort('driver')}>Driver - toggleSort('used')}>In use - toggleSort('size')} sub="bytes" subTone="number">Size - toggleSort('mountpoint')} sub="path" subTone="string">Mountpoint - - - - - {rows.map((v, index) => ( - setDetailsTarget(v.Name)}> - e.stopPropagation()}> - selection.toggle(v.Name, index, e.shiftKey)} - title="Select volume" - /> - - {v.Name} - {v.Driver} - - - {(() => { - const users = usage.byVolume.get(v.Name); - return users && users.length > 0 - ? {users.length} container{users.length > 1 ? 's' : ''} - : unused; - })()} - - - - {sizesLoading && !(v.Name in sizeByName) ? : formatBytes(sizeByName[v.Name])} - - {v.Mountpoint} - e.stopPropagation()}> -
- setDetailsTarget(v.Name)}> - - - setRemoveTarget(v)}> - - -
- - - ))} - -
- {/* Tiêu đề trang ghi TỔNG số, còn bảng thì đang LỌC — hai con số khác - nhau và trước đây không chỗ nào nói ra. Dòng này nói cả hai. */} - -
- ) - )} -
- - - - { if (!o) setDetailsTarget(null); }} - connection={connection} - name={detailsTarget} - users={detailsTarget ? usage.byVolume.get(detailsTarget) : undefined} - /> - - { if (!o) setRemoveTarget(null); }} - title="Remove volume?" - description={removeTarget ? `Remove "${removeTarget.Name}". Data on this volume is lost.` : ''} - confirmLabel="Remove" - onConfirm={async () => { - if (!removeTarget) return; - await containerApi.volumeRemove(connection, removeTarget.Name, true); - setRemoveTarget(null); - load(); - }} - /> - - removeBulk(selection.keys)} - /> -
- ); -} - -function CreateVolumeDialog({ open, onOpenChange, connection, onCreated }: { - open: boolean; onOpenChange: (o: boolean) => void; connection: ContainerConnection; onCreated: () => void; -}) { - const containerApi = useContainerApi(); - const [name, setName] = useState(''); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - const create = async () => { - if (!name.trim()) { setError('Name is required'); return; } - setBusy(true); - setError(null); - try { - await containerApi.volumeCreate(connection, name.trim()); - onCreated(); - onOpenChange(false); - setName(''); - } catch (e) { - setError(String(e instanceof Error ? e.message : e)); - } finally { - setBusy(false); - } - }; - - return ( - - - New volume -
- setName(e.target.value)} disabled={busy} className="font-mono text-sm" /> - {error && {error}} -
- - - - -
-
- ); -} diff --git a/src/components/tools/container/api_sdk.ts b/src/components/tools/container/api_sdk.ts deleted file mode 100644 index 41257d57..00000000 --- a/src/components/tools/container/api_sdk.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useMemo } from 'react'; -import { usePluginSdkFor } from '@/platform'; -import { createContainerApi, type ContainerApi } from './types'; - -/** - * Lớp lệnh gắn với SDK của plugin. - * - * Không dùng React context: mọi chỗ gọi đều đã nằm trong một component hoặc một - * hook, nên một `useMemo` tại chỗ là đủ và rẻ hơn một provider — đồng thời tránh - * việc các test đang render component con đứng lẻ phải dựng thêm provider chỉ để - * chạy được. - */ -export function useContainerApi(): ContainerApi { - const sdk = usePluginSdkFor('container-manager'); - return useMemo(() => createContainerApi(sdk), [sdk]); -} diff --git a/src/components/tools/container/format.ts b/src/components/tools/container/format.ts deleted file mode 100644 index 217429aa..00000000 --- a/src/components/tools/container/format.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** Human-readable byte size, shared by Images/Volumes lists and the details - * dialogs — was duplicated per-view before. Docker uses `-1` for "not - * available" (e.g. a volume's UsageData.Size when the driver doesn't report - * it) — rendered as an em dash rather than a nonsense negative size. */ -export function formatBytes(n: number | undefined | null): string { - if (n === undefined || n === null || n < 0) return '—'; - if (n === 0) return '0 B'; - const units = ['B', 'KB', 'MB', 'GB', 'TB']; - let i = 0; - let v = n; - while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } - return `${v.toFixed(1)} ${units[i]}`; -} diff --git a/src/components/tools/container/logsPanel.test.ts b/src/components/tools/container/logsPanel.test.ts deleted file mode 100644 index 892cc1a2..00000000 --- a/src/components/tools/container/logsPanel.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildMatcher, formatTimestamp } from '@/components/tools/container/LogsPanel'; - -describe('buildMatcher', () => { - it('truy vấn rỗng không lọc gì', () => { - const m = buildMatcher(' ', false, false); - expect(m.test).toBeNull(); - expect(m.ranges).toBeNull(); - expect(m.error).toBeNull(); - }); - - it('tìm chuỗi thường, mặc định không phân biệt hoa thường', () => { - const m = buildMatcher('ERROR', false, false); - expect(m.test!('unhandled error at boot')).toBe(true); - expect(m.ranges!('unhandled error at boot')).toEqual([[10, 15]]); - }); - - it('bật phân biệt hoa thường thì bỏ qua khác case', () => { - const m = buildMatcher('ERROR', false, true); - expect(m.test!('unhandled error')).toBe(false); - expect(m.test!('unhandled ERROR')).toBe(true); - }); - - it('trả về mọi lần khớp trong một dòng', () => { - const m = buildMatcher('ab', false, false); - expect(m.ranges!('ab-ab-ab')).toEqual([[0, 2], [3, 5], [6, 8]]); - }); - - it('chế độ regex khớp theo mẫu', () => { - const m = buildMatcher('GET /(users|orders)', true, false); - expect(m.error).toBeNull(); - expect(m.test!('GET /users 200')).toBe(true); - expect(m.test!('GET /health 200')).toBe(false); - }); - - it('regex có state không rò rỉ giữa các lần gọi', () => { - // RegExp cờ /g nhớ lastIndex — nếu không reset, dòng thứ hai sẽ trượt. - const m = buildMatcher('a', true, false); - expect(m.test!('aaa')).toBe(true); - expect(m.test!('aaa')).toBe(true); - expect(m.ranges!('aaa')).toEqual([[0, 1], [1, 2], [2, 3]]); - expect(m.ranges!('aaa')).toEqual([[0, 1], [1, 2], [2, 3]]); - }); - - it('regex khớp rỗng không treo vòng lặp', () => { - const m = buildMatcher('x*', true, false); - expect(m.ranges!('axxb')).toEqual([[1, 3]]); - }); - - it('regex sai cú pháp báo lỗi thay vì ném ra ngoài', () => { - const m = buildMatcher('foo(', true, false); - expect(m.error).toBeTruthy(); - expect(m.test).toBeNull(); - }); -}); - -describe('formatTimestamp', () => { - it('rút gọn RFC3339 về giờ:phút:giây.mili', () => { - expect(formatTimestamp('2026-08-26T09:41:02.123456789Z')).toBe('09:41:02.123'); - }); - - it('giữ nguyên khi không có phần thập phân', () => { - expect(formatTimestamp('2026-08-26T09:41:02Z')).toBe('09:41:02'); - }); - - it('trả nguyên chuỗi nếu không phải định dạng có T', () => { - expect(formatTimestamp('not-a-timestamp')).toBe('not-a-timestamp'); - }); -}); diff --git a/src/components/tools/container/mcpBridge.test.ts b/src/components/tools/container/mcpBridge.test.ts deleted file mode 100644 index ed989c48..00000000 --- a/src/components/tools/container/mcpBridge.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; -import type { ContainerToolState } from './useContainerState'; -import type { ContainerConnection, LogLine, StatsFrame } from './types'; - -/** - * `isTauri` is computed once at module load from `'__TAURI_INTERNALS__' in - * window`, so — same gotcha as apiclient/mcpBridge.test.ts — it must be set - * BEFORE importing `mcpBridge.ts`, with `vi.resetModules()` forcing a fresh - * load, or the bridge's effect no-ops in plain jsdom. - */ - -const listenMock = vi.fn(); -const invokeMock = vi.fn(); -const unlisten = vi.fn(); -const listConfigsMock = vi.fn(); -const saveConfigMock = vi.fn(); -const deleteConfigMock = vi.fn(); -const testConnectionMock = vi.fn(); -const listMock = vi.fn(); -const detailsMock = vi.fn(); -const startMock = vi.fn(); -const stopMock = vi.fn(); -const restartMock = vi.fn(); -const pauseMock = vi.fn(); -const unpauseMock = vi.fn(); -const removeMock = vi.fn(); -const logsStartMock = vi.fn(); -const logsStopMock = vi.fn(); -const statsSnapshotMock = vi.fn(); -const imageListMock = vi.fn(); -const imageDetailsMock = vi.fn(); -const imageRemoveMock = vi.fn(); - -vi.mock('@tauri-apps/api/event', () => ({ listen: (...args: unknown[]) => listenMock(...args) })); -vi.mock('@tauri-apps/api/core', () => ({ - invoke: (...args: unknown[]) => invokeMock(...args), -})); -// Lớp lệnh giờ đến từ `useContainerApi()` (dựng trên SDK của plugin), nên mock -// đặt ở đó thay vì ở `./types` — test không phải biết lớp lệnh được dựng bằng -// cách nào, chỉ cần biết bridge gọi những hàm nào. -vi.mock('./api_sdk', () => ({ - useContainerApi: () => ({ - listConfigs: (...a: unknown[]) => listConfigsMock(...a), - saveConfig: (...a: unknown[]) => saveConfigMock(...a), - deleteConfig: (...a: unknown[]) => deleteConfigMock(...a), - testConnection: (...a: unknown[]) => testConnectionMock(...a), - list: (...a: unknown[]) => listMock(...a), - details: (...a: unknown[]) => detailsMock(...a), - start: (...a: unknown[]) => startMock(...a), - stop: (...a: unknown[]) => stopMock(...a), - restart: (...a: unknown[]) => restartMock(...a), - pause: (...a: unknown[]) => pauseMock(...a), - unpause: (...a: unknown[]) => unpauseMock(...a), - remove: (...a: unknown[]) => removeMock(...a), - logsStart: (...a: unknown[]) => logsStartMock(...a), - statsSnapshot: (...a: unknown[]) => statsSnapshotMock(...a), - imageList: (...a: unknown[]) => imageListMock(...a), - imageDetails: (...a: unknown[]) => imageDetailsMock(...a), - imageRemove: (...a: unknown[]) => imageRemoveMock(...a), - }), -})); - -type CallHandler = (event: { payload: { id: string; tool: string; args: Record } }) => void; - -async function renderBridge(state: ContainerToolState) { - vi.resetModules(); - (window as unknown as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = {}; - const { useMcpBridge } = await import('./mcpBridge'); - return renderHook(() => useMcpBridge(state)); -} - -function makeConn(overrides: Partial = {}): ContainerConnection { - return { id: 'c1', name: 'Local', socketPath: '/var/run/docker.sock', ...overrides }; -} - -function makeState(overrides: Partial = {}): ContainerToolState { - return { - selectedConnId: '', setSelectedConnId: vi.fn(), - connectedConnId: '', setConnectedConnId: vi.fn(), - view: 'overview', - showOverview: vi.fn(), showContainers: vi.fn(), showImages: vi.fn(), - showVolumes: vi.fn(), showNetworks: vi.fn(), showCompose: vi.fn(), - refreshKey: 0, refresh: vi.fn(), - ...overrides, - } as ContainerToolState; -} - -beforeEach(() => { - listenMock.mockReset(); - invokeMock.mockReset().mockResolvedValue(undefined); - listConfigsMock.mockReset().mockResolvedValue([makeConn()]); - saveConfigMock.mockReset().mockImplementation((c: ContainerConnection) => Promise.resolve({ ...c, id: c.id || 'new-conn-id' })); - deleteConfigMock.mockReset().mockResolvedValue(undefined); - testConnectionMock.mockReset().mockResolvedValue(undefined); - listMock.mockReset().mockResolvedValue([]); - detailsMock.mockReset().mockResolvedValue({ id: 'ctr1', name: 'web', running: true }); - startMock.mockReset().mockResolvedValue(undefined); - stopMock.mockReset().mockResolvedValue(undefined); - restartMock.mockReset().mockResolvedValue(undefined); - pauseMock.mockReset().mockResolvedValue(undefined); - unpauseMock.mockReset().mockResolvedValue(undefined); - removeMock.mockReset().mockResolvedValue(undefined); - logsStopMock.mockReset().mockResolvedValue(undefined); - logsStartMock.mockReset().mockResolvedValue({ stop: logsStopMock }); - statsSnapshotMock.mockReset().mockResolvedValue({}); - imageListMock.mockReset().mockResolvedValue([]); - imageDetailsMock.mockReset().mockResolvedValue({ id: 'img1' }); - imageRemoveMock.mockReset().mockResolvedValue(undefined); -}); -afterEach(() => { - delete (window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; -}); - -describe('useMcpBridge (Container) — web build (no __TAURI_INTERNALS__)', () => { - it('never registers a listener outside Tauri', async () => { - vi.resetModules(); - delete (window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; - const { useMcpBridge } = await import('./mcpBridge'); - renderHook(() => useMcpBridge(makeState())); - await new Promise((r) => setTimeout(r, 0)); - expect(listenMock).not.toHaveBeenCalled(); - }); -}); - -describe('useMcpBridge (Container) — Tauri desktop', () => { - let capturedCb: CallHandler | undefined; - - beforeEach(() => { - capturedCb = undefined; - unlisten.mockClear(); - listenMock.mockImplementation((_event: string, cb: CallHandler) => { - capturedCb = cb; - return Promise.resolve(unlisten); - }); - }); - - let callSeq = 0; - - async function call(state: ContainerToolState, tool: string, args: Record) { - await renderBridge(state); - await waitFor(() => expect(capturedCb).toBeDefined()); - const id = `call-${++callSeq}`; - invokeMock.mockClear(); - capturedCb!({ payload: { id, tool, args } }); - await waitFor(() => expect(invokeMock).toHaveBeenCalledWith('mcp_respond', expect.objectContaining({ id }))); - return invokeMock.mock.calls.find((c) => c[0] === 'mcp_respond' && (c[1] as { id: string }).id === id)?.[1] as { id: string; result: unknown; error: unknown }; - } - - it('registers exactly one mcp:call listener', async () => { - await renderBridge(makeState()); - await waitFor(() => expect(listenMock).toHaveBeenCalledTimes(1)); - expect(listenMock.mock.calls[0][0]).toBe('mcp:call'); - }); - - it('an mcp:call for a tool this bridge does not own is left unanswered', async () => { - await renderBridge(makeState()); - await waitFor(() => expect(capturedCb).toBeDefined()); - invokeMock.mockClear(); - capturedCb!({ payload: { id: 'other-tool-call', tool: 'mock_status', args: {} } }); - await new Promise((r) => setTimeout(r, 0)); - expect(invokeMock).not.toHaveBeenCalled(); - }); - - // ── Connections ──────────────────────────────────────────────────────── - - it('container_list_connections returns whatever containerApi.listConfigs resolves', async () => { - const res = await call(makeState(), 'container_list_connections', {}); - expect(res.error).toBeNull(); - expect(res.result).toEqual([makeConn()]); - }); - - it('container_get_connection finds by id and errors on an unknown one', async () => { - const ok = await call(makeState(), 'container_get_connection', { connectionId: 'c1' }); - expect((ok.result as ContainerConnection).id).toBe('c1'); - - const bad = await call(makeState(), 'container_get_connection', { connectionId: 'nope' }); - expect(bad.result).toBeNull(); - expect(bad.error).toMatch(/No container connection with id/); - }); - - it('container_add_connection requires name and socketPath, saves with an empty id', async () => { - const res = await call(makeState(), 'container_add_connection', { name: 'Colima', socketPath: '/tmp/colima.sock' }); - expect(saveConfigMock).toHaveBeenCalledWith({ id: '', name: 'Colima', socketPath: '/tmp/colima.sock' }); - expect((res.result as ContainerConnection).id).toBe('new-conn-id'); - }); - - it('container_update_connection merges patch onto the existing connection (flat merge)', async () => { - await call(makeState(), 'container_update_connection', { connectionId: 'c1', patch: { socketPath: '/tmp/other.sock' } }); - expect(saveConfigMock).toHaveBeenCalledWith(makeConn({ socketPath: '/tmp/other.sock' })); - }); - - it('container_delete_connection deletes an existing id and errors on an unknown one', async () => { - const res = await call(makeState(), 'container_delete_connection', { connectionId: 'c1' }); - expect(deleteConfigMock).toHaveBeenCalledWith('c1'); - expect(res.result).toEqual({ ok: true }); - - deleteConfigMock.mockClear(); - const bad = await call(makeState(), 'container_delete_connection', { connectionId: 'nope' }); - expect(bad.error).toMatch(/No container connection with id/); - expect(deleteConfigMock).not.toHaveBeenCalled(); - }); - - it('container_test_connection tests without touching connected state', async () => { - const state = makeState(); - const res = await call(state, 'container_test_connection', { connectionId: 'c1' }); - expect(testConnectionMock).toHaveBeenCalledWith(makeConn()); - expect(state.setConnectedConnId).not.toHaveBeenCalled(); - expect(res.result).toEqual({ ok: true }); - }); - - it('container_connect tests and marks the connection live/selected', async () => { - const state = makeState(); - const res = await call(state, 'container_connect', { connectionId: 'c1' }); - expect(testConnectionMock).toHaveBeenCalledWith(makeConn()); - expect(state.setConnectedConnId).toHaveBeenCalledWith('c1'); - expect(state.setSelectedConnId).toHaveBeenCalledWith('c1'); - expect(res.result).toEqual({ ok: true, connectedConnId: 'c1' }); - }); - - it('container_disconnect clears the connected connection', async () => { - const state = makeState({ connectedConnId: 'c1' }); - const res = await call(state, 'container_disconnect', {}); - expect(state.setConnectedConnId).toHaveBeenCalledWith(''); - expect(res.result).toEqual({ ok: true }); - }); - - it('container_connection_status reports the current selection/connection', async () => { - const state = makeState({ selectedConnId: 'c1', connectedConnId: 'c1' }); - const res = await call(state, 'container_connection_status', {}); - expect(res.result).toEqual({ selectedConnId: 'c1', connectedConnId: 'c1' }); - }); - - // ── Lifecycle (require an active connection) ────────────────────────── - - it('container_list errors when no connection is active', async () => { - const res = await call(makeState({ connectedConnId: '' }), 'container_list', {}); - expect(res.error).toMatch(/No active container connection/); - expect(listMock).not.toHaveBeenCalled(); - }); - - it('container_list resolves the active connection and forwards `all`', async () => { - listMock.mockResolvedValue([{ Id: 'ctr1' }]); - const res = await call(makeState({ connectedConnId: 'c1' }), 'container_list', { all: true }); - expect(listMock).toHaveBeenCalledWith(makeConn(), true); - expect(res.result).toEqual([{ Id: 'ctr1' }]); - }); - - it('container_inspect returns curated details', async () => { - const res = await call(makeState({ connectedConnId: 'c1' }), 'container_inspect', { containerId: 'ctr1' }); - expect(detailsMock).toHaveBeenCalledWith(makeConn(), 'ctr1'); - expect(res.result).toEqual({ id: 'ctr1', name: 'web', running: true }); - }); - - it('container_start/stop/restart/pause/unpause forward to the matching containerApi call', async () => { - const state = makeState({ connectedConnId: 'c1' }); - await call(state, 'container_start', { containerId: 'ctr1' }); - expect(startMock).toHaveBeenCalledWith(makeConn(), 'ctr1'); - await call(state, 'container_stop', { containerId: 'ctr1' }); - expect(stopMock).toHaveBeenCalledWith(makeConn(), 'ctr1'); - await call(state, 'container_restart', { containerId: 'ctr1' }); - expect(restartMock).toHaveBeenCalledWith(makeConn(), 'ctr1'); - await call(state, 'container_pause', { containerId: 'ctr1' }); - expect(pauseMock).toHaveBeenCalledWith(makeConn(), 'ctr1'); - await call(state, 'container_unpause', { containerId: 'ctr1' }); - expect(unpauseMock).toHaveBeenCalledWith(makeConn(), 'ctr1'); - }); - - it('container_remove forwards force', async () => { - await call(makeState({ connectedConnId: 'c1' }), 'container_remove', { containerId: 'ctr1', force: true }); - expect(removeMock).toHaveBeenCalledWith(makeConn(), 'ctr1', true); - }); - - it('container_stats returns the snapshot for the requested container, errors if absent', async () => { - const frame: StatsFrame = { cpuPercent: 1.5, memUsageBytes: 100, memLimitBytes: 1000, netRxBytes: 0, netTxBytes: 0 }; - statsSnapshotMock.mockResolvedValue({ ctr1: frame }); - const ok = await call(makeState({ connectedConnId: 'c1' }), 'container_stats', { containerId: 'ctr1' }); - expect(statsSnapshotMock).toHaveBeenCalledWith(makeConn(), ['ctr1']); - expect(ok.result).toEqual(frame); - - statsSnapshotMock.mockResolvedValue({}); - const bad = await call(makeState({ connectedConnId: 'c1' }), 'container_stats', { containerId: 'ctr1' }); - expect(bad.error).toMatch(/No stats available/); - }); - - it('container_logs collects lines streamed through onLine within the window, then stops the subscription', async () => { - let capturedOnLine: ((line: LogLine) => void) | undefined; - logsStartMock.mockImplementation((_config, _id, _tail, _since, _until, _ts, onLine: (line: LogLine) => void) => { - capturedOnLine = onLine; - return Promise.resolve({ stop: logsStopMock }); - }); - - await renderBridge(makeState({ connectedConnId: 'c1' })); - await waitFor(() => expect(capturedCb).toBeDefined()); - const id = `call-${++callSeq}`; - invokeMock.mockClear(); - capturedCb!({ payload: { id, tool: 'container_logs', args: { containerId: 'ctr1' } } }); - - // Give logsStart's promise a tick to resolve and hand back onLine before - // feeding it messages — the real 1.5s collection window then elapses for - // real (COLLECT_WINDOW_MS isn't exported to fake-time it). - await new Promise((r) => setTimeout(r, 10)); - capturedOnLine?.({ stream: 'stdout', message: 'hello' }); - capturedOnLine?.({ stream: 'stdout', message: 'world' }); - - await waitFor( - () => expect(invokeMock).toHaveBeenCalledWith('mcp_respond', expect.objectContaining({ id })), - { timeout: 3000 }, - ); - const res = invokeMock.mock.calls.find((c) => c[0] === 'mcp_respond' && (c[1] as { id: string }).id === id)?.[1] as { result: unknown }; - - expect(logsStartMock).toHaveBeenCalledWith(makeConn(), 'ctr1', '100', 0, 0, false, expect.anything()); - expect(logsStopMock).toHaveBeenCalled(); - expect(res.result).toEqual({ lines: [{ stream: 'stdout', message: 'hello' }, { stream: 'stdout', message: 'world' }] }); - }, 10000); - - // ── Images ───────────────────────────────────────────────────────────── - - it('container_list_images/container_image_details/container_remove_image forward to the active connection', async () => { - const state = makeState({ connectedConnId: 'c1' }); - imageListMock.mockResolvedValue([{ Id: 'img1' }]); - const list = await call(state, 'container_list_images', {}); - expect(imageListMock).toHaveBeenCalledWith(makeConn()); - expect(list.result).toEqual([{ Id: 'img1' }]); - - const details = await call(state, 'container_image_details', { imageId: 'img1' }); - expect(imageDetailsMock).toHaveBeenCalledWith(makeConn(), 'img1'); - expect(details.result).toEqual({ id: 'img1' }); - - const removed = await call(state, 'container_remove_image', { imageId: 'img1', force: true }); - expect(imageRemoveMock).toHaveBeenCalledWith(makeConn(), 'img1', true); - expect(removed.result).toEqual({ ok: true }); - }); -}); diff --git a/src/components/tools/container/mcpBridge.ts b/src/components/tools/container/mcpBridge.ts deleted file mode 100644 index 518d85b0..00000000 --- a/src/components/tools/container/mcpBridge.ts +++ /dev/null @@ -1,279 +0,0 @@ -// MCP bridge — frontend half, Containers tool. Unlike the redis_*/kafka_*/ -// rabbit_* bridges (connection management only), this one covers full -// container lifecycle management too, plus image list/remove — the user -// explicitly asked for that wider scope. It's still the narrowest useful -// slice of `containerApi`: no volumes/networks, no image pull/tag/prune, no -// live-streaming logs/stats (both collected as bounded one-shot snapshots -// instead — see container_logs/container_stats below), no cgroup resource -// edits. Same shape as every other tool bridge: the Rust sidecar -// (src-tauri/src/bin/devtool-mcp-server.rs) forwards each tool call to the -// loopback control server (src-tauri/src/mcp_bridge.rs) as an `mcp:call` -// Tauri event; `useMcpBridge` answers it by running the matching handler -// below, then reports back via `mcp_respond`. -// -// Connection CRUD (list/add/update/delete) needs no React state at all — -// `containerApi` is a thin wrapper over Tauri commands that read/write a -// JSON file in the app data dir (src-tauri/src/bin/devtool-svc-container.rs), so it -// can be called directly regardless of whether ContainerManager.tsx is -// mounted. Every lifecycle/image operation below needs an ACTIVE -// connection (`state.connectedConnId`) — there is no per-call connection -// argument on `containerApi.list`/`start`/`stop`/etc., only a full -// `ContainerConnection` object, so these operate on whichever connection -// the tool (or a prior `container_connect` MCP call) has marked live, -// mirroring how `mock_*` tools operate on "the" mock server rather than -// taking a server id per call. - -import { useEffect, useRef } from 'react'; -import { usePluginSdkFor } from '@/platform'; -import { isTauri } from '@/lib/platform'; -import { useContainerApi } from './api_sdk'; -import type { ContainerApi, ContainerConnection, LogLine, StatsFrame } from './types'; -import type { ContainerToolState } from './useContainerState'; - -interface McpCallEvent { - id: string; - tool: string; - args: Record; -} - -type ToolHandler = (args: Record) => Promise; - -function requireString(v: unknown, name: string): string { - if (typeof v !== 'string' || !v) throw new Error(`"${name}" is required and must be a string`); - return v; -} - -async function requireConnection(containerApi: ContainerApi, id: string): Promise { - const all = await containerApi.listConfigs(); - const conn = all.find((c) => c.id === id); - if (!conn) throw new Error(`No container connection with id "${id}"`); - return conn; -} - -async function requireActiveConnection(containerApi: ContainerApi, state: ContainerToolState): Promise { - if (!state.connectedConnId) { - throw new Error('No active container connection — call container_connect with a connectionId first.'); - } - return requireConnection(containerApi, state.connectedConnId); -} - -// Log/stats streams are inherently long-lived (a sidecar STREAM -// subscription), which doesn't fit MCP's one request/one response shape. -// Collect what arrives within a short window instead of an open-ended live -// tail — long enough to catch the requested tail on an idle container, short -// enough that a call doesn't hang. -const COLLECT_WINDOW_MS = 1500; - -function collectLogs( - containerApi: ContainerApi, - config: ContainerConnection, containerId: string, tail: string, - since: number, until: number, timestamps: boolean, -): Promise { - return new Promise((resolve) => { - const lines: LogLine[] = []; - let subscription: { stop(): Promise } | null = null; - containerApi.logsStart(config, containerId, tail, since, until, timestamps, (line) => lines.push(line)) - .then((sub) => { subscription = sub; }) - .catch(() => { /* surfaced as an empty result — the container may not exist */ }); - setTimeout(() => { - if (subscription) void subscription.stop(); - resolve(lines); - }, COLLECT_WINDOW_MS); - }); -} - -function buildHandlers(containerApi: ContainerApi, state: ContainerToolState): Record { - return { - // ── Connections ────────────────────────────────────────────────────── - container_list_connections: async () => containerApi.listConfigs(), - - container_get_connection: async (args) => requireConnection(containerApi, requireString(args.connectionId, 'connectionId')), - - container_add_connection: async (args) => { - const conn: ContainerConnection = { - id: '', - name: requireString(args.name, 'name'), - socketPath: requireString(args.socketPath, 'socketPath'), - }; - return containerApi.saveConfig(conn); - }, - - // `patch` is a partial ContainerConnection — only included fields - // change. No nested objects on this type, so a plain shallow merge is - // correct (unlike update_request's script/auth/body — see - // apiclient/mcpBridge.ts). - container_update_connection: async (args) => { - const id = requireString(args.connectionId, 'connectionId'); - const current = await requireConnection(containerApi, id); - const patch = (args.patch ?? {}) as Partial; - return containerApi.saveConfig({ ...current, ...patch, id }); - }, - - container_delete_connection: async (args) => { - const id = requireString(args.connectionId, 'connectionId'); - await requireConnection(containerApi, id); - await containerApi.deleteConfig(id); - return { ok: true }; - }, - - container_test_connection: async (args) => { - const conn = await requireConnection(containerApi, requireString(args.connectionId, 'connectionId')); - await containerApi.testConnection(conn); - return { ok: true }; - }, - - container_connect: async (args) => { - const id = requireString(args.connectionId, 'connectionId'); - const conn = await requireConnection(containerApi, id); - await containerApi.testConnection(conn); - state.setConnectedConnId(id); - state.setSelectedConnId(id); - return { ok: true, connectedConnId: id }; - }, - - container_disconnect: async () => { - state.setConnectedConnId(''); - return { ok: true }; - }, - - container_connection_status: async () => ({ - selectedConnId: state.selectedConnId, - connectedConnId: state.connectedConnId, - }), - - // ── Container lifecycle (operate on the active connection) ───────────── - container_list: async (args) => { - const config = await requireActiveConnection(containerApi, state); - return containerApi.list(config, args.all === true); - }, - - container_inspect: async (args) => { - const config = await requireActiveConnection(containerApi, state); - return containerApi.details(config, requireString(args.containerId, 'containerId')); - }, - - container_start: async (args) => { - const config = await requireActiveConnection(containerApi, state); - await containerApi.start(config, requireString(args.containerId, 'containerId')); - return { ok: true }; - }, - - container_stop: async (args) => { - const config = await requireActiveConnection(containerApi, state); - await containerApi.stop(config, requireString(args.containerId, 'containerId')); - return { ok: true }; - }, - - container_restart: async (args) => { - const config = await requireActiveConnection(containerApi, state); - await containerApi.restart(config, requireString(args.containerId, 'containerId')); - return { ok: true }; - }, - - container_pause: async (args) => { - const config = await requireActiveConnection(containerApi, state); - await containerApi.pause(config, requireString(args.containerId, 'containerId')); - return { ok: true }; - }, - - container_unpause: async (args) => { - const config = await requireActiveConnection(containerApi, state); - await containerApi.unpause(config, requireString(args.containerId, 'containerId')); - return { ok: true }; - }, - - container_remove: async (args) => { - const config = await requireActiveConnection(containerApi, state); - await containerApi.remove(config, requireString(args.containerId, 'containerId'), args.force === true); - return { ok: true }; - }, - - // Collects up to ~1.5s of recent output rather than opening a live tail - // — see collectLogs above. `tail` matches the daemon's own flag (a - // count, or "all"); defaults to the last 100 lines. - container_logs: async (args) => { - const config = await requireActiveConnection(containerApi, state); - const containerId = requireString(args.containerId, 'containerId'); - const tail = typeof args.tail === 'string' ? args.tail : typeof args.tail === 'number' ? String(args.tail) : '100'; - const since = typeof args.since === 'number' ? args.since : 0; - const until = typeof args.until === 'number' ? args.until : 0; - const timestamps = args.timestamps === true; - const lines = await collectLogs(containerApi, config, containerId, tail, since, until, timestamps); - return { lines }; - }, - - // One CPU/memory/network sample, not a live stream — same data - // `statsSnapshot` feeds the container table's live usage columns with. - container_stats: async (args) => { - const config = await requireActiveConnection(containerApi, state); - const containerId = requireString(args.containerId, 'containerId'); - const snapshot = await containerApi.statsSnapshot(config, [containerId]); - const stats: StatsFrame | undefined = snapshot[containerId]; - if (!stats) throw new Error(`No stats available for container "${containerId}" (is it running?)`); - return stats; - }, - - // ── Images (operate on the active connection) ─────────────────────── - container_list_images: async () => { - const config = await requireActiveConnection(containerApi, state); - return containerApi.imageList(config); - }, - - container_image_details: async (args) => { - const config = await requireActiveConnection(containerApi, state); - return containerApi.imageDetails(config, requireString(args.imageId, 'imageId')); - }, - - container_remove_image: async (args) => { - const config = await requireActiveConnection(containerApi, state); - await containerApi.imageRemove(config, requireString(args.imageId, 'imageId'), args.force === true); - return { ok: true }; - }, - }; -} - -// Registers the single `mcp:call` listener once and keeps it answering with -// the *latest* Container state via a ref, matching this repo's convention -// for long-lived event listeners that read changing React state (see -// docs/ai/CLAUDE.md's "Stable refs for long-lived event listeners") — same -// pattern as the API Client's/Mock Server's/Redis's/Kafka's/RabbitMQ's -// `useMcpBridge`. -export function useMcpBridge(state: ContainerToolState, enabled = true): void { - const sdk = usePluginSdkFor('container-manager'); - const containerApi = useContainerApi(); - const handlersRef = useRef>({}); - handlersRef.current = buildHandlers(containerApi, state); - - useEffect(() => { - if (!isTauri || !enabled) return; - let cancelled = false; - let unlisten: (() => void) | null = null; - - (async () => { - // Qua SDK: sự kiện `mcp:call` và lệnh `mcp_respond` đều nằm trong quyền - // 'native' + allowlist của plugin, nên cầu nối này cũng hiện trong nhật ký - // như mọi lời gọi khác thay vì là một đường đi vòng. - const fn = await sdk.native.listen('mcp:call', async (payload) => { - const { id, tool, args } = payload; - const handler = handlersRef.current[tool]; - // Not one of this bridge's tools — leave it alone rather than - // answering "unknown tool", since several bridges may be listening - // on the shared mcp:call event at once (see McpBackgroundBridge.tsx). - if (!handler) return; - try { - const result = await handler(args ?? {}); - await sdk.native.invoke('mcp_respond', { id, result: result ?? null, error: null }); - } catch (e) { - await sdk.native.invoke('mcp_respond', { id, result: null, error: (e as Error).message ?? String(e) }); - } - }); - if (cancelled) fn(); - else unlisten = fn; - })(); - - return () => { - cancelled = true; - unlisten?.(); - }; - }, [enabled, sdk]); -} diff --git a/src/components/tools/container/mcpRuntimeContext.tsx b/src/components/tools/container/mcpRuntimeContext.tsx deleted file mode 100644 index 74af2d3c..00000000 --- a/src/components/tools/container/mcpRuntimeContext.tsx +++ /dev/null @@ -1,31 +0,0 @@ -// Shared Container Manager runtime — one `useContainerState()` instance for -// the whole app, provided via context instead of instantiated fresh by -// ContainerManager.tsx on every mount. Same reasoning as -// apiclient/mcpRuntimeContext.tsx / redis / kafka / rabbit's equivalents: -// with the background MCP bridge (Settings → MCP) able to answer calls -// while the Containers tool is off-screen, a second independent -// `useContainerState()` instance would race the mounted tool's own -// `connectedConnId`/`selectedConnId` (each a `usePersistentState`) and -// could silently drop whichever side wrote last. Mounted unconditionally at -// the app root — cheap, since `useContainerState()` does no I/O beyond an -// initial localStorage read. - -import { createContext, useContext, type ReactNode } from 'react'; -import { useContainerState, type ContainerToolState } from './useContainerState'; - -const ContainerRuntimeContext = createContext(null); - -export function ContainerRuntimeProvider({ children }: { children: ReactNode }) { - const state = useContainerState(); - return ( - - {children} - - ); -} - -export function useContainerRuntime(): ContainerToolState { - const ctx = useContext(ContainerRuntimeContext); - if (!ctx) throw new Error('useContainerRuntime must be used within ContainerRuntimeProvider'); - return ctx; -} diff --git a/src/components/tools/container/types.ts b/src/components/tools/container/types.ts deleted file mode 100644 index af8975a3..00000000 --- a/src/components/tools/container/types.ts +++ /dev/null @@ -1,592 +0,0 @@ -import type { PluginSdk } from '@/platform'; - -// ── Connection profile ────────────────────────────────────────────────────── - -export interface ContainerConnection { - id: string; - name: string; - socketPath: string; -} - -export const EMPTY_CONNECTION: ContainerConnection = { - id: '', - name: '', - socketPath: '', -}; - -export interface DetectedSocket { - label: string; - socketPath: string; -} - -// ── Containers (raw Docker Engine API shapes — PascalCase, matches bollard/ -// Docker's own JSON so no field-name mapping layer is needed) ────────────── - -export interface ContainerPort { - IP?: string; - PrivatePort: number; - PublicPort?: number; - Type: string; -} - -export interface ContainerSummary { - Id: string; - Names?: string[]; - Image?: string; - ImageID?: string; - Command?: string; - Created?: number; - State?: string; - Status?: string; - Ports?: ContainerPort[]; - Labels?: Record; - // The list endpoint already reports each container's mounts and attached - // networks. Declaring them here is what lets the Images/Volumes/Networks - // views answer "is this in use, and by what?" from the container list they - // can load anyway, instead of inspecting every resource one by one. - Mounts?: { Type?: string; Name?: string; Source?: string; Destination?: string }[]; - NetworkSettings?: { Networks?: Record }; -} - -export interface LogLine { - stream: string; - message: string; - /** Present only when the stream was started with timestamps on; split off - * from the message backend-side so searches never match inside it. */ - timestamp?: string | null; -} - -export interface StatsFrame { - cpuPercent: number; - memUsageBytes: number; - memLimitBytes: number; - netRxBytes: number; - netTxBytes: number; -} - -// Curated projection of `inspect_container` — see ContainerDetails in -// devtool-svc-container.rs for why this isn't the raw bollard ContainerInspectResponse -// shape (dozens of fields, most never worth showing in the UI). -export interface ContainerMountInfo { - type?: string; - name?: string; - source?: string; - destination?: string; - mode?: string; - rw?: boolean; -} - -export interface ContainerPortBinding { - containerPort: string; - hostIp?: string; - hostPort?: string; -} - -export interface ContainerNetworkInfo { - name: string; - ipAddress?: string; - gateway?: string; - macAddress?: string; -} - -// The cgroup limits `docker update` can change on a live container. `null` -// means "unlimited / daemon default" — the backend normalises the daemon's -// `0`-for-unset to null so the editor can tell the two apart. -export interface ContainerResources { - nanoCpus?: number | null; - cpuShares?: number | null; - cpuPeriod?: number | null; - cpuQuota?: number | null; - cpusetCpus?: string | null; - memoryBytes?: number | null; - memoryReservationBytes?: number | null; - memorySwapBytes?: number | null; - blkioWeight?: number | null; - pidsLimit?: number | null; - restartPolicy?: string | null; - restartMaxRetry?: number | null; -} - -/** Only the fields present are sent to the daemon, which merges them into the - * container's existing HostConfig — so a bulk edit of one limit leaves every - * other limit on those containers alone. */ -export type ContainerResourceUpdate = Partial>; - -export interface ContainerDetails { - id: string; - name: string; - image: string; - platform?: string; - created?: string; - status?: string; - running: boolean; - paused: boolean; - restarting: boolean; - exitCode?: number; - startedAt?: string; - finishedAt?: string; - healthStatus?: string; - command?: string; - entrypoint: string[]; - restartPolicy?: string; - restartMaxRetry?: number; - env: string[]; - labels: Record; - mounts: ContainerMountInfo[]; - ports: ContainerPortBinding[]; - networks: ContainerNetworkInfo[]; - resources: ContainerResources; -} - -// ── Images ────────────────────────────────────────────────────────────── - -export interface ImageSummary { - Id: string; - RepoTags?: string[]; - Created?: number; - Size?: number; -} - -export interface PullProgress { - status: string; - id?: string | null; - progressCurrent?: number | null; - progressTotal?: number | null; -} - -// Curated projection of `inspect_image` — see ImageDetails in devtool-svc-container.rs -// for why this isn't the raw bollard ImageInspect shape. -export interface ImageDetails { - id: string; - repoTags: string[]; - repoDigests: string[]; - created?: string; - size: number; - architecture?: string; - os?: string; - author?: string; - cmd: string[]; - entrypoint: string[]; - env: string[]; - workingDir?: string; - exposedPorts: string[]; - labels: Record; - layerCount: number; -} - -/** Result of any of the four prune endpoints. Networks free no disk, so - * `spaceReclaimed` is always 0 there. */ -export interface PruneResult { - deleted: number; - spaceReclaimed: number; -} - -// ── Volumes / Networks ───────────────────────────────────────────────────── - -export interface VolumeInfo { - Name: string; - Driver: string; - Mountpoint: string; - CreatedAt?: string; - /** Compose stamps `com.docker.compose.project` on volumes and networks it - * creates, the same way it does on containers — that is how the Compose - * view's "Down" finds what belongs to a project. */ - Labels?: Record | null; -} - -export interface NetworkInfo { - Id: string; - Name: string; - Driver?: string; - Scope?: string; - Labels?: Record | null; -} - -// Curated projections of `inspect_network` / `inspect_volume`, same approach -// as ContainerDetails/ImageDetails. -export interface NetworkIpamConfig { - subnet?: string; - ipRange?: string; - gateway?: string; -} - -export interface NetworkDetails { - id: string; - name: string; - driver?: string; - scope?: string; - created?: string; - internal: boolean; - attachable: boolean; - ingress: boolean; - ipv6: boolean; - ipamDriver?: string; - ipamConfig: NetworkIpamConfig[]; - options: Record; - labels: Record; -} - -export interface VolumeDetails { - name: string; - driver: string; - mountpoint: string; - createdAt?: string; - scope?: string; - labels: Record; - options: Record; - sizeBytes?: number | null; - refCount?: number | null; -} - -// ── System overview ───────────────────────────────────────────────────── - -export interface SystemInfo { - ID?: string; - Containers?: number; - ContainersRunning?: number; - ContainersPaused?: number; - ContainersStopped?: number; - Images?: number; - ServerVersion?: string; - OperatingSystem?: string; - Architecture?: string; - NCPU?: number; - MemTotal?: number; - Name?: string; -} - -export interface DiskUsageSummary { - ActiveCount?: number; - TotalCount?: number; - Reclaimable?: number; - TotalSize?: number; -} - -// Matches bollard's `SystemDataUsageResponse` in this bollard version: -// aggregate counters per resource type only — no per-item lists (the raw -// Docker Engine API response does include those, but this bollard version's -// typed model drops them on deserialize). See volume_sizes in -// devtool-svc-container.rs for how per-volume size is obtained instead. -export interface SystemDataUsageResponse { - ImageUsage?: DiskUsageSummary; - ContainerUsage?: DiskUsageSummary; - VolumeUsage?: DiskUsageSummary; - BuildCacheUsage?: DiskUsageSummary; -} - -// ── Compose ───────────────────────────────────────────────────────────── - -// Compose projects are not a first-class bollard/Docker Engine API concept — -// there is no daemon-side "project" object. What exists is a handful of -// well-known labels docker compose stamps on every container it creates -// (com.docker.compose.project, .service, .project.working_dir, -// .project.config_files). Grouping ContainerSummary rows by those labels -// gives project/service structure using only data container_list already -// returns — the same read-only technique GUI tools like OrbStack use for -// their compose grouping. There is deliberately no up/down/build here: that -// requires the compose spec's client-side orchestration logic, which has no -// Docker Engine API equivalent (see ComposeView.tsx for the full rationale). -export const COMPOSE_LABELS = { - project: 'com.docker.compose.project', - service: 'com.docker.compose.service', - workingDir: 'com.docker.compose.project.working_dir', - configFiles: 'com.docker.compose.project.config_files', -} as const; - -export interface ComposeProjectGroup { - name: string; - workingDir: string | null; - configFiles: string | null; - containers: ContainerSummary[]; -} - -export function groupByComposeProject(containers: ContainerSummary[]): ComposeProjectGroup[] { - const groups = new Map(); - for (const c of containers) { - const project = c.Labels?.[COMPOSE_LABELS.project]; - if (!project) continue; - let group = groups.get(project); - if (!group) { - group = { - name: project, - workingDir: c.Labels?.[COMPOSE_LABELS.workingDir] ?? null, - configFiles: c.Labels?.[COMPOSE_LABELS.configFiles] ?? null, - containers: [], - }; - groups.set(project, group); - } - group.containers.push(c); - } - return Array.from(groups.values()).sort((a, b) => a.name.localeCompare(b.name)); -} - -// ── Invoke wrappers ───────────────────────────────────────────────────────── - -/** Trả về từ `logsStart`/`statsStart` — `.stop()` gói cả hai bước bắt buộc: - * báo sidecar dừng qua method `*-unsubscribe` (nếu đã nhận được - * `subscriptionId`) rồi mới gỡ đăng ký phía host, đúng thứ tự - * `pubsubSubscribe` của Redis đã làm. */ -export interface ContainerStreamSubscription { - stop(): Promise; -} - -/** - * Lớp lệnh của tool, dựng theo `sdk.service` (Tier B — sidecar - * `devtool-svc-container`) thay vì gọi thẳng `invoke`. Nhờ vậy allowlist - * `service.methods` trong manifest có hiệu lực thật — một method gõ sai hay - * ngoài danh sách bị chặn ngay và ghi vào nhật ký, thay vì lặng lẽ đi thẳng - * xuống sidecar. - * - * Chữ ký công khai của hầu hết các hàm GIỮ NGUYÊN so với bản `sdk.native` - * trước đó — vẫn nhận nguyên `config: ContainerConnection`, không phải - * `configId` — để giảm diff ở mọi call site: bản thân sidecar (khác Tier A) - * đòi `configId` (nó tra cứu từ file cấu hình RIÊNG của nó, xem - * `devtool-svc-container.rs::find_config`), nên ở đây chỉ đơn giản gửi - * `config.id` xuống. `saveConfig`/`testConnection` là hai ngoại lệ đúng: chúng - * thao tác trên một `ContainerConnection` CHƯA CHẮC đã có `id` (form "Thêm kết - * nối" trước khi lưu), nên sidecar nhận nguyên object, khớp đúng - * `handle()`'s `"save-config"`/`"test-connection"`. - * - * Dùng qua `useContainerApi()` (xem api_sdk.ts); factory để lộ ra đây chỉ cho - * test và cho code không phải React. - */ -export function createContainerApi(sdk: PluginSdk) { - const call = (method: string, params?: Record) => - sdk.service.call(method, params); - - return { - listConfigs: () => call('list-configs'), - saveConfig: (config: ContainerConnection) => call('save-config', { config }), - deleteConfig: (configId: string) => call('delete-config', { configId }), - detectSockets: () => call('detect-sockets'), - testConnection: (config: ContainerConnection) => call('test-connection', { config }), - - list: (config: ContainerConnection, all: boolean) => - call('list', { configId: config.id, all }), - inspect: (config: ContainerConnection, containerId: string) => - call('inspect', { configId: config.id, containerId }), - start: (config: ContainerConnection, containerId: string) => - call('start', { configId: config.id, containerId }), - stop: (config: ContainerConnection, containerId: string) => - call('stop', { configId: config.id, containerId }), - restart: (config: ContainerConnection, containerId: string) => - call('restart', { configId: config.id, containerId }), - pause: (config: ContainerConnection, containerId: string) => - call('pause', { configId: config.id, containerId }), - unpause: (config: ContainerConnection, containerId: string) => - call('unpause', { configId: config.id, containerId }), - remove: (config: ContainerConnection, containerId: string, force: boolean) => - call('remove', { configId: config.id, containerId, force }), - details: (config: ContainerConnection, containerId: string) => - call('details', { configId: config.id, containerId }), - - /** - * `since`/`until` are Unix seconds; pass 0 for "no bound" on either. - * `timestamps` asks the daemon to prefix each line — it can only be - * chosen when the stream starts, so toggling it restarts the stream. - * - * Backed by the sidecar's STREAM method `logs-start` — same shape as - * Redis's `pubsubSubscribe`: the first event carries - * `{ type: 'subscribed', subscriptionId }`, every `{ type: 'line', ... }` - * event is forwarded to `onLine`, and a subscribe-time failure (bad - * config, container not found) arrives as a THIRD event shape, - * `{ type: 'error', message }` — never as a protocol-level - * `ServiceResponse.error` (see `send_stream_error` in - * devtool-svc-container.rs and the ADR's Tier B section for why). This - * promise properly WAITS for either `subscribed` or `error` before - * settling. `.stop()` calls `logs-unsubscribe` first (only if a - * `subscriptionId` was received), then stops the host-side registration. - */ - logsStart: ( - config: ContainerConnection, containerId: string, tail: string, - since: number, until: number, timestamps: boolean, onLine: (line: LogLine) => void, - ): Promise => - startSubscriptionStream<{ stream?: string; message?: string; timestamp?: string | null }, LogLine>( - sdk, call, 'logs-start', 'logs-unsubscribe', - { configId: config.id, containerId, tail, since, until, timestamps }, - (event) => ({ stream: event.stream ?? 'stdout', message: event.message ?? '', timestamp: event.timestamp ?? null }), - onLine, - ), - - /** Same STREAM shape as `logsStart`, backed by `stats-start`/`stats-unsubscribe`. */ - statsStart: ( - config: ContainerConnection, containerId: string, onFrame: (frame: StatsFrame) => void, - ): Promise => - startSubscriptionStream( - sdk, call, 'stats-start', 'stats-unsubscribe', - { configId: config.id, containerId }, - (event) => event, - onFrame, - ), - - /** One sample per container for the table's live usage columns — cheaper - * than one open stats stream per row. Containers that fail are omitted. */ - statsSnapshot: (config: ContainerConnection, containerIds: string[]) => - call>('stats-snapshot', { configId: config.id, containerIds }), - - resources: (config: ContainerConnection, containerId: string) => - call('resources', { configId: config.id, containerId }), - updateResources: (config: ContainerConnection, containerId: string, resources: ContainerResourceUpdate) => - call('update-resources', { configId: config.id, containerId, resources }), - - prune: (config: ContainerConnection) => call('container-prune', { configId: config.id }), - - imageList: (config: ContainerConnection) => call('image-list', { configId: config.id }), - imageInspect: (config: ContainerConnection, imageId: string) => call('image-inspect', { configId: config.id, imageId }), - imageDetails: (config: ContainerConnection, imageId: string) => call('image-details', { configId: config.id, imageId }), - imageRemove: (config: ContainerConnection, imageId: string, force: boolean) => - call('image-remove', { configId: config.id, imageId, force }), - - /** - * `image-pull` is a SELF-TERMINATING stream (the `tick-stream` pattern - * from `devtool-svc-echo`, not the infinite `pubsub-subscribe`/ - * `logs-start` pattern — no unsubscribe method, nothing to stop by hand). - * The sidecar sends `{ type: 'progress', ...PullProgress }` per Docker - * pull event, then `{ type: 'done' }` once the daemon's own stream ends, - * or `{ type: 'error', message }` on failure — this promise resolves on - * `done` and rejects on `error`, forwarding every `progress` event to - * `onProgress` in between. The framing-level `done: true` the sidecar - * also sends is NOT what this waits for: `service_host.rs`'s `dispatch()` - * drops that line's payload before it reaches `onMessage` (see the - * sidecar's `handle_image_pull` doc comment) — completion has to be - * encoded in the application payload instead, same lesson as the ADR's - * stream-error section. - */ - imagePull: ( - config: ContainerConnection, image: string, tag: string, onProgress: (p: PullProgress) => void, - ): Promise => new Promise((resolve, reject) => { - let subscription: { stop(): Promise } | null = null; - const stopQuietly = () => { void subscription?.stop().catch(() => {}); }; - sdk.service.stream<{ - type: string; - status?: string; - id?: string | null; - progressCurrent?: number | null; - progressTotal?: number | null; - message?: string; - }>( - 'image-pull', - (event) => { - if (event.type === 'progress') { - onProgress({ - status: event.status ?? '', - id: event.id ?? null, - progressCurrent: event.progressCurrent ?? null, - progressTotal: event.progressTotal ?? null, - }); - return; - } - if (event.type === 'done') { - resolve(); - stopQuietly(); - return; - } - if (event.type === 'error') { - reject(new Error(event.message ?? 'Pull failed')); - stopQuietly(); - } - }, - { configId: config.id, image, tag }, - ) - .then((sub) => { subscription = sub; }) - .catch(reject); - }), - - /** `danglingOnly` = `docker image prune`; `false` = `docker image prune -a`. */ - imagePrune: (config: ContainerConnection, danglingOnly: boolean) => - call('image-prune', { configId: config.id, danglingOnly }), - imageTag: (config: ContainerConnection, imageId: string, repo: string, tag: string) => - call('image-tag', { configId: config.id, imageId, repo, tag }), - - volumeList: (config: ContainerConnection) => call('volume-list', { configId: config.id }), - volumeRemove: (config: ContainerConnection, name: string, force: boolean) => - call('volume-remove', { configId: config.id, name, force }), - volumeCreate: (config: ContainerConnection, name: string) => - call('volume-create', { configId: config.id, name }), - /** Per-volume disk usage in bytes, keyed by name — `{}` on Windows (named - * pipe transport isn't wired up for this raw request, see backend). */ - volumeSizes: (config: ContainerConnection) => call>('volume-sizes', { configId: config.id }), - volumePrune: (config: ContainerConnection) => call('volume-prune', { configId: config.id }), - volumeDetails: (config: ContainerConnection, name: string) => - call('volume-details', { configId: config.id, name }), - - networkList: (config: ContainerConnection) => call('network-list', { configId: config.id }), - networkRemove: (config: ContainerConnection, name: string) => call('network-remove', { configId: config.id, name }), - networkCreate: (config: ContainerConnection, name: string, driver: string) => - call('network-create', { configId: config.id, name, driver }), - networkPrune: (config: ContainerConnection) => call('network-prune', { configId: config.id }), - networkDetails: (config: ContainerConnection, networkId: string) => - call('network-details', { configId: config.id, networkId }), - - systemInfo: (config: ContainerConnection) => call('system-info', { configId: config.id }), - systemDf: (config: ContainerConnection) => call('system-df', { configId: config.id }), - }; -} - -/** - * Dùng chung cho `logsStart`/`statsStart` — cả hai đều là stream VÔ HẠN - * (mirror `pubsubSubscribe` của Redis): sự kiện đầu `{ type: 'subscribed', - * subscriptionId }` mới cho promise settle, `{ type: 'error', message }` làm - * nó reject, mọi sự kiện khác được ánh xạ qua `mapEvent` rồi forward tới - * `onEvent`. `.stop()` gọi `unsubscribeMethod` (nếu có `subscriptionId`) - * TRƯỚC rồi mới dừng đăng ký phía host — đúng thứ tự bắt buộc đã ghi ở - * `pubsubSubscribe`. - */ -async function startSubscriptionStream( - sdk: PluginSdk, - call: (method: string, params?: Record) => Promise, - startMethod: string, - unsubscribeMethod: string, - params: Record, - mapEvent: (event: RawEvent) => MappedEvent, - onEvent: (event: MappedEvent) => void, -): Promise { - let subscriptionId: string | null = null; - let settleReady: (() => void) | null = null; - let settleFailed: ((e: Error) => void) | null = null; - const ready = new Promise((resolve, reject) => { - settleReady = resolve; - settleFailed = reject; - }); - - // Trộn thêm `type`/`subscriptionId`/`message` vào kiểu sự kiện thô qua giao - // (`&`) thay vì bắt `RawEvent` phải `extends` một kiểu có ba trường tuỳ - // chọn đó: `StatsFrame` (dùng cho `statsStart`) không có trường nào trùng - // tên với ba trường này, và TypeScript coi một kiểu KHÔNG CÓ trường nào - // trùng với một kiểu chỉ toàn trường tuỳ chọn là khả nghi (lỗi TS2559) — - // giao kiểu ở đây tránh hẳn luật đó vì không phải một phép gán/ràng buộc. - const subscription = await sdk.service.stream( - startMethod, - (event) => { - if (event.type === 'subscribed') { - subscriptionId = event.subscriptionId ?? null; - settleReady?.(); - return; - } - if (event.type === 'error') { - settleFailed?.(new Error(event.message ?? 'Stream failed')); - return; - } - onEvent(mapEvent(event)); - }, - params, - ); - - try { - await ready; - } catch (e) { - await subscription.stop().catch(() => {}); - throw e; - } - - return { - async stop() { - if (subscriptionId) { - await call(unsubscribeMethod, { subscriptionId }).catch(() => {}); - } - await subscription.stop(); - }, - }; -} - -export type ContainerApi = ReturnType; diff --git a/src/components/tools/container/usage.test.ts b/src/components/tools/container/usage.test.ts deleted file mode 100644 index bb41b113..00000000 --- a/src/components/tools/container/usage.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildUsageIndex, describeUsers } from '@/components/tools/container/usage'; -import { describePruneResult } from '@/components/tools/container/PruneButton'; -import type { ContainerSummary } from '@/components/tools/container/types'; - -const CONTAINERS: ContainerSummary[] = [ - { - Id: 'aaaa1111', - Names: ['/web-1'], - ImageID: 'sha256:img-nginx', - Mounts: [ - { Type: 'volume', Name: 'site-data', Destination: '/usr/share/nginx/html' }, - { Type: 'bind', Source: '/Users/me/conf', Destination: '/etc/nginx/conf.d' }, - ], - NetworkSettings: { Networks: { frontend: { IPAddress: '172.20.0.3' }, bridge: {} } }, - }, - { - Id: 'bbbb2222', - Names: ['/api-1'], - ImageID: 'sha256:img-node', - Mounts: [{ Type: 'volume', Name: 'site-data', Destination: '/data' }], - NetworkSettings: { Networks: { frontend: {} } }, - }, - { - Id: 'cccc3333', - Names: ['/worker'], - ImageID: 'sha256:img-node', - }, -]; - -describe('buildUsageIndex', () => { - it('nhóm container theo image id', () => { - const index = buildUsageIndex(CONTAINERS); - expect(index.byImage.get('sha256:img-node')).toEqual(['api-1', 'worker']); - expect(index.byImage.get('sha256:img-nginx')).toEqual(['web-1']); - expect(index.byImage.get('sha256:missing')).toBeUndefined(); - }); - - it('chỉ tính named volume, bỏ qua bind mount', () => { - const index = buildUsageIndex(CONTAINERS); - expect(index.byVolume.get('site-data')).toEqual(['web-1', 'api-1']); - // Bind mount không có Name nên không tạo khoá nào. - expect(index.byVolume.size).toBe(1); - }); - - it('nhóm theo tên network, kể cả container gắn nhiều network', () => { - const index = buildUsageIndex(CONTAINERS); - expect(index.byNetwork.get('frontend')).toEqual(['web-1', 'api-1']); - expect(index.byNetwork.get('bridge')).toEqual(['web-1']); - }); - - it('container thiếu Mounts/NetworkSettings không làm hỏng index', () => { - const index = buildUsageIndex([{ Id: 'dddd4444' }]); - expect(index.byImage.size).toBe(0); - expect(index.byVolume.size).toBe(0); - expect(index.byNetwork.size).toBe(0); - }); - - it('rơi về tên rút gọn từ Id khi container không có Names', () => { - const index = buildUsageIndex([{ Id: 'ffffffffffffffff', ImageID: 'sha256:x' }]); - expect(index.byImage.get('sha256:x')).toEqual(['ffffffffffff']); - }); -}); - -describe('describeUsers', () => { - it('nói rõ khi không có container nào dùng', () => { - expect(describeUsers(undefined)).toBe('Not used by any container'); - expect(describeUsers([])).toBe('Not used by any container'); - }); - - it('cắt bớt danh sách dài', () => { - expect(describeUsers(['a', 'b'])).toBe('a, b'); - expect(describeUsers(['a', 'b', 'c', 'd', 'e', 'f'], 4)).toBe('a, b, c, d +2 more'); - }); -}); - -describe('describePruneResult', () => { - it('báo rõ khi không có gì để xoá', () => { - expect(describePruneResult({ deleted: 0, spaceReclaimed: 0 }, 'images')).toBe('No unused images to remove.'); - }); - - it('gộp số lượng và dung lượng thu hồi', () => { - expect(describePruneResult({ deleted: 3, spaceReclaimed: 1024 * 1024 }, 'images')) - .toBe('Removed 3 images · reclaimed 1.0 MB.'); - }); - - it('bỏ phần dung lượng khi endpoint không thu hồi byte nào (networks)', () => { - expect(describePruneResult({ deleted: 2, spaceReclaimed: 0 }, 'networks')).toBe('Removed 2 networks.'); - }); -}); diff --git a/src/components/tools/container/usage.ts b/src/components/tools/container/usage.ts deleted file mode 100644 index 471dd2bc..00000000 --- a/src/components/tools/container/usage.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { type ContainerConnection, type ContainerSummary } from './types'; -import { useContainerApi } from './api_sdk'; - -/** - * "Is this image / volume / network actually being used, and by what?" - * - * Docker has no endpoint that answers this per resource — the CLI works it out - * the same way, by walking the container list. Every container summary already - * carries its image id, its mounts and its attached networks, so one - * `container_list(all)` call is enough to index all three at once; inspecting - * each image, volume and network individually would be dozens of round trips - * for the same answer. - * - * Names are kept alongside the counts because "used by 3 containers" is much - * less useful than being able to point at which three. - */ -export interface UsageIndex { - /** Keyed by full image id (`sha256:…`) — matches ImageSummary.Id. */ - byImage: Map; - /** Keyed by volume name, from each container's named mounts. */ - byVolume: Map; - /** Keyed by network name. */ - byNetwork: Map; -} - -export const EMPTY_USAGE: UsageIndex = { byImage: new Map(), byVolume: new Map(), byNetwork: new Map() }; - -function containerName(c: ContainerSummary): string { - return c.Names?.[0]?.replace(/^\//, '') ?? c.Id.slice(0, 12); -} - -export function buildUsageIndex(containers: ContainerSummary[]): UsageIndex { - const index: UsageIndex = { byImage: new Map(), byVolume: new Map(), byNetwork: new Map() }; - const push = (map: Map, key: string, name: string) => { - const list = map.get(key); - if (list) list.push(name); - else map.set(key, [name]); - }; - - for (const c of containers) { - const name = containerName(c); - if (c.ImageID) push(index.byImage, c.ImageID, name); - for (const m of c.Mounts ?? []) { - // Only named volumes are addressable in the Volumes view — bind mounts - // have a host path instead of a name and nothing to match against. - if (m.Type === 'volume' && m.Name) push(index.byVolume, m.Name, name); - } - for (const netName of Object.keys(c.NetworkSettings?.Networks ?? {})) { - push(index.byNetwork, netName, name); - } - } - return index; -} - -/** - * Loads the container list and indexes it. Kept separate from each view's own - * data load so a slow or failed usage lookup never blocks or breaks the list - * itself — on failure the index is simply empty and the "In use" columns fall - * back to showing nothing. - * - * `reload` is returned rather than only keyed off `refreshKey` because the - * views reload themselves after their own mutations (a prune, a removal) - * without going through the toolbar's refresh — and a stale "in use by 3 - * containers" next to a freshly emptied list is worse than no column at all. - */ -export function useUsageIndex(connection: ContainerConnection, refreshKey: number): { - usage: UsageIndex; - reload: () => void; -} { - const containerApi = useContainerApi(); - const [usage, setUsage] = useState(EMPTY_USAGE); - const [tick, setTick] = useState(0); - - useEffect(() => { - let cancelled = false; - containerApi.list(connection, true) - .then((cs) => { if (!cancelled) setUsage(buildUsageIndex(cs)); }) - .catch(() => { if (!cancelled) setUsage(EMPTY_USAGE); }); - return () => { cancelled = true; }; - }, [connection, refreshKey, tick]); - - const reload = useCallback(() => setTick((t) => t + 1), []); - return { usage, reload }; -} - -/** "nginx-1, redis-cache +2 more" — the tooltip body for an In-use cell. */ -export function describeUsers(names: string[] | undefined, max = 4): string { - if (!names || names.length === 0) return 'Not used by any container'; - const shown = names.slice(0, max).join(', '); - return names.length > max ? `${shown} +${names.length - max} more` : shown; -} diff --git a/src/components/tools/container/useContainerState.ts b/src/components/tools/container/useContainerState.ts deleted file mode 100644 index 7a49c67c..00000000 --- a/src/components/tools/container/useContainerState.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useState } from 'react'; -import { usePluginSdkFor, usePluginState } from '@/platform'; - -export type ContainerView = 'overview' | 'containers' | 'images' | 'volumes' | 'networks' | 'compose'; - -export interface ContainerToolState { - selectedConnId: string; - setSelectedConnId: (id: string) => void; - connectedConnId: string; - setConnectedConnId: (id: string) => void; - view: ContainerView; - showOverview: () => void; - showContainers: () => void; - showImages: () => void; - showVolumes: () => void; - showNetworks: () => void; - showCompose: () => void; - refreshKey: number; - refresh: () => void; -} - -export function useContainerState(): ContainerToolState { - const sdk = usePluginSdkFor('container-manager'); - const [selectedConnId, setSelectedConnIdRaw] = usePluginState(sdk, 'selectedConnId', '', { legacyKey: 'devtool:container:selectedConnId' }); - const [connectedConnId, setConnectedConnId] = usePluginState(sdk, 'connectedConnId', '', { legacyKey: 'devtool:container:connectedConnId' }); - const [view, setView] = usePluginState(sdk, 'view', 'overview', { legacyKey: 'devtool:container:view' }); - const [refreshKey, setRefreshKey] = useState(0); - - const refresh = () => setRefreshKey((k) => k + 1); - - const setSelectedConnId = (id: string) => { - setSelectedConnIdRaw(id); - setView('overview'); - }; - - return { - selectedConnId, setSelectedConnId, - connectedConnId, setConnectedConnId, - view, - showOverview: () => setView('overview'), - showContainers: () => setView('containers'), - showImages: () => setView('images'), - showVolumes: () => setView('volumes'), - showNetworks: () => setView('networks'), - showCompose: () => setView('compose'), - refreshKey, refresh, - }; -} diff --git a/src/components/tools/container/useRowSelection.ts b/src/components/tools/container/useRowSelection.ts deleted file mode 100644 index 41795d9d..00000000 --- a/src/components/tools/container/useRowSelection.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; - -/** - * Multi-select state for the container tool's list views (containers, images, - * volumes, networks). All four had — or wanted — the same "checkbox column + - * select-all header + bulk action bar" behaviour, so it lives here once - * instead of being re-implemented per view. - * - * `rows` is the *visible* (filtered + sorted) row list; `keyOf` maps a row to - * its stable id (container/image id, volume/network name). Selection is kept - * as a key set so it survives re-sorts and filter changes — a row hidden by - * the search box stays selected, and the bulk bar keeps counting it, which is - * why `selectAllVisible` and `clear` are separate from `prune`. - * - * Range select: pass the row index and the click's `shiftKey` to `toggle` and - * a shift-click extends from the previously clicked row, the way a file - * manager does. - */ -export function useRowSelection(rows: T[], keyOf: (row: T) => string) { - const [selected, setSelected] = useState>(new Set()); - const lastIndexRef = useRef(null); - - const visibleKeys = useMemo(() => rows.map(keyOf), [rows, keyOf]); - - const isSelected = useCallback((key: string) => selected.has(key), [selected]); - - const toggle = useCallback((key: string, index?: number, shiftKey?: boolean) => { - // Read the anchor before scheduling the update: `setSelected`'s updater - // runs later (batched), by which point the `lastIndexRef.current = index` - // line below has already fired — reading the ref lazily inside the - // updater would make `anchor` equal the new `index` itself, collapsing - // every shift-click range down to the single just-clicked row. - const anchor = lastIndexRef.current; - setSelected((prev) => { - const next = new Set(prev); - if (shiftKey && anchor !== null && index !== undefined) { - const [from, to] = anchor <= index ? [anchor, index] : [index, anchor]; - // A shift-click always *adds* the range (never clears it), matching the - // way the rest of the app's multi-selects behave. - for (let i = from; i <= to; i++) next.add(visibleKeys[i]); - } else if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - return next; - }); - if (index !== undefined) lastIndexRef.current = index; - }, [visibleKeys]); - - const selectAllVisible = useCallback(() => { - setSelected((prev) => { - const next = new Set(prev); - visibleKeys.forEach((k) => next.add(k)); - return next; - }); - }, [visibleKeys]); - - const clearVisible = useCallback(() => { - setSelected((prev) => { - const next = new Set(prev); - visibleKeys.forEach((k) => next.delete(k)); - return next; - }); - }, [visibleKeys]); - - const clear = useCallback(() => { - setSelected(new Set()); - lastIndexRef.current = null; - }, []); - - /** Drop keys that no longer exist — call after a reload so removed rows - * don't linger in the count of a stale selection. */ - const prune = useCallback((existing: string[]) => { - const alive = new Set(existing); - setSelected((prev) => { - const next = new Set([...prev].filter((k) => alive.has(k))); - return next.size === prev.size ? prev : next; - }); - }, []); - - const allVisibleSelected = visibleKeys.length > 0 && visibleKeys.every((k) => selected.has(k)); - const someVisibleSelected = !allVisibleSelected && visibleKeys.some((k) => selected.has(k)); - - const toggleAllVisible = useCallback(() => { - if (allVisibleSelected) clearVisible(); - else selectAllVisible(); - }, [allVisibleSelected, clearVisible, selectAllVisible]); - - return { - selected, - keys: useMemo(() => Array.from(selected), [selected]), - count: selected.size, - isSelected, - toggle, - selectAllVisible, - toggleAllVisible, - clear, - prune, - allVisibleSelected, - someVisibleSelected, - /** Visible rows not yet selected — drives the "Select all N" affordance. */ - unselectedVisibleCount: visibleKeys.filter((k) => !selected.has(k)).length, - }; -} diff --git a/src/components/tools/container/useSort.test.ts b/src/components/tools/container/useSort.test.ts deleted file mode 100644 index 5bf28b63..00000000 --- a/src/components/tools/container/useSort.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import { act, renderHook } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { useSort } from './useSort'; - -type Row = { name: string; size: number }; - -const rows: Row[] = [ - { name: 'zebra', size: 3 }, - { name: 'apple', size: 1 }, - { name: 'mango', size: 2 }, -]; - -const getters = { - name: (r: Row) => r.name, - size: (r: Row) => r.size, -}; - -// Bọc trong StrictMode để bắt đúng lỗi đã xảy ra thật: React cố ý gọi hàm cập -// nhật state HAI LẦN ở dev để phát hiện side effect bên trong nó. Bản cũ của -// useSort gọi `setSortDir` như side effect bên trong hàm cập nhật của -// `setSortKey` — dưới StrictMode, lời gọi đó chạy hai lần, tự đảo hướng hai -// lần rồi triệt tiêu nhau, nên cột đã sort không bao giờ chuyển sang giảm -// dần được. Không bọc StrictMode thì test này xanh ngay cả với code cũ. -const strict = ({ children }: { children: React.ReactNode }) => - React.createElement(React.StrictMode, null, children); - -describe('useSort', () => { - it('bấm lần đầu vào một cột mới sort tăng dần', () => { - const { result } = renderHook(() => useSort(rows, getters), { wrapper: strict }); - act(() => result.current.toggleSort('name')); - expect(result.current.directionFor('name')).toBe('asc'); - expect(result.current.sorted.map((r) => r.name)).toEqual(['apple', 'mango', 'zebra']); - }); - - it('bấm lần hai vào CÙNG cột đảo sang giảm dần — ca lỗi thật dưới StrictMode', () => { - const { result } = renderHook(() => useSort(rows, getters), { wrapper: strict }); - act(() => result.current.toggleSort('name')); - act(() => result.current.toggleSort('name')); - expect(result.current.directionFor('name')).toBe('desc'); - expect(result.current.sorted.map((r) => r.name)).toEqual(['zebra', 'mango', 'apple']); - }); - - it('bấm lần ba quay lại tăng dần', () => { - const { result } = renderHook(() => useSort(rows, getters), { wrapper: strict }); - act(() => result.current.toggleSort('name')); - act(() => result.current.toggleSort('name')); - act(() => result.current.toggleSort('name')); - expect(result.current.directionFor('name')).toBe('asc'); - }); - - it('chuyển sang cột KHÁC luôn bắt đầu lại từ tăng dần', () => { - const { result } = renderHook(() => useSort(rows, getters), { wrapper: strict }); - act(() => result.current.toggleSort('name')); - act(() => result.current.toggleSort('name')); // name: desc - act(() => result.current.toggleSort('size')); // đổi cột — phải về asc, không kế thừa desc - expect(result.current.directionFor('size')).toBe('asc'); - expect(result.current.directionFor('name')).toBeNull(); - expect(result.current.sorted.map((r) => r.size)).toEqual([1, 2, 3]); - }); - - it('defaultKey đặt cột sort ban đầu mà không cần bấm', () => { - const { result } = renderHook(() => useSort(rows, getters, 'size'), { wrapper: strict }); - expect(result.current.directionFor('size')).toBe('asc'); - expect(result.current.sorted.map((r) => r.size)).toEqual([1, 2, 3]); - }); -}); diff --git a/src/components/tools/container/useSort.ts b/src/components/tools/container/useSort.ts deleted file mode 100644 index 7835883b..00000000 --- a/src/components/tools/container/useSort.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { useMemo, useState } from 'react'; -import type { SortDirection } from '@/components/ui/data-table'; - -/** - * Client-side column sort for a table whose rows are already fully loaded - * (every container-tool list view: containers, images, volumes, networks). - * `getters` maps a column key to a value accessor; sorting is stable and - * falls back to string comparison unless both values are numbers. - */ -export function useSort(rows: T[], getters: Record string | number>, defaultKey?: string) { - // Cột và chiều nằm CHUNG một state, cập nhật bằng đúng MỘT setState thuần — - // không phải hai state riêng đổi lồng vào nhau. - // - // Bản trước: `setSortKey(prev => { ...; setSortDir(...); return prev; })` — - // gọi `setSortDir` như một SIDE EFFECT bên trong hàm cập nhật của - // `setSortKey`. Dưới `React.StrictMode` (bật ở main.tsx), React cố ý gọi - // hàm cập nhật state HAI LẦN ở môi trường dev để bắt đúng loại lỗi này — - // nên `setSortDir` bị gọi hai lần mỗi cú click, tự đảo hai lần và triệt - // tiêu nhau: bấm cột đã sort không bao giờ chuyển sang giảm dần được, dù - // code "nhìn" thì đúng logic. Gộp lại thành một state, một setState thuần - // là cách duy nhất tắt được lớp bẫy này, không phải né StrictMode. - const [sort, setSort] = useState<{ key: string | null; dir: SortDirection }>({ - key: defaultKey ?? null, - dir: 'asc', - }); - - const sorted = useMemo(() => { - const getter = sort.key ? getters[sort.key] : null; - if (!getter) return rows; - const withIndex = rows.map((row, index) => ({ row, index })); - withIndex.sort((a, b) => { - const av = getter(a.row); - const bv = getter(b.row); - const cmp = typeof av === 'number' && typeof bv === 'number' - ? av - bv - : String(av).localeCompare(String(bv)); - if (cmp !== 0) return sort.dir === 'asc' ? cmp : -cmp; - return a.index - b.index; // stable tie-break - }); - return withIndex.map((w) => w.row); - // `getters` is deliberately excluded: callers pass a fresh object literal - // every render, so including it would make this memo never hit — but its - // closures (e.g. a size map still loading in) can change independently of - // `rows`/`sort`, so skip the memo instead of risking a stale sort order. - // Sorting a docker-scale list (rarely thousands of rows) on every render - // is cheap enough that this isn't a real perf cost. - }, [rows, sort.key, sort.dir, getters]); - - const toggleSort = (key: string) => { - setSort((prev) => ( - prev.key === key - ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } - : { key, dir: 'asc' } - )); - }; - - const directionFor = (key: string): SortDirection | null => (sort.key === key ? sort.dir : null); - - return { sorted, toggleSort, directionFor }; -} diff --git a/src/components/tools/kafka/BrokerForm.tsx b/src/components/tools/kafka/BrokerForm.tsx deleted file mode 100644 index 97661d53..00000000 --- a/src/components/tools/kafka/BrokerForm.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { useState, useEffect } from 'react'; -import { ModalShell } from '@/components/ui/modal-shell'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Info } from 'lucide-react'; -import { Callout } from '@/components/ui/callout'; -import type { BrokerConfig } from './types'; - -interface BrokerFormProps { - initial?: BrokerConfig | null; - onSave: (config: BrokerConfig) => Promise; - onCancel: () => void; -} - -const EMPTY: BrokerConfig = { - id: '', - name: '', - bootstrapServers: 'localhost:9092', - sslEnabled: false, -}; - -export function BrokerForm({ initial, onSave, onCancel }: BrokerFormProps) { - const [form, setForm] = useState(initial ?? EMPTY); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(''); - - useEffect(() => { - setForm(initial ?? EMPTY); - setError(''); - }, [initial]); - - const set = (k: keyof BrokerConfig, v: string | boolean) => - setForm((f) => ({ ...f, [k]: v })); - - const handleSubmit = async () => { - if (!form.name.trim()) { setError('Name is required'); return; } - if (!form.bootstrapServers.trim()) { setError('Bootstrap servers is required'); return; } - setSaving(true); - setError(''); - try { - // Only persist fields the app actually uses. TLS/SASL aren't supported yet, - // so we never collect or store credentials — and editing a config that was - // saved by an older build drops any previously stored password here. - await onSave({ - id: form.id, - name: form.name, - bootstrapServers: form.bootstrapServers, - sslEnabled: false, - }); - } catch (e) { - setError(String(e)); - } finally { - setSaving(false); - } - }; - - // ModalShell portals to , so the fixed overlay escapes the tool's - // entrance-animation wrapper (an animating `transform` ancestor would offset - // a fixed child). - return ( - - - - - } - > -
-
- - set('name', e.target.value)} - placeholder="My Kafka Cluster" - className="mt-1" - /> -
- -
- - set('bootstrapServers', e.target.value)} - placeholder="localhost:9092" - className="mt-1 font-mono text-sm" - /> -

Kafka broker ports only — not ZooKeeper (2181)

-
- - - Connections are plaintext. TLS/SSL and SASL - authentication are not yet supported — don't point this at a broker that requires - encryption or credentials. - - - {error && ( -

{error}

- )} -
-
- ); -} diff --git a/src/components/tools/kafka/ConfigTab.tsx b/src/components/tools/kafka/ConfigTab.tsx deleted file mode 100644 index 65ca82e6..00000000 --- a/src/components/tools/kafka/ConfigTab.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Search } from 'lucide-react'; -import { Input } from '@/components/ui/input'; -import { Callout } from '@/components/ui/callout'; -import { LoadingRow } from '@/components/ui/spinner'; -import { cn } from '@/lib/utils'; -import { type TopicConfig } from './types'; -import { useKafkaApi } from './api_sdk'; - -interface ConfigTabProps { - brokerId: string; - topic: string; -} - -export function ConfigTab({ brokerId, topic }: ConfigTabProps) { - const kafkaApi = useKafkaApi(); - const [configs, setConfigs] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [filter, setFilter] = useState(''); - - useEffect(() => { - setLoading(true); - setError(''); - kafkaApi.topicConfigs(brokerId, topic) - .then(setConfigs) - .catch((e) => setError(String(e))) - .finally(() => setLoading(false)); - }, [brokerId, topic]); - - const filtered = filter - ? configs.filter( - (c) => - c.name.toLowerCase().includes(filter.toLowerCase()) || - (c.value ?? '').toLowerCase().includes(filter.toLowerCase()), - ) - : configs; - - if (loading) { - return ( - - ); - } - - if (error) { - return ( - {error} - ); - } - - return ( -
- {/* Filter bar */} -
- - setFilter(e.target.value)} - placeholder="Filter name or value…" - className="h-ctl text-xs border-0 shadow-none focus-visible:ring-0 px-0" - /> - {filter && ( - - {filtered.length} of {configs.length} - - )} -
- - {/* Config table */} -
- {/* Sticky header */} -
- Name - Value -
- - {filtered.length === 0 ? ( -
No matching configs
- ) : ( - filtered.map((c, i) => ( -
- {c.name} - - {c.value ?? '(null)'} - -
- )) - )} -
-
- ); -} diff --git a/src/components/tools/kafka/ConsumeView.tsx b/src/components/tools/kafka/ConsumeView.tsx deleted file mode 100644 index 204ec7d1..00000000 --- a/src/components/tools/kafka/ConsumeView.tsx +++ /dev/null @@ -1,483 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { - Radio, Play, Pause, Square, ChevronDown, ChevronRight, Check, RefreshCw, Trash, ArrowLeft, -} from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Segmented } from '@/components/ui/segmented'; -import { CopyButton } from '@/components/ui/copy-button'; -import { StatusDot } from '@/components/ui/status-dot'; -import { SearchInput } from '@/components/ui/search-input'; -import { Badge } from '@/components/ui/badge'; -import { Callout } from '@/components/ui/callout'; -import { Spinner } from '@/components/ui/spinner'; -import { SectionLabel } from '@/components/ui/section-label'; -import { cn } from '@/lib/utils'; -import { type ConsumeFrom, type KafkaConsumedMessage } from './types'; -import { useKafkaApi } from './api_sdk'; -import { kafkaConsumerStore, useKafkaConsumers, type KafkaConsumerSession } from './kafkaConsumerStore'; -import { kafkaInputHistory, useKafkaRecentMatches } from './kafkaInputHistoryStore'; -import { RecentSuggestions } from './RecentSuggestions'; -import { CodeViewer } from '@/design-system'; -import type { TopicPrefill } from './useKafkaState'; - -type ValueFormat = 'json' | 'plain' | 'hex'; - -interface ConsumeViewProps { - brokerId: string; - refreshKey: number; - onRefresh: () => void; - prefill?: TopicPrefill | null; - /** Topic of the consumer to show in detail; null = the consumer list. */ - detailTopic: string | null; - onOpenConsumer: (topic: string) => void; - onCloseDetail: () => void; -} - -export function ConsumeView({ brokerId, refreshKey, onRefresh, prefill, detailTopic, onOpenConsumer, onCloseDetail }: ConsumeViewProps) { - const sessions = useKafkaConsumers().filter((s) => s.brokerId === brokerId); - const detail = detailTopic ? sessions.find((s) => s.topic === detailTopic) : undefined; - - // Detail panel for a single consumer (when one is selected and still running). - if (detailTopic && detail) { - return ; - } - - // Master: the list of consumers + the start form. - return ( -
-
-
- -
-

Consume (realtime)

-

{sessions.length} active · anonymous, no offsets committed

-
-
- -
- -
-
- - -
- Active consumers - {sessions.length === 0 - ?

No consumers running. Start one above to watch a topic in realtime.

- : ( -
- {sessions.map((s) => onOpenConsumer(s.topic)} />)} -
- )} -
-
-
-
- ); -} - -/** Compact row in the consumer list — click to open its detail panel. */ -function ConsumerListRow({ session: s, onOpen }: { session: KafkaConsumerSession; onOpen: () => void }) { - return ( -
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpen(); } }} - className="flex items-center gap-2 px-3 py-2.5 cursor-pointer hover:bg-bg-2/40" - > - - {s.topic} - {s.from === 'latest' ? 'new only' : 'from start'} - {s.paused && ( - paused - )} - - {s.starting ? 'starting…' : `${s.received.toLocaleString()} received`} - - e.stopPropagation()} className="shrink-0"> - - - -
- ); -} - -function StartConsumerForm({ brokerId, refreshKey, sessions, prefill, onStarted }: { - brokerId: string; refreshKey: number; sessions: KafkaConsumerSession[]; prefill?: TopicPrefill | null; onStarted: (topic: string) => void; -}) { - const kafkaApi = useKafkaApi(); - const [topic, setTopic] = useState(''); - const [from, setFrom] = useState('latest'); - const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); - const [topics, setTopics] = useState([]); - - useEffect(() => { - let alive = true; - kafkaApi.listTopics(brokerId) - .then((ts) => { if (alive) setTopics(ts.map((t) => t.name).sort((a, b) => a.localeCompare(b))); }) - .catch(() => { if (alive) setTopics([]); }); - return () => { alive = false; }; - }, [brokerId, refreshKey]); - - useEffect(() => { - if (prefill?.topic) { setTopic(prefill.topic); setError(null); } - }, [prefill?.token]); - - const alreadyRunning = sessions.some((s) => s.topic === topic.trim()); - - const start = async () => { - if (!topic.trim()) { setError('Pick or type a topic'); return; } - if (alreadyRunning) { setError('A consumer is already running on this topic'); return; } - setError(null); - setBusy(true); - const started = topic.trim(); - try { - await kafkaConsumerStore.start(brokerId, started, from); - kafkaInputHistory.add(brokerId, 'topic', started); // remember for the combobox - setTopic(''); - onStarted(started); // jump straight to the new consumer's detail panel - } catch (e) { - setError(String(e instanceof Error ? e.message : e)); - } finally { - setBusy(false); - } - }; - - return ( -
-

Start a consumer

-
-
- - { setTopic(v); setError(null); }} /> -
-
- -
- - value={from} - onValueChange={setFrom} - size="sm" - options={[ - { value: 'latest', label: 'New only' }, - { value: 'earliest', label: 'Beginning' }, - ]} - /> -
-
-
-

- {from === 'latest' - ? 'Streams only messages produced after you start (all partitions).' - : 'Replays from the earliest retained offset, then follows new messages (all partitions).'} - {' '}Anonymous: no consumer group is joined and no offsets are committed, so real consumers are unaffected. -

- -
- - {alreadyRunning && Already consuming this topic} -
- {error && {error}} -
- ); -} - -/** Full-panel detail view for a single consumer: status, controls, search, messages. */ -function ConsumerDetail({ session: s, onBack }: { session: KafkaConsumerSession; onBack: () => void }) { - const [query, setQuery] = useState(''); - const [format, setFormat] = useState('json'); - - const q = query.trim().toLowerCase(); - const matches = q - ? s.messages.filter((m) => - (m.value ?? '').toLowerCase().includes(q) - || (m.key ?? '').toLowerCase().includes(q) - || String(m.partition).includes(q) - || String(m.offset).includes(q)) - : s.messages; - const shown = matches.slice(0, 200); - const buffered = s.messages.length; - const capped = s.received > buffered; - - return ( -
- {/* Header: back · topic · status · controls */} -
-
- - -
-

{s.topic}

-

- {s.from === 'latest' ? 'new only' : 'from start'} - {' · '} - {s.starting ? 'starting…' : `${s.received.toLocaleString()} received`} - {capped && ` · keeping last ${buffered.toLocaleString()}`} - {s.paused && s.bufferedWhilePaused > 0 && ` · +${s.bufferedWhilePaused.toLocaleString()} buffered`} -

-
-
-
- - - -
-
- - {/* Search + value format */} -
- - - value={format} - onValueChange={setFormat} - size="sm" - options={[ - { value: 'json', label: 'JSON' }, - { value: 'plain', label: 'Plain' }, - { value: 'hex', label: 'Hex' }, - ]} - /> - - {q ? `${matches.length.toLocaleString()} match${matches.length === 1 ? '' : 'es'}` : `${buffered.toLocaleString()} buffered`} - {capped && ' · capped'} - -
- - {/* Messages */} -
- {shown.length === 0 - ?

{q ? 'No messages match your search.' : (s.starting ? 'Starting…' : 'Waiting for messages…')}

- : ( -
- {shown.map((m) => )} - {matches.length > shown.length && ( -

- Showing first 200 of {matches.length.toLocaleString()}{q ? ' matches' : ''}. Narrow your search to see more. -

- )} -
- )} -
-
- ); -} - -function MessageRow({ m, format }: { m: KafkaConsumedMessage; format: ValueFormat }) { - const [expanded, setExpanded] = useState(false); - const headerEntries = Object.entries(m.headers ?? {}); - - return ( -
- {/* Compact, scannable row — click to expand the full payload. */} -
setExpanded((e) => !e)} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setExpanded((x) => !x); } }} - className="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-bg-2/40" - > - {expanded ? : } - {fmtTime(m.timestamp)} - p{m.partition} - @{m.offset} - {m.key != null && m.key !== '' && ( - {m.key} - )} - {previewValue(m, format)} - {headerEntries.length > 0 && ( - ⌗{headerEntries.length} - )} - e.stopPropagation()} className="shrink-0"> - - -
- - {/* Expanded detail — full value, key, and headers for tracing. */} - {expanded && ( -
-
- partition {m.partition} - offset {m.offset} - {m.timestamp} -
- - {m.key != null && m.key !== '' && ( - - )} - - {headerEntries.length > 0 && ( -
-
Headers
-
- {headerEntries.map(([k, v]) => ( -
- {k} - {v} -
- ))} -
-
- )} - -
-
- Value - -
- {format === 'hex' ? ( -
{formatValue(m, format)}
- ) : ( -
- -
- )} -
-
- )} -
- ); -} - -function DetailField({ label, value }: { label: string; value: string }) { - return ( -
-
- {label} - -
-
{value}
-
- ); -} - -/** Local HH:MM:SS.mmm for quick scanning (falls back to the raw timestamp). */ -function fmtTime(ts: string): string { - const d = new Date(ts); - if (Number.isNaN(d.getTime())) return ts; - const p = (n: number, l = 2) => String(n).padStart(l, '0'); - return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`; -} - -/** One-line, whitespace-collapsed preview of the value for the compact row. */ -function previewValue(m: KafkaConsumedMessage, format: ValueFormat): string { - if (format === 'hex') { - if (!m.valueB64) return m.value === null ? '(null)' : '(empty)'; - try { return `${base64ToBytes(m.valueB64).length} bytes (hex)`; } catch { return '(binary)'; } - } - if (m.value === null) return m.valueB64 ? '(binary — switch to Hex)' : '(null)'; - return m.value.replace(/\s+/g, ' ').trim() || '(empty)'; -} - -/** Render the record value per the chosen format. */ -function formatValue(m: KafkaConsumedMessage, format: ValueFormat): string { - if (format === 'hex') { - if (!m.valueB64) return m.value === null ? '(null)' : '(empty)'; - try { return hexDump(base64ToBytes(m.valueB64)); } catch { return '(unable to decode)'; } - } - if (m.value === null) return m.valueB64 ? '(binary — switch to Hex)' : '(null)'; - if (format === 'json') { - try { return JSON.stringify(JSON.parse(m.value), null, 2); } catch { return m.value; } - } - return m.value; // plain -} - -function base64ToBytes(b64: string): Uint8Array { - const bin = atob(b64); - const out = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); - return out; -} - -/** Classic 16-byte-per-row hex dump with an ASCII gutter. */ -function hexDump(bytes: Uint8Array): string { - const lines: string[] = []; - for (let i = 0; i < bytes.length; i += 16) { - const slice = bytes.subarray(i, i + 16); - const hex = Array.from(slice).map((b) => b.toString(16).padStart(2, '0')).join(' ').padEnd(16 * 3 - 1, ' '); - const ascii = Array.from(slice).map((b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : '.')).join(''); - lines.push(`${i.toString(16).padStart(8, '0')} ${hex} ${ascii}`); - } - return lines.join('\n') || '(empty)'; -} - -/** Searchable topic picker that also accepts a typed (custom) topic name. */ -function TopicCombobox({ brokerId, value, topics, onChange }: { - brokerId: string; value: string; topics: string[]; onChange: (v: string) => void; -}) { - const [open, setOpen] = useState(false); - const blurTimer = useRef | null>(null); - const recent = useKafkaRecentMatches(brokerId, 'topic', value); - const q = value.trim().toLowerCase(); - const matches = topics.filter((t) => t.toLowerCase().includes(q) && !recent.includes(t)).slice(0, 50); - - const pick = (v: string) => { - if (blurTimer.current) clearTimeout(blurTimer.current); - onChange(v); - setOpen(false); - }; - - return ( -
- { onChange(e.target.value); setOpen(true); }} - onFocus={() => setOpen(true)} - onBlur={() => { blurTimer.current = setTimeout(() => setOpen(false), 120); }} - onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false); }} - placeholder="topic name — type to search" - className="font-mono text-sm h-ctl" - /> - {open && (recent.length > 0 || matches.length > 0) && ( -
- - {matches.map((t) => ( - - ))} -
- )} -
- ); -} diff --git a/src/components/tools/kafka/ConsumersTab.tsx b/src/components/tools/kafka/ConsumersTab.tsx deleted file mode 100644 index d76d859e..00000000 --- a/src/components/tools/kafka/ConsumersTab.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { useEffect, useState } from 'react'; -import { AlertCircle, RefreshCw, ChevronRight, ChevronDown } from 'lucide-react'; -import { LoadingRow } from '@/components/ui/spinner'; -import { Button } from '@/components/ui/button'; -import { type GroupLag } from './types'; -import { useKafkaApi } from './api_sdk'; - -interface ConsumersTabProps { - brokerId: string; - topic: string; - onSelectGroup: (groupId: string) => void; -} - -// Group per-partition lag rows by consumer group, preserving order. -function groupByGroup(rows: GroupLag[]): [string, GroupLag[]][] { - const byGroup = new Map(); - for (const r of rows) { - const arr = byGroup.get(r.groupId); - if (arr) arr.push(r); - else byGroup.set(r.groupId, [r]); - } - return [...byGroup.entries()]; -} - -// Consumer groups are fetched only when this tab is opened (it's mounted -// lazily by TopicView), and only re-fetched when the user clicks Refresh — -// never automatically, since the scan asks every group for its offsets. -export function ConsumersTab({ brokerId, topic, onSelectGroup }: ConsumersTabProps) { - const kafkaApi = useKafkaApi(); - const [groups, setGroups] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - // Consumer groups expanded in the list. Collapsed by default. - const [expanded, setExpanded] = useState>(new Set()); - - const load = () => { - setLoading(true); - setError(''); - kafkaApi.topicConsumerGroups(brokerId, topic) - .then((g) => { - setGroups(g); - setExpanded(new Set()); - }) - .catch((e) => setError(String(e))) - .finally(() => setLoading(false)); - }; - - const toggleGroup = (groupId: string) => - setExpanded((prev) => { - const next = new Set(prev); - next.has(groupId) ? next.delete(groupId) : next.add(groupId); - return next; - }); - - // Load once when the tab first opens for this topic. - useEffect(() => { - load(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [brokerId, topic]); - - if (loading && !groups) { - return ( - - ); - } - - if (error) { - return ( -
-
- - {error} -
- -
- ); - } - - if (!groups || groups.length === 0) { - return ( -
-

No consumer groups committed to this topic

- -
- ); - } - - const grouped = groupByGroup(groups); - - return ( -
-
- - {grouped.length} group{grouped.length !== 1 ? 's' : ''} - - -
- - {grouped.map(([groupId, parts]) => { - const open = expanded.has(groupId); - const totalLag = parts.reduce((s, p) => s + (p.lag > 0 ? p.lag : 0), 0); - const hasLag = parts.some((p) => p.lag > 0); - return ( -
- {/* Group header — toggles partition detail; group name opens the group */} -
toggleGroup(groupId)} - > - {open - ? - : } - - - {parts.length} part{parts.length !== 1 ? 's' : ''} - - - lag {totalLag.toLocaleString()} - -
- - {open && ( -
- {/* Per-partition column headers */} -
- Part - Committed - Latest - Lag -
- - {parts.map((p) => { - const lagColor = p.lag < 0 - ? 'text-fg-mute' - : p.lag === 0 - ? 'text-ok' - : 'text-warn'; - const latest = p.lag >= 0 && p.committedOffset >= 0 ? p.committedOffset + p.lag : -1; - - return ( -
- {p.partition} - - {p.committedOffset >= 0 ? p.committedOffset.toLocaleString() : '—'} - - - {latest >= 0 ? latest.toLocaleString() : '—'} - - - {p.lag >= 0 ? p.lag.toLocaleString() : '—'} - -
- ); - })} -
- )} -
- ); - })} -
- ); -} diff --git a/src/components/tools/kafka/GroupListView.tsx b/src/components/tools/kafka/GroupListView.tsx deleted file mode 100644 index 3ce15c98..00000000 --- a/src/components/tools/kafka/GroupListView.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { useEffect, useState } from 'react'; -import { RefreshCw, Users } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { SearchInput } from '@/components/ui/search-input'; -import { ViewHeader } from '@/components/ui/view-header'; -import { Callout } from '@/components/ui/callout'; -import { LoadingRow } from '@/components/ui/spinner'; -import { StatusDot, type StatusDotTone } from '@/components/ui/status-dot'; -import { DataTable, Thead, Tbody, Tr, Th, Td, DataTableStatus } from '@/components/ui/data-table'; -import { type GroupSummary } from './types'; -import { useKafkaApi } from './api_sdk'; - -const STATE_TONE: Record = { - Stable: 'live', - Empty: 'idle', - Dead: 'error', - PreparingRebalance: 'starting', - CompletingRebalance: 'starting', -}; - -interface GroupListViewProps { - brokerId: string; - refreshKey: number; - onRefresh: () => void; - onSelectGroup: (id: string) => void; -} - -export function GroupListView({ brokerId, refreshKey, onRefresh, onSelectGroup }: GroupListViewProps) { - const kafkaApi = useKafkaApi(); - const [filter, setFilter] = useState(''); - const [groups, setGroups] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - setLoading(true); - setError(null); - kafkaApi.listGroups(brokerId) - .then((g) => setGroups(g.sort((a, b) => a.groupId.localeCompare(b.groupId)))) - .catch((e) => { setGroups([]); setError(String(e instanceof Error ? e.message : e)); }) - .finally(() => setLoading(false)); - }, [brokerId, refreshKey]); - - const f = filter.trim().toLowerCase(); - const rows = (groups ?? []).filter((g) => g.groupId.toLowerCase().includes(f)); - - return ( -
- Refresh} - /> - -
- -
- -
- {loading && !groups && } - {error && {error}} - {groups && !error && ( - rows.length === 0 - ?

{f ? 'No matching groups.' : 'No consumer groups.'}

- : ( -
- - - - Group - State - Protocol - - - - {rows.map((g) => ( - onSelectGroup(g.groupId)}> - {g.groupId} - - - - {g.state || '—'} - - - {g.protocolType || '—'} - - ))} - - - {/* Tiêu đề trang ghi TỔNG số, còn bảng thì đang LỌC — hai con số khác - nhau và trước đây không chỗ nào nói ra. Dòng này nói cả hai. */} - -
- ) - )} -
-
- ); -} diff --git a/src/components/tools/kafka/GroupView.tsx b/src/components/tools/kafka/GroupView.tsx deleted file mode 100644 index 0fe86aa1..00000000 --- a/src/components/tools/kafka/GroupView.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import { useEffect, useState } from 'react'; -import { RefreshCw, ChevronRight, ChevronDown, Users } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Callout } from '@/components/ui/callout'; -import { LoadingRow } from '@/components/ui/spinner'; -import { SectionLabel } from '@/components/ui/section-label'; -import { cn } from '@/lib/utils'; -import { ViewHeader } from '@/components/ui/view-header'; -import { type GroupDetails, type Assignment } from './types'; -import { useKafkaApi } from './api_sdk'; - -interface GroupViewProps { - brokerId: string; - groupId: string; - refreshKey: number; - onRefresh: () => void; - onSelectTopic: (topic: string) => void; - onBack: () => void; -} - -// Group assignments by topic, preserving the backend's topic/partition order. -function groupByTopic(assignments: Assignment[]): [string, Assignment[]][] { - const byTopic = new Map(); - for (const a of assignments) { - const arr = byTopic.get(a.topic); - if (arr) arr.push(a); - else byTopic.set(a.topic, [a]); - } - return [...byTopic.entries()]; -} - -const STATE_DOT: Record = { - Stable: 'bg-ok', - Empty: 'bg-fg-mute/40', - Dead: 'bg-bad', - PreparingRebalance: 'bg-warn', - CompletingRebalance: 'bg-warn', -}; - -export function GroupView({ brokerId, groupId, refreshKey, onRefresh, onSelectTopic, onBack }: GroupViewProps) { - const kafkaApi = useKafkaApi(); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - // Topics expanded in the assignments view. Collapsed by default. - const [expanded, setExpanded] = useState>(new Set()); - - useEffect(() => { - if (!brokerId || !groupId) return; - setLoading(true); - setError(''); - kafkaApi.groupDetails(brokerId, groupId) - .then((d) => { - setData(d); - setExpanded(new Set()); - }) - .catch((e) => setError(String(e))) - .finally(() => setLoading(false)); - }, [brokerId, groupId, refreshKey]); - - const toggleTopic = (topic: string) => - setExpanded((prev) => { - const next = new Set(prev); - next.has(topic) ? next.delete(topic) : next.add(topic); - return next; - }); - - return ( -
- {groupId}} - subtitle={data ? ( - - - {data.state} · {data.memberCount} member{data.memberCount !== 1 ? 's' : ''} - - ) : 'Consumer group'} - actions={( - - )} - /> - - {/* Content */} -
- {loading && ( - - )} - - {!loading && error && ( - {error} - )} - - {!loading && !error && data && ( - <> - {/* Members */} - {data.members.length > 0 && ( -
- Members -
- {data.members.map((m) => ( -
- {m.clientId || m.memberId} - {m.clientHost} -
- ))} -
-
- )} - - {data.assignments.length === 0 ? ( -
- No committed offsets — group may not be active yet -
- ) : ( - groupByTopic(data.assignments).map(([topic, parts]) => { - const open = expanded.has(topic); - const totalLag = parts.reduce((s, a) => s + (a.lag > 0 ? a.lag : 0), 0); - const hasLag = parts.some((a) => a.lag > 0); - return ( -
- {/* Topic header — toggles partition detail; topic name browses the topic */} -
toggleTopic(topic)} - > - {open - ? - : } - - - {parts.length} part{parts.length !== 1 ? 's' : ''} - - - lag {totalLag.toLocaleString()} - -
- - {open && ( -
- {/* Per-partition column headers */} -
- Part - Consumer - Committed - Latest - Lag -
- - {parts.map((a) => { - const lagColor = a.lag < 0 - ? 'text-fg-mute' - : a.lag === 0 - ? 'text-ok' - : 'text-warn'; - const latest = a.lag >= 0 && a.committedOffset >= 0 - ? a.committedOffset + a.lag - : -1; - - return ( -
- {a.partition} - - {a.clientId ? ( - <> - {a.clientId} - {a.clientHost && ( - {a.clientHost} - )} - - ) : ( - unassigned - )} - - - {a.committedOffset >= 0 ? a.committedOffset.toLocaleString() : '—'} - - - {latest >= 0 ? latest.toLocaleString() : '—'} - - - {a.lag >= 0 ? a.lag.toLocaleString() : '—'} - -
- ); - })} -
- )} -
- ); - }) - )} - - )} -
-
- ); -} diff --git a/src/components/tools/kafka/KafkaExplorer.tsx b/src/components/tools/kafka/KafkaExplorer.tsx deleted file mode 100644 index 2ff3d8d8..00000000 --- a/src/components/tools/kafka/KafkaExplorer.tsx +++ /dev/null @@ -1,264 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useLiveConnection, usePluginMcpBridgeActive, usePluginSdkFor, usePluginState } from '@/platform'; -import { Server, Info, Plug } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Callout } from '@/components/ui/callout'; -import { Spinner } from '@/components/ui/spinner'; -import { LeftPanel } from './LeftPanel'; -import { TopicListView } from './TopicListView'; -import { GroupListView } from './GroupListView'; -import { TopicView } from './TopicView'; -import { GroupView } from './GroupView'; -import { ConsumeView } from './ConsumeView'; -import { ProduceView } from './ProduceView'; -import { KafkaInfoModal } from './KafkaInfoModal'; -import { ToolHeaderActions } from '@/components/ToolHeaderActions'; -import { useKafkaRuntime } from './mcpRuntimeContext'; -import { useMcpBridge } from './mcpBridge'; -import { useKafkaApi } from './api_sdk'; -import { kafkaConsumerStore } from './kafkaConsumerStore'; -import { cn } from '@/lib/utils'; - -const LEFT_MIN = 180; -const LEFT_MAX = 520; -const LEFT_DEFAULT = 256; - -export function KafkaExplorer() { - const kafkaApi = useKafkaApi(); - const kafkaState = useKafkaRuntime(); - const { - selectedBrokerId, - setSelectedBrokerId, - connectedBrokerId, - setConnectedBrokerId, - view, - selectedTopic, - selectedGroup, - selectedTab, - setSelectedTab, - consumePrefill, - producePrefill, - consumeDetailTopic, - showTopics, - showGroups, - showConsumers, - openConsumer, - showProduce, - selectTopic, - selectGroup, - refreshKey, - refresh, - } = kafkaState; - - const sdk = usePluginSdkFor('kafka-explorer'); - const mcpBridgeActive = usePluginMcpBridgeActive(sdk); - // Skipped while the background bridge (Settings → MCP) is on — that one - // instance, mounted once at the app root, already answers for this exact - // state regardless of which tool is on screen (see mcpRuntimeContext.tsx). - // Also skipped outright when the per-tool MCP toggle is off for Kafka Explorer. - useMcpBridge(kafkaState, mcpBridgeActive); - - const [connecting, setConnecting] = useState(false); - const [connectError, setConnectError] = useState(null); - const isConnected = !!selectedBrokerId && connectedBrokerId === selectedBrokerId; - - // Connect = verify the broker is reachable, then mark it live. Only one broker - // is live at a time, so connecting elsewhere stops the previous one's consumers. - const handleConnect = useCallback(async () => { - if (!selectedBrokerId) return; - setConnecting(true); - setConnectError(null); - try { - await kafkaApi.testConnection(selectedBrokerId); - if (connectedBrokerId && connectedBrokerId !== selectedBrokerId) kafkaConsumerStore.stopForBroker(connectedBrokerId); - setConnectedBrokerId(selectedBrokerId); - } catch (e) { - setConnectError(String(e instanceof Error ? e.message : e)); - } finally { - setConnecting(false); - } - }, [selectedBrokerId, connectedBrokerId, setConnectedBrokerId]); - - const handleDisconnect = useCallback(() => { - if (selectedBrokerId) kafkaConsumerStore.stopForBroker(selectedBrokerId); - setConnectError(null); - setConnectedBrokerId(''); - }, [selectedBrokerId, setConnectedBrokerId]); - - // Surface the live state to the app sidebar's connection indicator. - useLiveConnection(sdk, isConnected); - - // Stop any realtime consumers when leaving the tool so nothing keeps running - // in the background. They persist across view switches within the tool (the - // store lives outside this component tree), but not after it unmounts. - useEffect(() => () => { kafkaConsumerStore.stopAll(); }, []); - - const [infoDismissed, setInfoDismissed] = usePluginState(sdk, 'info-dismissed', false, { legacyKey: 'devtool:kafka:info-dismissed' }); - const [showInfo, setShowInfo] = useState(!infoDismissed); - const [leftWidth, setLeftWidth] = usePluginState(sdk, 'leftPanelWidth', LEFT_DEFAULT, { legacyKey: 'devtool:kafka:leftPanelWidth' }); - const [isResizing, setIsResizing] = useState(false); - - const [brokerName, setBrokerName] = useState(''); - useEffect(() => { - if (!selectedBrokerId) { setBrokerName(''); return; } - let alive = true; - kafkaApi.listConfigs() - .then((cfgs) => { if (alive) setBrokerName(cfgs.find((c) => c.id === selectedBrokerId)?.name ?? ''); }) - .catch(() => { if (alive) setBrokerName(''); }); - return () => { alive = false; }; - }, [selectedBrokerId, refreshKey]); - - const handleResizeStart = (e: React.MouseEvent) => { - e.preventDefault(); - const startX = e.clientX; - const startWidth = leftWidth; - setIsResizing(true); - - const onMouseMove = (ev: MouseEvent) => { - setLeftWidth(Math.min(Math.max(startWidth + ev.clientX - startX, LEFT_MIN), LEFT_MAX)); - }; - const onMouseUp = () => { - setIsResizing(false); - window.removeEventListener('mousemove', onMouseMove); - window.removeEventListener('mouseup', onMouseUp); - }; - window.addEventListener('mousemove', onMouseMove); - window.addEventListener('mouseup', onMouseUp); - }; - - return ( -
- {/* Info button injected into the global tool header */} - - - - - {/* Left panel — width is user-resizable and persisted */} -
- -
- - {/* Resize handle — 5px wide hit zone, 1px visible line that thickens on hover/drag */} -
setLeftWidth(LEFT_DEFAULT)} - title="Drag to resize · Double-click to reset" - > -
-
- - {/* Right panel */} -
- {!selectedBrokerId ? ( -
- -

Add a broker to get started

-
- ) : !isConnected ? ( - - ) : view === 'topic' && selectedTopic != null ? ( - showConsumers(selectedTopic)} - onProduce={() => showProduce(selectedTopic)} - onBackToTopics={showTopics} - /> - ) : view === 'group' && selectedGroup != null ? ( - - ) : view === 'groups' ? ( - - ) : view === 'consume' ? ( - - ) : view === 'produce' ? ( - - ) : ( - - )} -
- - {/* Info modal */} - {showInfo && ( - setShowInfo(false)} - onDismissPermanently={() => { - setInfoDismissed(true); - setShowInfo(false); - }} - /> - )} -
- ); -} - -/** Shown when a broker is selected but not yet connected. */ -function DisconnectedPanel({ name, connecting, error, onConnect }: { - name: string; connecting: boolean; error: string | null; onConnect: () => void; -}) { - return ( -
-
- -
- {name &&

{name}

} - - {error && {error}} -
- ); -} diff --git a/src/components/tools/kafka/KafkaInfoModal.tsx b/src/components/tools/kafka/KafkaInfoModal.tsx deleted file mode 100644 index 76bd63f6..00000000 --- a/src/components/tools/kafka/KafkaInfoModal.tsx +++ /dev/null @@ -1,184 +0,0 @@ - -import { ModalShell } from '@/components/ui/modal-shell'; -import { Badge, type BadgeTone } from '@/components/ui/badge'; -import { Callout } from '@/components/ui/callout'; -import { SectionLabel } from '@/components/ui/section-label'; - -interface KafkaInfoModalProps { - onClose: () => void; - onDismissPermanently: () => void; -} - -const BADGE_TONE: Record<'read' | 'write' | 'destructive' | 'warn', BadgeTone> = { - read: 'info', write: 'warning', destructive: 'danger', warn: 'warning', -}; - -interface RowProps { - action: string; - when: string; - calls: string; - badge: { label: string; variant: 'read' | 'write' | 'destructive' | 'warn' }; - note?: string; -} - -function Row({ action, when, calls, badge, note }: RowProps) { - return ( -
-
- {badge.label} -
-
- {action} - {when} -
-
{calls}
- {note && ( -
{note}
- )} -
-
-
- ); -} - -export function KafkaInfoModal({ onClose, onDismissPermanently }: KafkaInfoModalProps) { - // ModalShell portals to , which also keeps the fixed overlay out of the - // tool's entrance-animation wrapper: an animating `transform` ancestor would - // make the modal jump to viewport coords when the animation ends. - return ( - - - - - } - > - - {/* Connection model */} -

- No persistent connection.{' '} - Each action opens one TCP connection to your broker and closes it when done. - The connection starts with a short probe (MetadataRequest, to confirm this is a Kafka port) - and is then reused for the command. The app identifies itself to brokers as - client ID devtool. There is no background polling — - data loads when you open a view and refreshes only when you navigate or click Refresh. -

- - {/* Security note */} - - Connections are unencrypted — TLS/SSL and SASL authentication are not implemented. - Don't point this at a broker that requires encryption or credentials. - - - {/* Operations table */} -
-
- Operations -
-
- - - - - - - - - - - - -
-
- -

- Full documentation: docs/human/kafka-explorer.md -

-
- ); -} diff --git a/src/components/tools/kafka/LeftPanel.tsx b/src/components/tools/kafka/LeftPanel.tsx deleted file mode 100644 index 74b402f1..00000000 --- a/src/components/tools/kafka/LeftPanel.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { useEffect, useState } from 'react'; -import { - Plus, Pencil, Trash2, List, Users, Radio, Send, Square, ChevronRight, ChevronDown, Plug, PlugZap, } from 'lucide-react'; -import { Spinner } from '@/components/ui/spinner'; -import { Button } from '@/components/ui/button'; -import { - Select, SelectTrigger, SelectValue, SelectContent, SelectItem, -} from '@/components/ui/select'; -import { IconButton } from '@/components/ui/icon-button'; -import { StatusDot } from '@/components/ui/status-dot'; -import { cn } from '@/lib/utils'; -import { BrokerForm } from './BrokerForm'; -import { type BrokerConfig } from './types'; -import { useKafkaApi } from './api_sdk'; -import { kafkaConsumerStore, useKafkaConsumers } from './kafkaConsumerStore'; -import type { KafkaView } from './useKafkaState'; - -interface LeftPanelProps { - selectedBrokerId: string; - onSelectBroker: (id: string) => void; - connected: boolean; - connecting: boolean; - onConnect: () => void; - onDisconnect: () => void; - view: KafkaView; - onShowTopics: () => void; - onShowGroups: () => void; - onShowConsumers: (topic?: string) => void; - onOpenConsumer: (topic: string) => void; - onShowProduce: (topic?: string) => void; -} - -export function LeftPanel({ - selectedBrokerId, onSelectBroker, connected, connecting, onConnect, onDisconnect, - view, onShowTopics, onShowGroups, onShowConsumers, onOpenConsumer, onShowProduce, -}: LeftPanelProps) { - const kafkaApi = useKafkaApi(); - const [configs, setConfigs] = useState([]); - const [loading, setLoading] = useState(true); - const [formOpen, setFormOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [removeArmed, setRemoveArmed] = useState(false); - const [consumersExpanded, setConsumersExpanded] = useState(true); - - const conn = configs.find((c) => c.id === selectedBrokerId) ?? null; - const activeConsumers = useKafkaConsumers().filter((s) => s.brokerId === selectedBrokerId); - - const loadConfigs = () => { - setLoading(true); - kafkaApi.listConfigs() - .then(setConfigs) - .catch(() => setConfigs([])) - .finally(() => setLoading(false)); - }; - useEffect(() => { loadConfigs(); }, []); - - // Reset the armed remove after a few seconds, or when the selection changes. - useEffect(() => { - if (!removeArmed) return; - const t = window.setTimeout(() => setRemoveArmed(false), 3000); - return () => window.clearTimeout(t); - }, [removeArmed]); - useEffect(() => { setRemoveArmed(false); }, [selectedBrokerId]); - - const handleSave = async (config: BrokerConfig) => { - const saved = await kafkaApi.saveConfig(config); - setFormOpen(false); - setEditing(null); - loadConfigs(); - onSelectBroker(saved.id); - }; - - const handleRemove = async () => { - if (!conn) return; - if (!removeArmed) { setRemoveArmed(true); return; } - setRemoveArmed(false); - await kafkaApi.deleteConfig(conn.id); - loadConfigs(); - onSelectBroker(''); - }; - - return ( -
- {/* Broker picker */} -
-
- - -
- {conn && ( - <> -
- - - - {conn.bootstrapServers} - - -
- { setEditing(conn); setFormOpen(true); }}> - - - - - -
-
- {connected ? ( - - ) : ( - - )} - - )} -
- - {!conn ? ( -
- {!loading &&

Add a broker to get started.

} -
- ) : ( -
- - - onShowProduce()} /> - - {/* Consume — collapsible parent for the active realtime consumers. */} -
- - {activeConsumers.length > 0 && ( - {activeConsumers.length} - )} - -
- - {consumersExpanded && activeConsumers.map((s) => ( -
- - - {s.received.toLocaleString()} - -
- ))} -
- )} - - {formOpen && ( - { setFormOpen(false); setEditing(null); }} - /> - )} -
- ); -} - -function NavItem({ icon: Icon, label, active, onClick }: { - icon: typeof List; label: string; active: boolean; onClick: () => void; -}) { - return ( - - ); -} diff --git a/src/components/tools/kafka/MessagesTab.tsx b/src/components/tools/kafka/MessagesTab.tsx deleted file mode 100644 index b5c4fbfc..00000000 --- a/src/components/tools/kafka/MessagesTab.tsx +++ /dev/null @@ -1,931 +0,0 @@ -import { useState, useRef, useMemo, useEffect } from 'react'; -import { usePluginConfig, usePluginSdkFor, usePluginState } from '@/platform'; -import { AlertCircle, Search, X, ArrowUp, ArrowDown, ArrowUpDown, Regex, ChevronRight } from 'lucide-react'; -import { LoadingRow, Spinner } from '@/components/ui/spinner'; -import { SectionLabel } from '@/components/ui/section-label'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { CopyButton } from '@/components/ui/copy-button'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { cn } from '@/lib/utils'; -import { type KafkaMessage, type PartitionInfo } from './types'; -import { useKafkaApi } from './api_sdk'; - -// Export helpers — bulk copy of the currently-shown messages. -function messagesToJson(msgs: KafkaMessage[]): string { - return JSON.stringify(msgs, null, 2); -} -function messagesToCsv(msgs: KafkaMessage[]): string { - const esc = (s: unknown) => `"${String(s ?? '').replace(/"/g, '""')}"`; - const rows = [['partition', 'offset', 'timestamp', 'key', 'value', 'headers'].join(',')]; - for (const m of msgs) { - rows.push([ - m.partition, m.offset, esc(m.timestamp), esc(m.key), esc(m.value), esc(JSON.stringify(m.headers)), - ].join(',')); - } - return rows.join('\n'); -} - -type FetchMode = 'tail' | 'from' | 'range' | 'time'; - -const SINCE_PRESETS: { ms: number; label: string }[] = [ - { ms: 15 * 60_000, label: 'Last 15 min' }, - { ms: 60 * 60_000, label: 'Last 1 hour' }, - { ms: 6 * 60 * 60_000, label: 'Last 6 hours' }, - { ms: 24 * 60 * 60_000, label: 'Last 24 hours' }, - { ms: 7 * 24 * 60 * 60_000, label: 'Last 7 days' }, -]; -type ValueMode = 'text' | 'json' | 'hex'; -type SortCol = 'partition' | 'offset' | 'key' | 'value' | 'timestamp'; -type SortDir = 'asc' | 'desc'; - -interface ColWidths { - partition: number; - offset: number; - key: number; - timestamp: number; -} - -const DEFAULT_WIDTHS: ColWidths = { partition: 40, offset: 88, key: 120, timestamp: 144 }; -const MIN_WIDTHS: ColWidths = { partition: 32, offset: 56, key: 60, timestamp: 90 }; -const DETAIL_MIN = 220; -const DETAIL_DEFAULT = 384; -const LIMITS = [20, 50, 100, 200] as const; - -interface MessagesTabProps { - brokerId: string; - topic: string; - partitions: PartitionInfo[]; -} - -function tryFormatJson(s: string): string { - try { return JSON.stringify(JSON.parse(s), null, 2); } catch { return s; } -} - -function toHexView(s: string): string { - const bytes = new TextEncoder().encode(s); - if (bytes.length === 0) return '(empty)'; - const lines: string[] = []; - for (let i = 0; i < bytes.length; i += 16) { - const chunk = bytes.slice(i, i + 16); - const hex = Array.from(chunk).map((b) => b.toString(16).padStart(2, '0')).join(' '); - const ascii = Array.from(chunk) - .map((b) => (b >= 32 && b < 127 ? String.fromCharCode(b) : '.')) - .join(''); - lines.push(`${i.toString(16).padStart(8, '0')} ${hex.padEnd(47, ' ')} |${ascii}|`); - } - return lines.join('\n'); -} - -function renderValue(value: string | null, mode: ValueMode): string { - if (value === null) return '(null)'; - if (mode === 'json') return tryFormatJson(value); - if (mode === 'hex') return toHexView(value); - return value; -} - -function formatTs(ts: string): string { - try { return new Date(ts).toLocaleString(); } catch { return ts; } -} - -// ── Shared: Copy button ─────────────────────────────────────────────────────── - -function CopyBtn({ text, className }: { text: string; className?: string }) { - return ( - - ); -} - -// ── Shared: Section header ──────────────────────────────────────────────────── - -function SectionHead({ title, aside }: { title: string; aside?: React.ReactNode }) { - return ( -
- - {title} - - {aside} -
- ); -} - -// ── Column header with sort + resize ───────────────────────────────────────── - -interface ColHeaderProps { - label: string; - col: SortCol; - align?: 'left' | 'right'; - sortCol: SortCol | null; - sortDir: SortDir; - onSort: (col: SortCol) => void; - onResizeStart?: (e: React.MouseEvent) => void; -} - -function ColHeader({ label, col, align = 'left', sortCol, sortDir, onSort, onResizeStart }: ColHeaderProps) { - const active = sortCol === col; - const Icon = active ? (sortDir === 'asc' ? ArrowUp : ArrowDown) : ArrowUpDown; - - return ( -
- - {onResizeStart && ( -
-
-
- )} -
- ); -} - -// ── JSON syntax highlighter ────────────────────────────────────────────────── - -type JTok = { t: 'key' | 'str' | 'num' | 'bool' | 'null' | 'punct' | 'ws'; v: string }; - -function tokenizeJson(s: string): JTok[] { - const out: JTok[] = []; - let i = 0; - while (i < s.length) { - const ch = s[i]; - if (ch === '"') { - let j = i + 1; - while (j < s.length) { - if (s[j] === '\\') { j += 2; continue; } - if (s[j] === '"') { j++; break; } - j++; - } - const after = s.slice(j).trimStart(); - out.push({ t: after.startsWith(':') ? 'key' : 'str', v: s.slice(i, j) }); - i = j; - } else if (s.slice(i, i + 4) === 'true') { - out.push({ t: 'bool', v: 'true' }); i += 4; - } else if (s.slice(i, i + 5) === 'false') { - out.push({ t: 'bool', v: 'false' }); i += 5; - } else if (s.slice(i, i + 4) === 'null') { - out.push({ t: 'null', v: 'null' }); i += 4; - } else if (ch === '-' || (ch >= '0' && ch <= '9')) { - let j = i; - if (s[j] === '-') j++; - while (j < s.length && s[j] >= '0' && s[j] <= '9') j++; - if (s[j] === '.') { j++; while (j < s.length && s[j] >= '0' && s[j] <= '9') j++; } - if (s[j] === 'e' || s[j] === 'E') { j++; if (s[j] === '+' || s[j] === '-') j++; while (j < s.length && s[j] >= '0' && s[j] <= '9') j++; } - out.push({ t: 'num', v: s.slice(i, j) }); i = j; - } else if ('{[]},:'.includes(ch)) { - out.push({ t: 'punct', v: ch }); i++; - } else { - let j = i + 1; - while (j < s.length && ' \n\r\t'.includes(s[j])) j++; - out.push({ t: 'ws', v: s.slice(i, j) }); i = j; - } - } - return out; -} - -// Dùng chung token với cây JSON của JsonFormatter — cùng khái niệm thì cùng -// màu ở mọi nơi trong app. Xem --json-*/--sql-* trong design-system/tokens.css. -const TOK_CLS: Record = { - key: 'text-[var(--json-key)]', - str: 'text-[var(--sql-string)]', - num: 'text-[var(--sql-number)]', - bool: 'text-[var(--sql-type)]', - null: 'text-[var(--json-null)]', - punct: 'text-fg-mute/70', - ws: '', -}; - -function JsonHighlight({ raw }: { raw: string }) { - const tokens = useMemo(() => { - try { - return tokenizeJson(JSON.stringify(JSON.parse(raw), null, 2)); - } catch { - return null; - } - }, [raw]); - - if (!tokens) { - return ( -
-
{raw}
-

Not valid JSON

-
- ); - } - - return ( -
-      {tokens.map((tok, idx) =>
-        tok.t === 'ws' ? tok.v : {tok.v}
-      )}
-    
- ); -} - -// ── Detail panel ────────────────────────────────────────────────────────────── - -interface DetailPanelProps { - msg: KafkaMessage; - defaultValueMode: ValueMode; - onClose: () => void; -} - -function DetailPanel({ msg, defaultValueMode, onClose }: DetailPanelProps) { - const [valueMode, setValueMode] = useState(defaultValueMode); - const renderedValue = useMemo(() => renderValue(msg.value, valueMode), [msg.value, valueMode]); - const ts = formatTs(msg.timestamp); - const headerEntries = Object.entries(msg.headers); - - const properties = [ - { label: 'Partition', value: String(msg.partition) }, - { label: 'Offset', value: String(msg.offset) }, - { label: 'Timestamp', value: ts }, - { label: 'Key', value: msg.key }, - ] as const; - - return ( -
- {/* Panel title bar */} -
- Message Detail - -
- - {/* Scrollable body */} -
- - {/* ── Properties ── */} -
- -
- {properties.map(({ label, value }) => ( -
- {label} - {value !== null ? ( - <> - {value} - - - ) : ( - null - )} -
- ))} -
-
- - {/* ── Value ── */} -
- -
- {(['text', 'json', 'hex'] as ValueMode[]).map((m) => ( - - ))} -
- {msg.value !== null && } -
- } - /> - {msg.value === null ? ( -

null

- ) : valueMode === 'json' ? ( - - ) : ( -
-              {renderedValue}
-            
- )} -
- - {/* ── Headers ── always shown */} -
- 0 - ? {headerEntries.length} - : undefined - } - /> - {headerEntries.length === 0 ? ( -

No headers

- ) : ( -
- {headerEntries.map(([k, v]) => ( -
-
-
{k}
-
{v}
-
- -
- ))} -
- )} -
- -
-
- ); -} - -// ── Main component ──────────────────────────────────────────────────────────── - -export function MessagesTab({ brokerId, topic, partitions }: MessagesTabProps) { - const sdk = usePluginSdkFor('kafka-explorer'); - const kafkaApi = useKafkaApi(); - const config = usePluginConfig(); - const [defaultValueMode, setDefaultValueMode] = usePluginState(sdk, `${brokerId}:defaultValueMode`, - 'text', { legacyKey: `devtool:kafka:${brokerId}:defaultValueMode` }); - const [partition, setPartition] = useState(partitions[0]?.id ?? 0); - const [mode, setMode] = useState('tail'); - const [sinceMs, setSinceMs] = useState(60 * 60_000); - const [fromOffset, setFromOffset] = useState('0'); - const [toOffset, setToOffset] = useState('100'); - const [limit, setLimit] = useState(50); - const [keyword, setKeyword] = useState(''); - const [useRegex, setUseRegex] = useState(false); - const [messages, setMessages] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - const [fetched, setFetched] = useState(false); - const [selectedMsg, setSelectedMsg] = useState(null); - const [hasMore, setHasMore] = useState(false); - const [loadingMore, setLoadingMore] = useState(false); - - // Column widths (px) — value column is always 1fr. Persisted so a tuned - // layout survives refetch / tab switches / app restart. - const [colWidths, setColWidths] = usePluginState(sdk, 'colWidths', DEFAULT_WIDTHS, { legacyKey: 'devtool:kafka:colWidths' }); - const colWidthsRef = useRef(colWidths); - colWidthsRef.current = colWidths; - - // Detail panel width (px) — also persisted. - const [detailWidth, setDetailWidth] = usePluginState(sdk, 'detailWidth', DETAIL_DEFAULT, { legacyKey: 'devtool:kafka:detailWidth' }); - const detailWidthRef = useRef(detailWidth); - detailWidthRef.current = detailWidth; - - // Monotonic token to drop stale async fetch results (see doFetch/loadMore). - const fetchSeq = useRef(0); - - // Sort - const [sortCol, setSortCol] = useState('offset'); - const [sortDir, setSortDir] = useState('desc'); - - const handleSort = (col: SortCol) => { - if (sortCol === col) { - setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); - } else { - setSortCol(col); - setSortDir('asc'); - } - }; - - // Column resize - const startColResize = (col: keyof ColWidths, e: React.MouseEvent) => { - e.preventDefault(); - const startX = e.clientX; - const startWidth = colWidthsRef.current[col]; - - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - - const onMove = (ev: MouseEvent) => { - const newWidth = Math.max(MIN_WIDTHS[col], startWidth + ev.clientX - startX); - setColWidths((w) => ({ ...w, [col]: newWidth })); - }; - const onUp = () => { - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }; - - // Detail panel resize — dragging left edge: drag left = wider, drag right = narrower - const startDetailResize = (e: React.MouseEvent) => { - e.preventDefault(); - const startX = e.clientX; - const startWidth = detailWidthRef.current; - - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - - const onMove = (ev: MouseEvent) => { - const newWidth = Math.max(DETAIL_MIN, startWidth + startX - ev.clientX); - setDetailWidth(newWidth); - detailWidthRef.current = newWidth; - }; - const onUp = () => { - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }; - - const gridCols = `${colWidths.partition}px ${colWidths.offset}px ${colWidths.key}px 1fr ${colWidths.timestamp}px`; - - const doFetch = async () => { - const id = ++fetchSeq.current; - setLoading(true); - setError(''); - setSelectedMsg(null); - setHasMore(false); - try { - let startOffset: number; - let fetchLimit: number; - let startTimestamp: number | null = null; - if (mode === 'tail') { - startOffset = -1; - fetchLimit = limit; - } else if (mode === 'from') { - startOffset = parseInt(fromOffset, 10) || 0; - fetchLimit = limit; - } else if (mode === 'time') { - startOffset = 0; - fetchLimit = limit; - startTimestamp = Date.now() - sinceMs; - } else { - const from = parseInt(fromOffset, 10) || 0; - const to = parseInt(toOffset, 10) || 0; - startOffset = from; - fetchLimit = Math.min(Math.max(to - from, 1), config.kafka.maxFetchMessages); - } - const msgs = await kafkaApi.fetchMessages(brokerId, topic, partition, startOffset, fetchLimit, startTimestamp); - // Discard if a newer fetch (e.g. partition switch) started meanwhile, so - // a slow response can't overwrite the data the user is now looking at. - if (id !== fetchSeq.current) return; - setMessages(msgs); - setFetched(true); - if (mode !== 'range' && msgs.length >= fetchLimit) { - const minLoaded = msgs.reduce((min, m) => Math.min(min, m.offset), Infinity); - const part = partitions.find((p) => p.id === partition); - setHasMore(!part || minLoaded > part.earliestOffset); - } - } catch (e) { - if (id !== fetchSeq.current) return; - setError(String(e)); - setMessages([]); - } finally { - if (id === fetchSeq.current) setLoading(false); - } - }; - - // Reset the selected partition when the topic changes so the stale value from - // a previous topic (which may have fewer partitions) never causes an immediate - // out-of-range fetch error. TopicView remounts only on mount, not on topic - // switch, so this guard is necessary. - useEffect(() => { - if (partitions.length > 0 && partition >= partitions.length) { - setPartition(partitions[0].id); - } - }, [topic, partitions.length]); // eslint-disable-line react-hooks/exhaustive-deps - - // Auto-load the latest page when the tab opens or the partition changes — - // no manual Fetch click needed for the default tail view. Only fires in - // 'tail' mode; the from/range/time modes need user-entered values, so they - // still wait for an explicit Fetch. `mode` is intentionally not a dependency - // so switching modes doesn't auto-fetch. - useEffect(() => { - if (mode === 'tail') doFetch(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [brokerId, topic, partition]); - - const loadMore = async () => { - if (loadingMore || messages.length === 0) return; - const id = ++fetchSeq.current; - setLoadingMore(true); - try { - const minOffset = messages.reduce((min, m) => Math.min(min, m.offset), Infinity); - const part = partitions.find((p) => p.id === partition); - const earliest = part?.earliestOffset ?? 0; - if (minOffset <= earliest) { setHasMore(false); return; } - const startOffset = Math.max(earliest, minOffset - limit); - const fetchLimit = minOffset - startOffset; - const more = await kafkaApi.fetchMessages(brokerId, topic, partition, startOffset, fetchLimit); - // Bail if a newer fetch/partition switch happened — otherwise we'd append - // this partition's rows onto a now-different message list. - if (id !== fetchSeq.current) return; - if (more.length === 0) { setHasMore(false); return; } - setMessages((prev) => [...prev, ...more]); - const newMin = more.reduce((min, m) => Math.min(min, m.offset), Infinity); - setHasMore(newMin > earliest); - } catch (e) { - if (id !== fetchSeq.current) return; - setError(String(e)); - } finally { - if (id === fetchSeq.current) setLoadingMore(false); - } - }; - - const handleRowClick = (msg: KafkaMessage) => { - const isSame = selectedMsg?.partition === msg.partition && selectedMsg?.offset === msg.offset; - setSelectedMsg(isSame ? null : msg); - }; - - // Invalid regex while in regex mode — surfaced in the filter bar; filter is - // bypassed (show all) so the list doesn't vanish mid-typing. - const regexError = useMemo(() => { - if (!useRegex || !keyword) return false; - try { new RegExp(keyword); return false; } catch { return true; } - }, [useRegex, keyword]); - - const filtered = useMemo(() => { - if (!keyword || regexError) return messages; - if (useRegex) { - const re = new RegExp(keyword, 'i'); - return messages.filter((m) => - [m.key, m.value, ...Object.values(m.headers)].some((v) => v != null && re.test(v)) - ); - } - const kw = keyword.toLowerCase(); - return messages.filter((m) => - [m.key, m.value, ...Object.values(m.headers)].some((v) => v?.toLowerCase().includes(kw)) - ); - }, [messages, keyword, useRegex, regexError]); - - const sorted = useMemo(() => { - if (!sortCol) return filtered; - return [...filtered].sort((a, b) => { - let cmp = 0; - switch (sortCol) { - case 'partition': cmp = a.partition - b.partition; break; - case 'offset': cmp = a.offset - b.offset; break; - case 'key': cmp = (a.key ?? '').localeCompare(b.key ?? ''); break; - case 'value': cmp = (a.value ?? '').localeCompare(b.value ?? ''); break; - case 'timestamp': { - const ta = new Date(a.timestamp).getTime(); - const tb = new Date(b.timestamp).getTime(); - cmp = !isNaN(ta) && !isNaN(tb) ? ta - tb : a.timestamp.localeCompare(b.timestamp); - break; - } - } - return sortDir === 'asc' ? cmp : -cmp; - }); - }, [filtered, sortCol, sortDir]); - - const selectedPartition = partitions.find((p) => p.id === partition); - const sortProps = { sortCol, sortDir, onSort: handleSort }; - - return ( -
- - {/* ── Toolbar ── */} -
-
- Partition - -
- -
- Start from -
- {(['tail', 'from', 'range', 'time'] as FetchMode[]).map((m) => ( - - ))} -
-
- - {mode === 'time' && ( -
- Since - -
- )} - - {mode === 'from' && ( -
- Start offset - setFromOffset(e.target.value)} - placeholder={selectedPartition ? String(selectedPartition.earliestOffset) : '0'} - className="h-ctl w-28 text-xs font-mono" - onKeyDown={(e) => e.key === 'Enter' && doFetch()} - /> -
- )} - - {mode === 'range' && ( -
-
- From - setFromOffset(e.target.value)} - className="h-ctl w-24 text-xs font-mono" - onKeyDown={(e) => e.key === 'Enter' && doFetch()} - /> -
- – -
- To - setToOffset(e.target.value)} - className="h-ctl w-24 text-xs font-mono" - onKeyDown={(e) => e.key === 'Enter' && doFetch()} - /> -
-
- )} - - {mode !== 'range' && ( -
- Limit - -
- )} - -
- Default view -
- {(['text', 'json', 'hex'] as ValueMode[]).map((m) => ( - - ))} -
-
- -
- {fetched && !loading && ( - - {filtered.length.toLocaleString()} - {keyword ? ` / ${messages.length.toLocaleString()}` : ''} - {' '}msg{filtered.length !== 1 ? 's' : ''} - - )} - -
-
- - {/* ── Filter bar ── */} -
- - setKeyword(e.target.value)} - placeholder={useRegex ? 'Filter by regex (key, value, headers)…' : 'Filter by key, value, or headers…'} - className={cn( - 'h-ctl text-xs border-0 shadow-none focus-visible:ring-0 px-0 flex-1', - useRegex && 'font-mono', - regexError && 'text-bad', - )} - disabled={!fetched} - /> - {regexError && invalid regex} - - {keyword && ( - - )} - {fetched && sorted.length > 0 && ( -
- Export - messagesToJson(sorted)} label="JSON" variant="ghost" size="sm" className="h-6 px-1.5 text-[11px]" iconClassName="h-3 w-3" /> - messagesToCsv(sorted)} label="CSV" variant="ghost" size="sm" className="h-6 px-1.5 text-[11px]" iconClassName="h-3 w-3" /> -
- )} -
- - {/* ── Body: message list + detail panel ── */} -
- - {/* Message list */} -
- - {/* Status states */} - {(loading || error || !fetched || (!loading && !error && fetched && filtered.length === 0)) && ( -
- {loading && ( - - )} - {!loading && error && ( - - - {error} - - )} - {!loading && !error && !fetched && ( - Choose a partition and click Fetch - )} - {!loading && !error && fetched && filtered.length === 0 && ( - - {keyword ? 'No messages match the filter' : 'No messages in this range'} - - )} -
- )} - - {/* Column headers */} - {!loading && !error && sorted.length > 0 && ( -
- startColResize('partition', e)} /> - startColResize('offset', e)} /> - startColResize('key', e)} /> - - startColResize('timestamp', e)} /> -
- )} - - {/* Message rows */} - {!loading && !error && sorted.length > 0 && ( -
- {sorted.map((msg) => { - const isSelected = selectedMsg?.partition === msg.partition && selectedMsg?.offset === msg.offset; - const ts = formatTs(msg.timestamp); - return ( - - ); - })} - - {/* Load more older messages */} - {(hasMore || loadingMore) && ( -
- {loadingMore ? ( - - ) : ( - - )} -
- )} -
- )} -
- - {/* Resizable detail panel */} - {selectedMsg && ( -
- {/* Drag handle on left edge */} -
-
-
- - {/* Panel content */} -
- setSelectedMsg(null)} - /> -
-
- )} -
-
- ); -} diff --git a/src/components/tools/kafka/PartitionsTab.tsx b/src/components/tools/kafka/PartitionsTab.tsx deleted file mode 100644 index 232419f1..00000000 --- a/src/components/tools/kafka/PartitionsTab.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type { PartitionInfo } from './types'; - -interface PartitionsTabProps { - partitions: PartitionInfo[]; -} - -export function PartitionsTab({ partitions }: PartitionsTabProps) { - if (partitions.length === 0) { - return
No partition data
; - } - - return ( -
-
- Partition - Earliest Offset - Latest Offset - Messages -
- {partitions.map((p) => { - const count = p.latestOffset >= 0 && p.earliestOffset >= 0 - ? p.latestOffset - p.earliestOffset - : -1; - return ( -
- {p.id} - - {p.earliestOffset >= 0 ? p.earliestOffset.toLocaleString() : '—'} - - - {p.latestOffset >= 0 ? p.latestOffset.toLocaleString() : '—'} - - - {count >= 0 ? count.toLocaleString() : '—'} - -
- ); - })} -
- ); -} diff --git a/src/components/tools/kafka/ProduceTab.tsx b/src/components/tools/kafka/ProduceTab.tsx deleted file mode 100644 index 8a09e03e..00000000 --- a/src/components/tools/kafka/ProduceTab.tsx +++ /dev/null @@ -1,372 +0,0 @@ -import { useState, useEffect, useRef } from 'react'; -import { usePluginSdkFor, usePluginState } from '@/platform'; -import { Plus, X, CheckCircle, AlertCircle, History, RotateCcw } from 'lucide-react'; -import { Spinner } from '@/components/ui/spinner'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Segmented } from '@/components/ui/segmented'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { type PartitionInfo, type BatchRecord } from './types'; -import { useKafkaApi } from './api_sdk'; -import { JsonEditor, TextEditor } from '@/design-system'; -import { produceDraft, type ProduceHeader as Header } from './produceDraft'; -import { kafkaInputHistory } from './kafkaInputHistoryStore'; - -interface RecentSend { key: string; value: string; headers: Header[]; at: number } - -// Kafka's default partitioner: murmur2(keyBytes) → toPositive → % numPartitions. -// Lets us preview which partition a keyed message will land in (auto mode). -function murmur2(data: Uint8Array): number { - const length = data.length; - const m = 0x5bd1e995; - const r = 24; - let h = (0x9747b28c ^ length) | 0; - const len4 = length & ~3; - for (let i = 0; i < len4; i += 4) { - let k = (data[i] & 0xff) | ((data[i + 1] & 0xff) << 8) | ((data[i + 2] & 0xff) << 16) | ((data[i + 3] & 0xff) << 24); - k = Math.imul(k, m); k ^= k >>> r; k = Math.imul(k, m); - h = Math.imul(h, m); h ^= k; - } - const left = length & 3; - if (left >= 3) h ^= (data[len4 + 2] & 0xff) << 16; - if (left >= 2) h ^= (data[len4 + 1] & 0xff) << 8; - if (left >= 1) { h ^= (data[len4] & 0xff); h = Math.imul(h, m); } - h ^= h >>> 13; h = Math.imul(h, m); h ^= h >>> 15; - return h | 0; -} -function partitionForKey(key: string, numPartitions: number): number { - if (numPartitions <= 0) return 0; - return (murmur2(new TextEncoder().encode(key)) & 0x7fffffff) % numPartitions; -} - -interface ProduceTabProps { - brokerId: string; - topic: string; - partitions: PartitionInfo[]; -} - -export function ProduceTab({ brokerId, topic, partitions }: ProduceTabProps) { - const sdk = usePluginSdkFor('kafka-explorer'); - const kafkaApi = useKafkaApi(); - // Seeded from the in-memory draft so the form survives tab/tool/topic switches. - const [partitionMode, setPartitionMode] = useState<'auto' | 'manual'>(() => produceDraft.partitionMode); - const [partition, setPartition] = useState(() => produceDraft.partition); - const [key, setKey] = useState(() => produceDraft.key); - const [headers, setHeaders] = useState(() => produceDraft.headers); - const [value, setValue] = useState(() => produceDraft.value); - const [valueFormat, setValueFormat] = useState<'json' | 'plain'>(() => produceDraft.valueFormat); - const [batch, setBatch] = useState(() => produceDraft.batch); - const [sending, setSending] = useState(false); - const [result, setResult] = useState<{ partition: number; offset: number } | null>(null); - const [batchResult, setBatchResult] = useState(null); - const [error, setError] = useState(''); - const clearTimer = useRef | null>(null); - - // Send-again history (per broker+topic), newest first, capped at 10. - const [recent, setRecent] = usePluginState(sdk, `${brokerId}:${topic}:recentSends`, [], { legacyKey: `devtool:kafka:${brokerId}:${topic}:recentSends` }); - - // Preview which partition a keyed message routes to in auto mode. - const previewPartition = (!batch && partitionMode === 'auto' && key.trim() && partitions.length > 0) - ? partitionForKey(key, partitions.length) - : null; - - // Clear status after 5 s - useEffect(() => { - if (result || error || batchResult !== null) { - clearTimer.current = setTimeout(() => { setResult(null); setError(''); setBatchResult(null); }, 5000); - } - return () => { if (clearTimer.current) clearTimeout(clearTimer.current); }; - }, [result, error, batchResult]); - - // Mirror the form into the in-memory draft on every render so it survives remounts. - useEffect(() => { - Object.assign(produceDraft, { key, value, headers, batch, partitionMode, partition, valueFormat }); - }); - - // Keep a manual partition selection within range when the topic changes. - useEffect(() => { if (partition >= partitions.length) setPartition(0); }, [partitions.length]); // eslint-disable-line react-hooks/exhaustive-deps - - /** Pretty-print the value as JSON in place; no-op if it isn't valid JSON. */ - const formatValue = () => { - try { setValue(JSON.stringify(JSON.parse(value), null, 2)); } catch { /* leave as typed */ } - }; - - const rememberSend = () => { - setRecent((prev) => [ - { key, value, headers: headers.filter((h) => h.key.trim()), at: Date.now() }, - ...prev.filter((r) => !(r.key === key && r.value === value)), - ].slice(0, 10)); - }; - - const loadRecent = (r: RecentSend) => { - setKey(r.key); - setValue(r.value); - setHeaders(r.headers); - setBatch(false); - }; - - const addHeader = () => setHeaders((h) => [...h, { key: '', value: '' }]); - const removeHeader = (i: number) => setHeaders((h) => h.filter((_, idx) => idx !== i)); - const updateHeader = (i: number, field: 'key' | 'value', val: string) => - setHeaders((h) => h.map((hdr, idx) => idx === i ? { ...hdr, [field]: val } : hdr)); - - const handleSend = async () => { - if (!value.trim()) { setError('Value is required'); return; } - setSending(true); - setResult(null); - setBatchResult(null); - setError(''); - const targetPartition = partitionMode === 'manual' ? partition : null; - try { - if (batch) { - // Value must be a JSON array of records: ["raw", {value, key?, headers?}, …] - let parsed: unknown; - try { parsed = JSON.parse(value); } catch { throw new Error('Batch value must be valid JSON'); } - if (!Array.isArray(parsed)) throw new Error('Batch value must be a JSON array'); - const records: BatchRecord[] = parsed.map((item, i) => { - if (typeof item === 'string') return { key: null, value: item }; - if (item && typeof item === 'object' && 'value' in item) { - const o = item as Record; - return { - key: o.key == null ? null : String(o.key), - value: typeof o.value === 'string' ? o.value : JSON.stringify(o.value), - headers: (o.headers && typeof o.headers === 'object') - ? Object.fromEntries(Object.entries(o.headers as Record).map(([k, v]) => [k, String(v)])) - : undefined, - }; - } - throw new Error(`Item ${i} must be a string or an object with a "value"`); - }); - const offsets = await kafkaApi.produceBatch(brokerId, topic, targetPartition, records); - setBatchResult(offsets.length); - kafkaInputHistory.add(brokerId, 'topic', topic); - } else { - const headersMap = Object.fromEntries( - headers.filter((h) => h.key.trim()).map((h) => [h.key.trim(), h.value]) - ); - const r = await kafkaApi.produce(brokerId, topic, targetPartition, key.trim() || null, value, headersMap); - setResult(r); - rememberSend(); - kafkaInputHistory.add(brokerId, 'topic', topic); - if (key.trim()) kafkaInputHistory.add(brokerId, 'key', key); - } - } catch (e) { - setError(String(e)); - } finally { - setSending(false); - } - }; - - return ( -
-
- {/* Topic label + Single/Batch */} -
-
- Sending to {topic} -
- setBatch(v === 'batch')} - size="sm" - options={[{ value: 'single', label: 'Single' }, { value: 'batch', label: 'Batch' }]} - aria-label="Produce mode" - /> -
- - {/* Recent sends — quick "send again" */} - {!batch && recent.length > 0 && ( -
-
- Recent -
-
- {recent.slice(0, 6).map((r, i) => ( - - ))} -
-
- )} - - {/* Partition */} -
-
- -
- - -
-
- {partitionMode === 'manual' && ( -
- -
- )} - {previewPartition !== null && ( - - key routes to P{previewPartition} - - )} -
- - {/* Key */} - {!batch && ( -
- - setKey(e.target.value)} - placeholder="message key" - className="mt-1 h-ctl text-xs font-mono" - /> -
- )} - - {/* Headers */} - {!batch && ( -
-
- - -
- {headers.length === 0 && ( -
No headers
- )} - {headers.map((h, i) => ( -
- updateHeader(i, 'key', e.target.value)} - placeholder="key" - className="h-ctl text-xs font-mono flex-1" - /> - updateHeader(i, 'value', e.target.value)} - placeholder="value" - className="h-ctl text-xs font-mono flex-[2]" - /> - -
- ))} -
- )} - - {/* Value */} -
-
- -
- {valueFormat === 'json' && ( - - )} - - value={valueFormat} - onValueChange={setValueFormat} - size="sm" - aria-label="Value format" - options={[{ value: 'json', label: 'JSON' }, { value: 'plain', label: 'Plain' }]} - /> -
-
- {batch && ( -

- An array of strings or {'{ value, key?, headers? }'} objects — produced to one partition. -

- )} - {valueFormat === 'json' ? ( - - ) : ( - - )} -
- - {/* Send + status */} -
- - - {result && ( -
- - partition {result.partition} @ offset {result.offset.toLocaleString()} -
- )} - - {batchResult !== null && ( -
- - produced {batchResult.toLocaleString()} message{batchResult !== 1 ? 's' : ''} -
- )} - - {error && ( -
- - {error} -
- )} -
-
-
- ); -} diff --git a/src/components/tools/kafka/ProduceView.tsx b/src/components/tools/kafka/ProduceView.tsx deleted file mode 100644 index 3819d1ec..00000000 --- a/src/components/tools/kafka/ProduceView.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { Send, Check, RefreshCw } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Callout } from '@/components/ui/callout'; -import { LoadingRow } from '@/components/ui/spinner'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { cn } from '@/lib/utils'; -import { type TopicDetails } from './types'; -import { useKafkaApi } from './api_sdk'; -import { ProduceTab } from './ProduceTab'; -import { useKafkaRecentMatches } from './kafkaInputHistoryStore'; -import { RecentSuggestions } from './RecentSuggestions'; -import { produceDraft } from './produceDraft'; -import type { TopicPrefill } from './useKafkaState'; - -interface ProduceViewProps { - brokerId: string; - refreshKey: number; - onRefresh: () => void; - prefill?: TopicPrefill | null; -} - -/** - * The single produce surface for the tool. Pick (or type) a topic, then the - * produce form for it. Entry points (a topic's Produce button) open this panel - * pre-filled via `prefill` — mirroring the RabbitMQ tool's Send/Request panel. - */ -export function ProduceView({ brokerId, refreshKey, onRefresh, prefill }: ProduceViewProps) { - const kafkaApi = useKafkaApi(); - // Seed the topic from the in-memory draft so it survives tab/tool switches. - const [topic, setTopic] = useState(() => produceDraft.topic); - const [topics, setTopics] = useState([]); - const [details, setDetails] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - let alive = true; - kafkaApi.listTopics(brokerId) - .then((ts) => { if (alive) setTopics(ts.map((t) => t.name).sort((a, b) => a.localeCompare(b))); }) - .catch(() => { if (alive) setTopics([]); }); - return () => { alive = false; }; - }, [brokerId, refreshKey]); - - useEffect(() => { - if (prefill?.topic) setTopic(prefill.topic); - }, [prefill?.token]); - - // Mirror the selected topic into the draft so returning to Produce restores it. - useEffect(() => { produceDraft.topic = topic; }); - - // Load partition metadata for the chosen topic (the produce form needs it). - useEffect(() => { - const t = topic.trim(); - if (!t) { setDetails(null); setError(null); return; } - let alive = true; - setLoading(true); - setError(null); - kafkaApi.topicDetails(brokerId, t) - .then((d) => { if (alive) setDetails(d); }) - .catch((e) => { if (alive) { setDetails(null); setError(String(e instanceof Error ? e.message : e)); } }) - .finally(() => { if (alive) setLoading(false); }); - return () => { alive = false; }; - }, [brokerId, topic, refreshKey]); - - return ( -
-
-
- -
-

Produce

-

Send a message to a topic

-
-
- -
- -
-
-
- - - {loading && } - {error && {error}} -
- - {details - ? - : !loading &&

Pick or type a topic above to produce a message.

} -
-
-
- ); -} - -/** Searchable topic picker that also accepts a typed (custom) topic name. */ -function TopicCombobox({ brokerId, value, topics, onChange }: { - brokerId: string; value: string; topics: string[]; onChange: (v: string) => void; -}) { - const [open, setOpen] = useState(false); - const blurTimer = useRef | null>(null); - const recent = useKafkaRecentMatches(brokerId, 'topic', value); - const q = value.trim().toLowerCase(); - const matches = topics.filter((t) => t.toLowerCase().includes(q) && !recent.includes(t)).slice(0, 50); - - const pick = (v: string) => { - if (blurTimer.current) clearTimeout(blurTimer.current); - onChange(v); - setOpen(false); - }; - - return ( -
- { onChange(e.target.value); setOpen(true); }} - onFocus={() => setOpen(true)} - onBlur={() => { blurTimer.current = setTimeout(() => setOpen(false), 120); }} - onKeyDown={(e) => { if (e.key === 'Escape') setOpen(false); }} - placeholder="topic name — type to search" - className="font-mono text-sm h-ctl" - /> - {open && (recent.length > 0 || matches.length > 0) && ( -
- - {matches.map((t) => ( - - ))} -
- )} -
- ); -} diff --git a/src/components/tools/kafka/PropertiesTab.tsx b/src/components/tools/kafka/PropertiesTab.tsx deleted file mode 100644 index 116736c1..00000000 --- a/src/components/tools/kafka/PropertiesTab.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { SectionLabel } from '@/components/ui/section-label'; -import type { TopicDetails } from './types'; - -interface PropertiesTabProps { - details: TopicDetails; -} - -function Row({ label, value }: { label: string; value: string }) { - return ( - <> - {label} - {value} - - ); -} - -export function PropertiesTab({ details }: PropertiesTabProps) { - const totalMessages = details.partitions.reduce((sum, p) => { - if (p.latestOffset >= 0 && p.earliestOffset >= 0) { - return sum + (p.latestOffset - p.earliestOffset); - } - return sum; - }, 0); - - return ( -
- {/* General */} -
- General -
- - - -
-
- - {/* Messages */} -
- Messages -
- Total messages - {totalMessages.toLocaleString()} -
-

- Computed from partition offsets (latest − earliest). May differ from actual message count if compaction or retention has run. -

-
- - {/* Partitions detail */} -
- Partition Offsets -
-
- ID - Earliest - Latest - Messages -
- {details.partitions.map((p) => { - const count = p.latestOffset >= 0 && p.earliestOffset >= 0 - ? p.latestOffset - p.earliestOffset - : -1; - return ( -
- {p.id} - {p.earliestOffset >= 0 ? p.earliestOffset.toLocaleString() : '—'} - {p.latestOffset >= 0 ? p.latestOffset.toLocaleString() : '—'} - {count >= 0 ? count.toLocaleString() : '—'} -
- ); - })} -
-
-
- ); -} diff --git a/src/components/tools/kafka/RecentSuggestions.tsx b/src/components/tools/kafka/RecentSuggestions.tsx deleted file mode 100644 index 75f340ca..00000000 --- a/src/components/tools/kafka/RecentSuggestions.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { Clock, X } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { kafkaInputHistory, type KafkaHistoryField } from './kafkaInputHistoryStore'; - -/** - * The "Recent" group at the top of a topic/key combobox: each remembered value - * with a clock marker and a hover × to forget it. Renders nothing when there's - * no history. Callers pass the already-filtered `items` (via - * `useKafkaRecentMatches`) and dedupe their own suggestions against them. - */ -export function RecentSuggestions({ items, brokerId, field, value, onPick }: { - items: string[]; - brokerId: string; - field: KafkaHistoryField; - value: string; - onPick: (v: string) => void; -}) { - if (items.length === 0) return null; - return ( -
-
Recent
- {items.map((v) => ( -
{ e.preventDefault(); onPick(v); }} - className={cn('group/recent w-full flex items-center gap-2 px-2.5 py-1.5 text-left cursor-pointer hover:bg-bg-2/60', value === v && 'text-acc')} - > - - {v} - { e.preventDefault(); e.stopPropagation(); kafkaInputHistory.remove(brokerId, field, v); }} - className="shrink-0 text-fg-mute/50 hover:text-bad opacity-0 group-hover/recent:opacity-100 transition-opacity" - > - - -
- ))} -
- ); -} diff --git a/src/components/tools/kafka/TopicListView.tsx b/src/components/tools/kafka/TopicListView.tsx deleted file mode 100644 index 45269201..00000000 --- a/src/components/tools/kafka/TopicListView.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { useEffect, useState } from 'react'; -import { RefreshCw, Plus, List } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Field } from '@/components/ui/tool-section'; -import { - Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, -} from '@/components/ui/dialog'; -import { ViewHeader } from '@/components/ui/view-header'; -import { SearchInput } from '@/components/ui/search-input'; -import { Callout } from '@/components/ui/callout'; -import { LoadingRow } from '@/components/ui/spinner'; -import { DataTable, Thead, Tbody, Tr, Th, Td, DataTableStatus } from '@/components/ui/data-table'; -import { type TopicSummary } from './types'; -import { useKafkaApi } from './api_sdk'; - -interface TopicListViewProps { - brokerId: string; - refreshKey: number; - onRefresh: () => void; - onSelectTopic: (name: string) => void; -} - -export function TopicListView({ brokerId, refreshKey, onRefresh, onSelectTopic }: TopicListViewProps) { - const kafkaApi = useKafkaApi(); - const [filter, setFilter] = useState(''); - const [createOpen, setCreateOpen] = useState(false); - const [topics, setTopics] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const load = () => { - setLoading(true); - setError(null); - kafkaApi.listTopics(brokerId) - .then((t) => setTopics(t.sort((a, b) => a.name.localeCompare(b.name)))) - .catch((e) => { setTopics([]); setError(String(e instanceof Error ? e.message : e)); }) - .finally(() => setLoading(false)); - }; - useEffect(() => { load(); }, [brokerId, refreshKey]); // eslint-disable-line - - const f = filter.trim().toLowerCase(); - const rows = (topics ?? []).filter((t) => t.name.toLowerCase().includes(f)); - - return ( -
- - - - - )} - /> - -
- -
- -
- {loading && !topics && } - {error && {error}} - {topics && !error && ( - rows.length === 0 - ?

{f ? 'No matching topics.' : 'No topics yet.'}

- : ( -
- - - - Name - Partitions - Replication - - - - {rows.map((t) => ( - onSelectTopic(t.name)}> - {t.name} - {t.partitionCount} - {t.replicationFactor} - - ))} - - - {/* Tiêu đề trang ghi TỔNG số, còn bảng thì đang LỌC — hai con số khác - nhau và trước đây không chỗ nào nói ra. Dòng này nói cả hai. */} - -
- ) - )} -
- - { load(); onSelectTopic(name); }} - /> -
- ); -} - -function CreateTopicDialog({ open, onOpenChange, brokerId, onCreated }: { - open: boolean; onOpenChange: (o: boolean) => void; brokerId: string; onCreated: (name: string) => void; -}) { - const kafkaApi = useKafkaApi(); - const [name, setName] = useState(''); - const [partitions, setPartitions] = useState('1'); - const [rf, setRf] = useState('1'); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - const create = async () => { - if (!name.trim()) { setError('Topic name is required'); return; } - const p = parseInt(partitions, 10); - const r = parseInt(rf, 10); - if (!(p >= 1)) { setError('Partitions must be ≥ 1'); return; } - if (!(r >= 1)) { setError('Replication factor must be ≥ 1'); return; } - setBusy(true); - setError(null); - try { - await kafkaApi.createTopic(brokerId, name.trim(), p, r); - onCreated(name.trim()); - onOpenChange(false); - setName(''); - } catch (e) { - setError(String(e instanceof Error ? e.message : e)); - } finally { - setBusy(false); - } - }; - - return ( - { if (!busy) { onOpenChange(o); setError(null); } }}> - - - New topic - Create a topic on this cluster. - -
- - { setName(e.target.value); setError(null); }} - onKeyDown={(e) => { if (e.key === 'Enter') create(); }} - placeholder="my.topic" autoFocus className="font-mono text-sm" - /> - -
- - setPartitions(e.target.value)} className="h-ctl" /> - - - setRf(e.target.value)} className="h-ctl" /> - -
- {error && {error}} -
- - - - -
-
- ); -} diff --git a/src/components/tools/kafka/TopicView.tsx b/src/components/tools/kafka/TopicView.tsx deleted file mode 100644 index 9428bd0a..00000000 --- a/src/components/tools/kafka/TopicView.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { useEffect, useState } from 'react'; -import { AlertCircle, RefreshCw, ChevronRight, Server, Radio, Send, ArrowLeft } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { LoadingRow, Spinner } from '@/components/ui/spinner'; -import { cn } from '@/lib/utils'; -import { type TopicDetails } from './types'; -import { useKafkaApi } from './api_sdk'; -import { PropertiesTab } from './PropertiesTab'; -import { MessagesTab } from './MessagesTab'; -import { ConfigTab } from './ConfigTab'; -import { ConsumersTab } from './ConsumersTab'; -import type { KafkaTab } from './useKafkaState'; - -const TABS: { id: KafkaTab; label: string }[] = [ - { id: 'data', label: 'Messages' }, - { id: 'properties', label: 'Properties' }, - { id: 'config', label: 'Config' }, - { id: 'consumers', label: 'Consumers' }, -]; - -interface TopicViewProps { - brokerId: string; - brokerName?: string; - topic: string; - refreshKey: number; - selectedTab: KafkaTab; - onSelectTab: (tab: KafkaTab) => void; - onRefresh: () => void; - onSelectGroup: (groupId: string) => void; - onConsume: () => void; - onProduce: () => void; - onBackToTopics?: () => void; -} - -export function TopicView({ - brokerId, - brokerName, - topic, - refreshKey, - selectedTab, - onSelectTab, - onRefresh, - onSelectGroup, - onConsume, - onProduce, - onBackToTopics, -}: TopicViewProps) { - const kafkaApi = useKafkaApi(); - // Tolerate a stale persisted tab (e.g. the removed 'produce'). - const activeTab: KafkaTab = TABS.some((t) => t.id === selectedTab) ? selectedTab : 'data'; - const [data, setData] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); - - useEffect(() => { - if (!brokerId || !topic) return; - setLoading(true); - setError(''); - kafkaApi.topicDetails(brokerId, topic) - .then(setData) - .catch((e) => setError(String(e))) - .finally(() => setLoading(false)); - }, [brokerId, topic, refreshKey]); - - return ( -
- {/* Header — breadcrumb: Broker › Topic › Tab */} -
-
- - {brokerName && ( - <> - - - {brokerName} - - - - )} - - - {TABS.find((t) => t.id === activeTab)?.label} - {data && ( - - {data.partitions.length}p · RF {data.replicationFactor} - - )} - {loading && } -
-
- {/* Produce / Consume open the global panels pre-targeted at this topic. */} - - - -
-
- - {/* Error */} - {!loading && error && ( -
- - {error} -
- )} - - {/* Tab bar */} -
- {TABS.map((tab) => ( - - ))} -
- - {/* Tab content */} -
- {loading && !data ? ( - - ) : data ? ( - <> - {activeTab === 'properties' && ( -
- -
- )} - {activeTab === 'data' && ( - - )} - {activeTab === 'config' && ( - - )} - {activeTab === 'consumers' && ( -
- -
- )} - - ) : null} -
-
- ); -} diff --git a/src/components/tools/kafka/api_sdk.ts b/src/components/tools/kafka/api_sdk.ts deleted file mode 100644 index caffb8d5..00000000 --- a/src/components/tools/kafka/api_sdk.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useMemo } from 'react'; -import { usePluginSdkFor } from '@/platform'; -import { createKafkaApi, type KafkaApi } from './types'; - -/** - * Lớp lệnh gắn với SDK của plugin. - * - * Không dùng React context: mọi chỗ gọi đều đã nằm trong một component hoặc một - * hook, nên một `useMemo` tại chỗ là đủ và rẻ hơn một provider — đồng thời tránh - * việc các test đang render component con đứng lẻ phải dựng thêm provider chỉ để - * chạy được. - */ -export function useKafkaApi(): KafkaApi { - const sdk = usePluginSdkFor('kafka-explorer'); - return useMemo(() => createKafkaApi(sdk), [sdk]); -} diff --git a/src/components/tools/kafka/kafkaConsumerStore.ts b/src/components/tools/kafka/kafkaConsumerStore.ts deleted file mode 100644 index ee56d136..00000000 --- a/src/components/tools/kafka/kafkaConsumerStore.ts +++ /dev/null @@ -1,182 +0,0 @@ -// Realtime Kafka consumer store. -// -// Anonymous consumers must keep running while you move between views inside the -// Kafka tool, so their state lives in this module-level store (not a component -// that unmounts on view switch). Components read it via `useKafkaConsumers` / -// `useTopicConsumer` (useSyncExternalStore). The tool stops everything on unmount -// so nothing leaks when you leave. Deliveries are batched into one UI update per -// window so a high-throughput topic can't trigger a re-render per message. - -import { useSyncExternalStore } from 'react'; -import { getPluginSdk } from '@/platform'; -import { createKafkaApi, type KafkaConsumedMessage, type ConsumeFrom } from './types'; - -const MAX_MESSAGES = 2000; -const FLUSH_MS = 120; - -export interface KafkaConsumerSession { - /** Backend consumer id ('' until consumeStart resolves). */ - id: string; - brokerId: string; - topic: string; - from: ConsumeFrom; - /** Most recent records, newest first, capped at MAX_MESSAGES. */ - messages: KafkaConsumedMessage[]; - /** Total received since start (or last Clear) — not capped. */ - received: number; - /** When paused, the view is frozen; new records keep buffering and apply on resume. */ - paused: boolean; - /** Count of records buffered while paused (waiting to be shown). */ - bufferedWhilePaused: number; - startedAt: number; - starting: boolean; -} - -type Listener = () => void; - -const sessions = new Map(); -const listeners = new Set(); -let snapshot: KafkaConsumerSession[] = []; - -const pending = new Map(); -const timers = new Map>(); - -const key = (brokerId: string, topic: string) => `${brokerId} ${topic}`; - -function emit() { - snapshot = Array.from(sessions.values()); - listeners.forEach((l) => l()); -} - -function enqueue(k: string, msg: KafkaConsumedMessage) { - let buf = pending.get(k); - if (!buf) { buf = []; pending.set(k, buf); } - buf.push(msg); - if (!timers.has(k)) timers.set(k, setTimeout(() => flush(k), FLUSH_MS)); -} - -function flush(k: string) { - timers.delete(k); - const buf = pending.get(k); - if (!buf || buf.length === 0) return; - const s = sessions.get(k); - if (!s) return; - if (s.paused) { - // Frozen view: keep buffering (don't merge or clear), just surface the count. - s.bufferedWhilePaused = buf.length; - emit(); - return; - } - pending.set(k, []); - s.received += buf.length; - s.bufferedWhilePaused = 0; - s.messages = buf.reverse().concat(s.messages); - if (s.messages.length > MAX_MESSAGES) s.messages.length = MAX_MESSAGES; - emit(); -} - -function discardPending(k: string) { - const t = timers.get(k); - if (t) clearTimeout(t); - timers.delete(k); - pending.delete(k); -} - -function subscribe(l: Listener) { - listeners.add(l); - return () => listeners.delete(l); -} - -/** - * Store này sống ở phạm vi module CHÍNH VÌ consumer phải chạy tiếp khi người - * dùng chuyển sang tool khác — nên nó không gọi hook được. `getPluginSdk` là - * lối lấy SDK ngoài React dành đúng cho trường hợp này: quyền và allowlist vẫn - * được kiểm y như khi gọi từ trong component. - */ -const sdk = getPluginSdk('kafka-explorer'); -const kafkaApi = createKafkaApi(sdk); - -export const kafkaConsumerStore = { - async start(brokerId: string, topic: string, from: ConsumeFrom): Promise { - const k = key(brokerId, topic); - if (sessions.has(k)) return; // already running - - const session: KafkaConsumerSession = { - id: '', brokerId, topic, from, messages: [], received: 0, paused: false, bufferedWhilePaused: 0, startedAt: Date.now(), starting: true, - }; - sessions.set(k, session); - emit(); - - const channel = await sdk.native.channel( - (msg) => { if (sessions.has(k)) enqueue(k, msg); }, - 'kafka-consume', - ); - - try { - const id = await kafkaApi.consumeStart({ configId: brokerId, topic, from }, channel); - const s = sessions.get(k); - if (!s) { kafkaApi.consumeStop(id).catch(() => {}); return; } // stopped mid-start - s.id = id; - s.starting = false; - emit(); - } catch (e) { - sessions.delete(k); - discardPending(k); // clear any messages that arrived during the failed start - emit(); - throw e; - } - }, - - async stop(brokerId: string, topic: string): Promise { - const k = key(brokerId, topic); - const s = sessions.get(k); - if (!s) return; - sessions.delete(k); - discardPending(k); - emit(); - if (s.id) { try { await kafkaApi.consumeStop(s.id); } catch { /* ignore */ } } - }, - - clear(brokerId: string, topic: string) { - const k = key(brokerId, topic); - const s = sessions.get(k); - if (s) { s.messages = []; s.received = 0; s.bufferedWhilePaused = 0; discardPending(k); emit(); } - }, - - /** Freeze the view (keep buffering) or resume and apply what arrived while paused. */ - setPaused(brokerId: string, topic: string, paused: boolean) { - const k = key(brokerId, topic); - const s = sessions.get(k); - if (!s || s.paused === paused) return; - s.paused = paused; - if (!paused) { - s.bufferedWhilePaused = 0; - flush(k); // apply everything buffered while paused - } - emit(); - }, - - /** Stop every consumer for one broker (used on Disconnect). */ - async stopForBroker(brokerId: string): Promise { - const all = Array.from(sessions.values()).filter((s) => s.brokerId === brokerId); - for (const s of all) { sessions.delete(key(s.brokerId, s.topic)); discardPending(key(s.brokerId, s.topic)); } - emit(); - await Promise.all(all.map((s) => (s.id ? kafkaApi.consumeStop(s.id).catch(() => {}) : Promise.resolve()))); - }, - - async stopAll(): Promise { - const all = Array.from(sessions.values()); - sessions.clear(); - for (const s of all) discardPending(key(s.brokerId, s.topic)); - emit(); - await Promise.all(all.map((s) => (s.id ? kafkaApi.consumeStop(s.id).catch(() => {}) : Promise.resolve()))); - }, -}; - -export function useKafkaConsumers(): KafkaConsumerSession[] { - return useSyncExternalStore(subscribe, () => snapshot, () => snapshot); -} - -export function useTopicConsumer(brokerId: string, topic: string): KafkaConsumerSession | undefined { - return useKafkaConsumers().find((s) => s.brokerId === brokerId && s.topic === topic); -} diff --git a/src/components/tools/kafka/kafkaInputHistoryStore.ts b/src/components/tools/kafka/kafkaInputHistoryStore.ts deleted file mode 100644 index 2d87d18a..00000000 --- a/src/components/tools/kafka/kafkaInputHistoryStore.ts +++ /dev/null @@ -1,93 +0,0 @@ -// Recently-used input values for the Kafka tool (produce/consume comboboxes). -// -// Remembers the topics (and produce keys) you've actually used, per broker, so -// the comboboxes can suggest them again — most-recent first. Shared via -// `useKafkaRecentMatches` (useSyncExternalStore) and persisted to localStorage, -// mirroring the RabbitMQ tool's inputHistoryStore. - -import { useSyncExternalStore } from 'react'; -import { getPluginSdk, migrateLegacyKey } from '@/platform'; - -// Store ở phạm vi module (lịch sử nhập dùng chung giữa các view của tool), nên -// SDK lấy qua `getPluginSdk` chứ không hook. `migrateLegacyKey` chạy một lần lúc -// nạp module: khoá cũ không mang namespace plugin, bỏ qua nó là mất lịch sử của -// người dùng. -const STORAGE_KEY = 'inputHistory'; -const sdk = getPluginSdk('kafka-explorer'); -migrateLegacyKey(sdk, STORAGE_KEY, 'devtool:kafka:inputHistory'); -const MAX = 25; // per field, per broker - -export type KafkaHistoryField = 'topic' | 'key'; - -type BrokerHistory = Partial>; -type Store = Record; - -const listeners = new Set<() => void>(); -let store: Store = load(); - -function load(): Store { - try { - const raw = sdk.storage.get(STORAGE_KEY); - return raw ? (JSON.parse(raw) as Store) : {}; - } catch { - return {}; - } -} - -function persist() { - try { sdk.storage.set(STORAGE_KEY, JSON.stringify(store)); } catch { /* ignore */ } -} - -function emit() { - persist(); - listeners.forEach((l) => l()); -} - -const EMPTY: string[] = []; - -function list(brokerId: string, field: KafkaHistoryField): string[] { - return store[brokerId]?.[field] ?? EMPTY; -} - -export const kafkaInputHistory = { - get: list, - - /** Record a freshly-used value, moving it to the front (deduped, capped). */ - add(brokerId: string, field: KafkaHistoryField, value: string) { - const v = value.trim(); - if (!v) return; - const cur = list(brokerId, field); - if (cur[0] === v) return; - const next = [v, ...cur.filter((x) => x !== v)].slice(0, MAX); - store = { ...store, [brokerId]: { ...store[brokerId], [field]: next } }; - emit(); - }, - - /** Forget one remembered value (the × in the dropdown). */ - remove(brokerId: string, field: KafkaHistoryField, value: string) { - const cur = list(brokerId, field); - if (!cur.includes(value)) return; - store = { ...store, [brokerId]: { ...store[brokerId], [field]: cur.filter((x) => x !== value) } }; - emit(); - }, -}; - -function subscribe(l: () => void) { - listeners.add(l); - return () => listeners.delete(l); -} - -function useKafkaInputHistory(brokerId: string, field: KafkaHistoryField): string[] { - return useSyncExternalStore( - subscribe, - () => store[brokerId]?.[field] ?? EMPTY, - () => store[brokerId]?.[field] ?? EMPTY, - ); -} - -/** History entries matching `query`, most-recent first, capped — for dropdowns. */ -export function useKafkaRecentMatches(brokerId: string, field: KafkaHistoryField, query: string, limit = 8): string[] { - const recent = useKafkaInputHistory(brokerId, field); - const q = query.trim().toLowerCase(); - return recent.filter((v) => v.toLowerCase().includes(q)).slice(0, limit); -} diff --git a/src/components/tools/kafka/mcpBridge.test.ts b/src/components/tools/kafka/mcpBridge.test.ts deleted file mode 100644 index fe1106e4..00000000 --- a/src/components/tools/kafka/mcpBridge.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, waitFor } from '@testing-library/react'; -import type { KafkaState } from './useKafkaState'; -import type { BrokerConfig } from './types'; - -/** - * `isTauri` is computed once at module load from `'__TAURI_INTERNALS__' in - * window`, so — same gotcha as apiclient/mcpBridge.test.ts — it must be set - * BEFORE importing `mcpBridge.ts`, with `vi.resetModules()` forcing a fresh - * load, or the bridge's effect no-ops in plain jsdom. - */ - -const listenMock = vi.fn(); -const invokeMock = vi.fn(); -const unlisten = vi.fn(); -const listConfigsMock = vi.fn(); -const saveConfigMock = vi.fn(); -const deleteConfigMock = vi.fn(); -const testConnectionMock = vi.fn(); -const stopForBrokerMock = vi.fn(); - -vi.mock('@tauri-apps/api/event', () => ({ listen: (...args: unknown[]) => listenMock(...args) })); -vi.mock('@tauri-apps/api/core', () => ({ invoke: (...args: unknown[]) => invokeMock(...args) })); -// Lớp lệnh giờ đến từ `useKafkaApi()` (dựng trên SDK của plugin), nên mock đặt ở -// đó thay vì ở `./types` — test không phải biết lớp lệnh được dựng bằng cách -// nào, chỉ cần biết bridge gọi những hàm nào. -vi.mock('./api_sdk', () => ({ - useKafkaApi: () => ({ - listConfigs: (...a: unknown[]) => listConfigsMock(...a), - saveConfig: (...a: unknown[]) => saveConfigMock(...a), - deleteConfig: (...a: unknown[]) => deleteConfigMock(...a), - testConnection: (...a: unknown[]) => testConnectionMock(...a), - }), -})); -vi.mock('./kafkaConsumerStore', () => ({ - kafkaConsumerStore: { stopForBroker: (...a: unknown[]) => stopForBrokerMock(...a) }, -})); - -type CallHandler = (event: { payload: { id: string; tool: string; args: Record } }) => void; - -async function renderBridge(state: KafkaState) { - vi.resetModules(); - (window as unknown as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = {}; - const { useMcpBridge } = await import('./mcpBridge'); - return renderHook(() => useMcpBridge(state)); -} - -function makeConn(overrides: Partial = {}): BrokerConfig { - return { id: 'b1', name: 'Local', bootstrapServers: 'localhost:9092', sslEnabled: false, ...overrides }; -} - -function makeState(overrides: Partial = {}): KafkaState { - return { - selectedBrokerId: '', setSelectedBrokerId: vi.fn(), - connectedBrokerId: '', setConnectedBrokerId: vi.fn(), - view: 'topics', selectedTopic: null, selectedGroup: null, - selectedTab: 'data', setSelectedTab: vi.fn(), - consumePrefill: null, producePrefill: null, consumeDetailTopic: null, - showTopics: vi.fn(), showGroups: vi.fn(), showConsumers: vi.fn(), openConsumer: vi.fn(), showProduce: vi.fn(), - selectTopic: vi.fn(), selectGroup: vi.fn(), - refreshKey: 0, refresh: vi.fn(), - ...overrides, - } as KafkaState; -} - -beforeEach(() => { - listenMock.mockReset(); - invokeMock.mockReset().mockResolvedValue(undefined); - listConfigsMock.mockReset().mockResolvedValue([makeConn()]); - saveConfigMock.mockReset().mockImplementation((c: BrokerConfig) => Promise.resolve({ ...c, id: c.id || 'new-conn-id' })); - deleteConfigMock.mockReset().mockResolvedValue(undefined); - testConnectionMock.mockReset().mockResolvedValue(undefined); - stopForBrokerMock.mockReset(); -}); -afterEach(() => { - delete (window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; -}); - -describe('useMcpBridge (Kafka) — web build (no __TAURI_INTERNALS__)', () => { - it('never registers a listener outside Tauri', async () => { - vi.resetModules(); - delete (window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; - const { useMcpBridge } = await import('./mcpBridge'); - renderHook(() => useMcpBridge(makeState())); - await new Promise((r) => setTimeout(r, 0)); - expect(listenMock).not.toHaveBeenCalled(); - }); -}); - -describe('useMcpBridge (Kafka) — Tauri desktop', () => { - let capturedCb: CallHandler | undefined; - - beforeEach(() => { - capturedCb = undefined; - unlisten.mockClear(); - listenMock.mockImplementation((_event: string, cb: CallHandler) => { - capturedCb = cb; - return Promise.resolve(unlisten); - }); - }); - - let callSeq = 0; - - async function call(state: KafkaState, tool: string, args: Record) { - await renderBridge(state); - await waitFor(() => expect(capturedCb).toBeDefined()); - const id = `call-${++callSeq}`; - invokeMock.mockClear(); - capturedCb!({ payload: { id, tool, args } }); - await waitFor(() => expect(invokeMock).toHaveBeenCalledWith('mcp_respond', expect.objectContaining({ id }))); - return invokeMock.mock.calls.find((c) => c[0] === 'mcp_respond' && (c[1] as { id: string }).id === id)?.[1] as { id: string; result: unknown; error: unknown }; - } - - it('registers exactly one mcp:call listener', async () => { - await renderBridge(makeState()); - await waitFor(() => expect(listenMock).toHaveBeenCalledTimes(1)); - expect(listenMock.mock.calls[0][0]).toBe('mcp:call'); - }); - - it('an mcp:call for a tool this bridge does not own is left unanswered', async () => { - await renderBridge(makeState()); - await waitFor(() => expect(capturedCb).toBeDefined()); - invokeMock.mockClear(); - capturedCb!({ payload: { id: 'other-tool-call', tool: 'mock_status', args: {} } }); - await new Promise((r) => setTimeout(r, 0)); - expect(invokeMock).not.toHaveBeenCalled(); - }); - - it('kafka_list_connections returns whatever kafkaApi.listConfigs resolves, unmasked', async () => { - listConfigsMock.mockResolvedValue([makeConn({ saslPassword: 'secret' })]); - const res = await call(makeState(), 'kafka_list_connections', {}); - expect(res.error).toBeNull(); - expect(res.result).toEqual([makeConn({ saslPassword: 'secret' })]); - }); - - it('kafka_get_connection finds by id and errors on an unknown one', async () => { - const ok = await call(makeState(), 'kafka_get_connection', { connectionId: 'b1' }); - expect((ok.result as BrokerConfig).id).toBe('b1'); - - const bad = await call(makeState(), 'kafka_get_connection', { connectionId: 'nope' }); - expect(bad.result).toBeNull(); - expect(bad.error).toMatch(/No Kafka connection with id/); - }); - - it('kafka_add_connection defaults sslEnabled and saves with an empty id', async () => { - const res = await call(makeState(), 'kafka_add_connection', { name: 'Staging', bootstrapServers: 'staging:9092' }); - expect(saveConfigMock).toHaveBeenCalledWith({ - id: '', name: 'Staging', bootstrapServers: 'staging:9092', - saslMechanism: undefined, saslUsername: undefined, saslPassword: undefined, sslEnabled: false, - }); - expect((res.result as BrokerConfig).id).toBe('new-conn-id'); - }); - - it('kafka_update_connection merges patch onto the existing connection (flat merge)', async () => { - listConfigsMock.mockResolvedValue([makeConn({ sslEnabled: false })]); - await call(makeState(), 'kafka_update_connection', { connectionId: 'b1', patch: { sslEnabled: true } }); - expect(saveConfigMock).toHaveBeenCalledWith(makeConn({ sslEnabled: true })); - }); - - it('kafka_delete_connection deletes an existing id and errors on an unknown one without deleting', async () => { - const res = await call(makeState(), 'kafka_delete_connection', { connectionId: 'b1' }); - expect(deleteConfigMock).toHaveBeenCalledWith('b1'); - expect(res.result).toEqual({ ok: true }); - - deleteConfigMock.mockClear(); - const bad = await call(makeState(), 'kafka_delete_connection', { connectionId: 'nope' }); - expect(bad.error).toMatch(/No Kafka connection with id/); - expect(deleteConfigMock).not.toHaveBeenCalled(); - }); - - it('kafka_test_connection calls kafkaApi.testConnection by id, without touching connected state', async () => { - const state = makeState(); - const res = await call(state, 'kafka_test_connection', { connectionId: 'b1' }); - expect(testConnectionMock).toHaveBeenCalledWith('b1'); - expect(state.setConnectedBrokerId).not.toHaveBeenCalled(); - expect(res.result).toEqual({ ok: true }); - }); - - it('kafka_connect tests and marks the broker live/selected, stopping consumers on the previous broker', async () => { - const state = makeState({ connectedBrokerId: 'old-broker' }); - const res = await call(state, 'kafka_connect', { connectionId: 'b1' }); - expect(testConnectionMock).toHaveBeenCalledWith('b1'); - expect(stopForBrokerMock).toHaveBeenCalledWith('old-broker'); - expect(state.setConnectedBrokerId).toHaveBeenCalledWith('b1'); - expect(state.setSelectedBrokerId).toHaveBeenCalledWith('b1'); - expect(res.result).toEqual({ ok: true, connectedBrokerId: 'b1' }); - }); - - it('kafka_connect does not stop consumers when reconnecting to the already-connected broker', async () => { - const state = makeState({ connectedBrokerId: 'b1' }); - await call(state, 'kafka_connect', { connectionId: 'b1' }); - expect(stopForBrokerMock).not.toHaveBeenCalled(); - }); - - it('kafka_disconnect stops consumers on the connected broker and clears it', async () => { - const state = makeState({ connectedBrokerId: 'b1' }); - const res = await call(state, 'kafka_disconnect', {}); - expect(stopForBrokerMock).toHaveBeenCalledWith('b1'); - expect(state.setConnectedBrokerId).toHaveBeenCalledWith(''); - expect(res.result).toEqual({ ok: true }); - }); - - it('kafka_disconnect is a no-op on stopForBroker when nothing is connected', async () => { - const state = makeState({ connectedBrokerId: '' }); - await call(state, 'kafka_disconnect', {}); - expect(stopForBrokerMock).not.toHaveBeenCalled(); - expect(state.setConnectedBrokerId).toHaveBeenCalledWith(''); - }); - - it('kafka_connection_status reports the current selection/connection', async () => { - const state = makeState({ selectedBrokerId: 'b1', connectedBrokerId: 'b1' }); - const res = await call(state, 'kafka_connection_status', {}); - expect(res.result).toEqual({ selectedBrokerId: 'b1', connectedBrokerId: 'b1' }); - }); -}); diff --git a/src/components/tools/kafka/mcpBridge.ts b/src/components/tools/kafka/mcpBridge.ts deleted file mode 100644 index cbdf667d..00000000 --- a/src/components/tools/kafka/mcpBridge.ts +++ /dev/null @@ -1,171 +0,0 @@ -// MCP bridge — frontend half, Kafka Explorer tool. Connection management -// only (list/add/update/delete/test/connect/disconnect) — no topic/consumer -// group/produce/consume operations, unlike the API Client and Mock Server -// bridges. Same shape as those: the Rust sidecar -// (src-tauri/src/bin/devtool-mcp-server.rs) forwards each tool call to the -// loopback control server (src-tauri/src/mcp_bridge.rs) as an `mcp:call` -// Tauri event; `useMcpBridge` answers it by running the matching handler -// below, then reports back via `mcp_respond`. -// -// Connection CRUD (list/add/update/delete/test) needs no React state at -// all — `kafkaApi` is a thin wrapper over Tauri commands that read/write a -// JSON file in the app data dir (src-tauri/src/kafka.rs), so it can be -// called directly from here regardless of whether KafkaExplorer.tsx is -// mounted. Only connect/disconnect (which selects/connects a *saved* -// broker) touches persisted UI state (`connectedBrokerId`), which is why -// those go through the shared `KafkaState` from mcpRuntimeContext.tsx -// instead of a second independent `usePersistentState` instance. -// -// Two mount points call this hook: `KafkaExplorer.tsx` (while the tool is -// on screen — always works, no setting needed) and, when the user opts in -// via Settings → MCP, `McpBackgroundBridge.tsx` at the app root (works -// regardless of which tool is on screen). `enabled` lets a caller mount the -// hook without it actually registering a listener, so the two mount points -// don't both listen at once and double-answer the same call. - -import { useEffect, useRef } from 'react'; -import { usePluginSdkFor } from '@/platform'; -import { isTauri } from '@/lib/platform'; -import { useKafkaApi } from './api_sdk'; -import type { KafkaApi, BrokerConfig } from './types'; -import { kafkaConsumerStore } from './kafkaConsumerStore'; -import type { KafkaState } from './useKafkaState'; - -interface McpCallEvent { - id: string; - tool: string; - args: Record; -} - -type ToolHandler = (args: Record) => Promise; - -function requireString(v: unknown, name: string): string { - if (typeof v !== 'string' || !v) throw new Error(`"${name}" is required and must be a string`); - return v; -} - -async function requireConnection(kafkaApi: KafkaApi, id: string): Promise { - const all = await kafkaApi.listConfigs(); - const conn = all.find((c) => c.id === id); - if (!conn) throw new Error(`No Kafka connection with id "${id}"`); - return conn; -} - -function buildHandlers(kafkaApi: KafkaApi, state: KafkaState): Record { - return { - kafka_list_connections: async () => kafkaApi.listConfigs(), - - kafka_get_connection: async (args) => requireConnection(kafkaApi, requireString(args.connectionId, 'connectionId')), - - kafka_add_connection: async (args) => { - const conn: BrokerConfig = { - id: '', - name: requireString(args.name, 'name'), - bootstrapServers: requireString(args.bootstrapServers, 'bootstrapServers'), - saslMechanism: typeof args.saslMechanism === 'string' ? args.saslMechanism : undefined, - saslUsername: typeof args.saslUsername === 'string' ? args.saslUsername : undefined, - saslPassword: typeof args.saslPassword === 'string' ? args.saslPassword : undefined, - sslEnabled: typeof args.sslEnabled === 'boolean' ? args.sslEnabled : false, - }; - return kafkaApi.saveConfig(conn); - }, - - // `patch` is a partial BrokerConfig — only included fields change. No - // nested objects on this type, so a plain shallow merge is correct - // (unlike update_request's script/auth/body — see apiclient/mcpBridge.ts). - kafka_update_connection: async (args) => { - const id = requireString(args.connectionId, 'connectionId'); - const current = await requireConnection(kafkaApi, id); - const patch = (args.patch ?? {}) as Partial; - return kafkaApi.saveConfig({ ...current, ...patch, id }); - }, - - kafka_delete_connection: async (args) => { - const id = requireString(args.connectionId, 'connectionId'); - await requireConnection(kafkaApi, id); - await kafkaApi.deleteConfig(id); - return { ok: true }; - }, - - // Verifies reachability without changing the connected/selected state — - // same as the UI's own "Connect" button before it marks the broker live. - kafka_test_connection: async (args) => { - const id = requireString(args.connectionId, 'connectionId'); - await requireConnection(kafkaApi, id); - await kafkaApi.testConnection(id); - return { ok: true }; - }, - - // Tests reachability, then marks the broker live (mirrors - // KafkaExplorer.tsx's handleConnect) — stopping any realtime consumers - // still running against a previously-connected broker, since only one - // broker is live at a time. - kafka_connect: async (args) => { - const id = requireString(args.connectionId, 'connectionId'); - await requireConnection(kafkaApi, id); - await kafkaApi.testConnection(id); - if (state.connectedBrokerId && state.connectedBrokerId !== id) { - kafkaConsumerStore.stopForBroker(state.connectedBrokerId); - } - state.setConnectedBrokerId(id); - state.setSelectedBrokerId(id); - return { ok: true, connectedBrokerId: id }; - }, - - kafka_disconnect: async () => { - if (state.connectedBrokerId) kafkaConsumerStore.stopForBroker(state.connectedBrokerId); - state.setConnectedBrokerId(''); - return { ok: true }; - }, - - kafka_connection_status: async () => ({ - selectedBrokerId: state.selectedBrokerId, - connectedBrokerId: state.connectedBrokerId, - }), - }; -} - -// Registers the single `mcp:call` listener once and keeps it answering with -// the *latest* Kafka state via a ref, matching this repo's convention for -// long-lived event listeners that read changing React state (see -// docs/ai/CLAUDE.md's "Stable refs for long-lived event listeners") — same -// pattern as the API Client's/Mock Server's/Redis's `useMcpBridge`. -export function useMcpBridge(state: KafkaState, enabled = true): void { - const sdk = usePluginSdkFor('kafka-explorer'); - const kafkaApi = useKafkaApi(); - const handlersRef = useRef>({}); - handlersRef.current = buildHandlers(kafkaApi, state); - - useEffect(() => { - if (!isTauri || !enabled) return; - let cancelled = false; - let unlisten: (() => void) | null = null; - - (async () => { - // Qua SDK: sự kiện `mcp:call` và lệnh `mcp_respond` đều nằm trong quyền - // 'native' + allowlist của plugin, nên cầu nối này cũng hiện trong nhật ký - // như mọi lời gọi khác thay vì là một đường đi vòng. - const fn = await sdk.native.listen('mcp:call', async (payload) => { - const { id, tool, args } = payload; - const handler = handlersRef.current[tool]; - // Not one of this bridge's tools — leave it alone rather than - // answering "unknown tool", since several bridges may be listening - // on the shared mcp:call event at once (see McpBackgroundBridge.tsx). - if (!handler) return; - try { - const result = await handler(args ?? {}); - await sdk.native.invoke('mcp_respond', { id, result: result ?? null, error: null }); - } catch (e) { - await sdk.native.invoke('mcp_respond', { id, result: null, error: (e as Error).message ?? String(e) }); - } - }); - if (cancelled) fn(); - else unlisten = fn; - })(); - - return () => { - cancelled = true; - unlisten?.(); - }; - }, [enabled, sdk]); -} diff --git a/src/components/tools/kafka/mcpRuntimeContext.tsx b/src/components/tools/kafka/mcpRuntimeContext.tsx deleted file mode 100644 index b6a2413b..00000000 --- a/src/components/tools/kafka/mcpRuntimeContext.tsx +++ /dev/null @@ -1,31 +0,0 @@ -// Shared Kafka Explorer runtime — one `useKafkaState()` instance for the -// whole app, provided via context instead of instantiated fresh by -// KafkaExplorer.tsx on every mount. Same reasoning as -// apiclient/mcpRuntimeContext.tsx / redis/mcpRuntimeContext.tsx: with the -// background MCP bridge (Settings → MCP) able to answer calls while the -// Kafka Explorer tool is off-screen, a second independent `useKafkaState()` -// instance would race the mounted tool's own `connectedBrokerId`/ -// `selectedBrokerId` (each a `usePersistentState`) and could silently drop -// whichever side wrote last. Mounted unconditionally at the app root — -// cheap, since `useKafkaState()` does no I/O beyond an initial localStorage -// read. - -import { createContext, useContext, type ReactNode } from 'react'; -import { useKafkaState, type KafkaState } from './useKafkaState'; - -const KafkaRuntimeContext = createContext(null); - -export function KafkaRuntimeProvider({ children }: { children: ReactNode }) { - const state = useKafkaState(); - return ( - - {children} - - ); -} - -export function useKafkaRuntime(): KafkaState { - const ctx = useContext(KafkaRuntimeContext); - if (!ctx) throw new Error('useKafkaRuntime must be used within KafkaRuntimeProvider'); - return ctx; -} diff --git a/src/components/tools/kafka/produceDraft.ts b/src/components/tools/kafka/produceDraft.ts deleted file mode 100644 index a3208184..00000000 --- a/src/components/tools/kafka/produceDraft.ts +++ /dev/null @@ -1,17 +0,0 @@ -// In-memory draft of the Produce form. The produce panel (and its ProduceTab, -// keyed per topic) remounts on every tab/tool/topic switch, so its fields live -// here at module scope to survive that — kept while the app is open and reset -// only when it's fully closed. Not persisted to disk (values can be large). - -export interface ProduceHeader { key: string; value: string } - -export const produceDraft = { - topic: '', - key: '', - value: '', - headers: [] as ProduceHeader[], - batch: false, - partitionMode: 'auto' as 'auto' | 'manual', - partition: 0, - valueFormat: 'json' as 'json' | 'plain', -}; diff --git a/src/components/tools/kafka/types.ts b/src/components/tools/kafka/types.ts deleted file mode 100644 index 56fca708..00000000 --- a/src/components/tools/kafka/types.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { Channel, PluginSdk } from '@/platform'; - -export interface BrokerConfig { - id: string; - name: string; - bootstrapServers: string; - saslMechanism?: string; - saslUsername?: string; - saslPassword?: string; - sslEnabled: boolean; -} - -export interface TopicSummary { - name: string; - partitionCount: number; - replicationFactor: number; -} - -export interface PartitionInfo { - id: number; - leader: number; - earliestOffset: number; - latestOffset: number; -} - -export interface GroupLag { - groupId: string; - partition: number; - committedOffset: number; - lag: number; -} - -export interface TopicDetails { - name: string; - partitions: PartitionInfo[]; - replicationFactor: number; -} - -export interface TopicConfig { - name: string; - value: string | null; -} - -export interface GroupSummary { - groupId: string; - state: string; - protocolType: string; -} - -export interface Assignment { - topic: string; - partition: number; - committedOffset: number; - lag: number; - // The consumer currently assigned this partition, if the group is active. - clientId?: string | null; - clientHost?: string | null; - memberId?: string | null; -} - -export interface GroupMember { - memberId: string; - clientId: string; - clientHost: string; -} - -export interface GroupDetails { - groupId: string; - state: string; - memberCount: number; - members: GroupMember[]; - assignments: Assignment[]; -} - -export interface BatchRecord { - key: string | null; - value: string; - headers?: Record; -} - -export interface ProduceResult { - partition: number; - offset: number; -} - -export interface KafkaMessage { - offset: number; - partition: number; - timestamp: string; - key: string | null; - value: string | null; - headers: Record; -} - -/** A record streamed by the realtime (anonymous) consumer. */ -export interface KafkaConsumedMessage { - partition: number; - offset: number; - timestamp: string; - key: string | null; - /** UTF-8 value when decodable, else null. */ - value: string | null; - /** Raw value bytes, base64 (null when the record had no value) — used for the hex view. */ - valueB64: string | null; - headers: Record; -} - -export type ConsumeFrom = 'latest' | 'earliest'; - -// ── Invoke wrappers ─────────────────────────────────────────────────────────── - -/** - * Lớp lệnh của tool, dựng theo SDK của plugin thay vì gọi thẳng `invoke`. - * - * Nhờ vậy allowlist `commands: ['kafka_', 'mcp_respond']` trong manifest có hiệu lực thật — - * một lệnh gõ sai hay một lệnh ngoài danh sách bị chặn ngay và ghi vào nhật ký, - * thay vì lặng lẽ đi thẳng xuống Rust. - * - * Dùng qua `useKafkaApi()` (xem api.ts); factory để lộ ra đây chỉ cho test và - * cho code không phải React. - */ -export function createKafkaApi(sdk: PluginSdk) { - const invoke = (command: string, args?: Record) => - sdk.native.invoke(command, args); - - return { - listConfigs: () => - invoke('kafka_list_configs'), - - saveConfig: (config: BrokerConfig) => - invoke('kafka_save_config', { config }), - - deleteConfig: (configId: string) => - invoke('kafka_delete_config', { configId }), - - testConnection: (configId: string) => - invoke('kafka_test_connection', { configId }), - - listTopics: (configId: string) => - invoke('kafka_list_topics', { configId }), - - topicDetails: (configId: string, topic: string) => - invoke('kafka_topic_details', { configId, topic }), - - // On-demand only (Consumers tab) — scans groups, so never call on topic open. - topicConsumerGroups: (configId: string, topic: string) => - invoke('kafka_topic_consumer_groups', { configId, topic }), - - createTopic: (configId: string, name: string, numPartitions: number, replicationFactor: number) => - invoke('kafka_create_topic', { configId, name, numPartitions, replicationFactor }), - - listGroups: (configId: string) => - invoke('kafka_list_groups', { configId }), - - groupDetails: (configId: string, groupId: string) => - invoke('kafka_group_details', { configId, groupId }), - - produce: (configId: string, topic: string, partition: number | null, key: string | null, value: string, headers: Record) => - invoke('kafka_produce', { configId, topic, partition, key, value, headers }), - - produceBatch: (configId: string, topic: string, partition: number | null, records: BatchRecord[]) => - invoke('kafka_produce_batch', { configId, topic, partition, records }), - - fetchMessages: (configId: string, topic: string, partition: number, offset: number, limit: number, startTimestamp?: number | null) => - invoke('kafka_fetch_messages', { configId, topic, partition, offset, limit, startTimestamp: startTimestamp ?? null }), - - deleteTopic: (configId: string, name: string) => - invoke('kafka_delete_topic', { configId, name }), - - topicConfigs: (configId: string, topic: string) => - invoke('kafka_topic_configs', { configId, topic }), - - /** Start a realtime anonymous consumer over all partitions; records stream to `onMessage`. */ - consumeStart: ( - args: { configId: string; topic: string; from: ConsumeFrom }, - onMessage: Channel, - ) => - invoke('kafka_consume_start', { - configId: args.configId, - topic: args.topic, - from: args.from, - onMessage, - }), - - consumeStop: (consumerId: string) => - invoke('kafka_consume_stop', { consumerId }), - }; -} - -export type KafkaApi = ReturnType; diff --git a/src/components/tools/kafka/useKafkaState.ts b/src/components/tools/kafka/useKafkaState.ts deleted file mode 100644 index f77ed25d..00000000 --- a/src/components/tools/kafka/useKafkaState.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { useState } from 'react'; -import { usePluginSdkFor, usePluginState } from '@/platform'; - -/** Which destination the right panel shows. Mirrors the RabbitMQ tool's nav model. */ -export type KafkaView = 'topics' | 'groups' | 'consume' | 'produce' | 'topic' | 'group'; - -/** Detail tabs shown inside a single topic. */ -export type KafkaTab = 'properties' | 'data' | 'config' | 'consumers'; - -/** Topic to pre-fill a panel (Consume / Produce) with when opened from an entry point. */ -export interface TopicPrefill { - topic: string; - /** Bumped each request so re-clicking re-applies even with the same value. */ - token: number; -} - -export interface KafkaState { - selectedBrokerId: string; - setSelectedBrokerId: (id: string) => void; - /** The broker the user has explicitly connected to ('' = none). */ - connectedBrokerId: string; - setConnectedBrokerId: (id: string) => void; - view: KafkaView; - selectedTopic: string | null; - selectedGroup: string | null; - selectedTab: KafkaTab; - setSelectedTab: (tab: KafkaTab) => void; - consumePrefill: TopicPrefill | null; - producePrefill: TopicPrefill | null; - /** Topic of the consumer shown in the detail panel; null = the consumer list. */ - consumeDetailTopic: string | null; - showTopics: () => void; - showGroups: () => void; - showConsumers: (topic?: string) => void; - /** Open one consumer's detail panel (from the list or the left-nav line). */ - openConsumer: (topic: string) => void; - showProduce: (topic?: string) => void; - selectTopic: (name: string) => void; - selectGroup: (id: string) => void; - refreshKey: number; - refresh: () => void; -} - -export function useKafkaState(): KafkaState { - const sdk = usePluginSdkFor('kafka-explorer'); - // Selection persists across restarts so you resume where you left off. - const [selectedBrokerId, setSelectedBrokerIdRaw] = usePluginState(sdk, 'selectedBrokerId', '', { legacyKey: 'devtool:kafka:selectedBrokerId' }); - const [connectedBrokerId, setConnectedBrokerId] = usePluginState(sdk, 'connectedBrokerId', '', { legacyKey: 'devtool:kafka:connectedBrokerId' }); - const [view, setView] = usePluginState(sdk, 'view', 'topics', { legacyKey: 'devtool:kafka:view' }); - const [selectedTopic, setSelectedTopic] = usePluginState(sdk, 'selectedTopic', null, { legacyKey: 'devtool:kafka:selectedTopic' }); - const [selectedGroup, setSelectedGroup] = usePluginState(sdk, 'selectedGroup', null, { legacyKey: 'devtool:kafka:selectedGroup' }); - const [selectedTab, setSelectedTab] = usePluginState(sdk, 'selectedTab', 'data', { legacyKey: 'devtool:kafka:selectedTab' }); - const [consumePrefill, setConsumePrefill] = useState(null); - const [producePrefill, setProducePrefill] = useState(null); - const [consumeDetailTopic, setConsumeDetailTopic] = useState(null); - const [refreshKey, setRefreshKey] = useState(0); - - const refresh = () => setRefreshKey((k) => k + 1); - - const setSelectedBrokerId = (id: string) => { - setSelectedBrokerIdRaw(id); - setView('topics'); - setSelectedTopic(null); - setSelectedGroup(null); - }; - - const showTopics = () => { setView('topics'); setSelectedTopic(null); setSelectedGroup(null); }; - const showGroups = () => { setView('groups'); setSelectedTopic(null); setSelectedGroup(null); }; - - const showConsumers = (topic?: string) => { - // Guard: callers sometimes wire this straight to onClick, which would pass the - // event as `topic`. Only accept a real string. - const t = typeof topic === 'string' ? topic : undefined; - setView('consume'); - setSelectedTopic(null); - setSelectedGroup(null); - setConsumeDetailTopic(null); - setConsumePrefill(t ? { topic: t, token: Date.now() } : null); - }; - - const openConsumer = (topic: string) => { - setView('consume'); - setSelectedTopic(null); - setSelectedGroup(null); - setConsumeDetailTopic(topic); - }; - - const showProduce = (topic?: string) => { - const t = typeof topic === 'string' ? topic : undefined; - setView('produce'); - setSelectedTopic(null); - setSelectedGroup(null); - setProducePrefill(t ? { topic: t, token: Date.now() } : null); - }; - - const selectTopic = (name: string) => { - setView('topic'); - setSelectedTopic(name); - setSelectedGroup(null); - setSelectedTab('data'); - }; - - const selectGroup = (id: string) => { - setView('group'); - setSelectedGroup(id); - setSelectedTopic(null); - }; - - return { - selectedBrokerId, - setSelectedBrokerId, - connectedBrokerId, - setConnectedBrokerId, - view, - selectedTopic, - selectedGroup, - selectedTab, - setSelectedTab, - consumePrefill, - producePrefill, - consumeDetailTopic, - showTopics, - showGroups, - showConsumers, - openConsumer, - showProduce, - selectTopic, - selectGroup, - refreshKey, - refresh, - }; -} diff --git a/src/components/tools/mockserver/mcpBridge.ts b/src/components/tools/mockserver/mcpBridge.ts index 826de54f..af8f15a2 100644 --- a/src/components/tools/mockserver/mcpBridge.ts +++ b/src/components/tools/mockserver/mcpBridge.ts @@ -24,6 +24,7 @@ import { usePluginSdkFor } from '@/platform'; import { isTauri } from '@/lib/platform'; import type { MockServerState } from './useMockServer'; import type { MockConfig, RequestLogEntry, Stub } from './types'; +import { MOCK_SERVER_MCP_TOOLS } from './mcpTools'; interface McpCallEvent { id: string; @@ -181,6 +182,11 @@ export function useMcpBridge(state: MockServerState, enabled = true): void { let unlisten: (() => void) | null = null; (async () => { + // Bundled with THIS tool, not the platform — devtool-mcp-server.rs has + // no compiled-in tool list of its own (see that file), so registering + // here is what makes its tools show up in `list_tools` at all. Same + // 'native' + allowlist gate as `mcp_respond` below. + await sdk.native.invoke('mcp_register_tools', { pluginId: sdk.id, tools: MOCK_SERVER_MCP_TOOLS }); // Qua SDK: sự kiện `mcp:call` và lệnh `mcp_respond` đều nằm trong quyền // 'native' + allowlist của plugin, nên cầu nối này cũng hiện trong nhật ký // như mọi lời gọi khác thay vì là một đường đi vòng. @@ -205,6 +211,10 @@ export function useMcpBridge(state: MockServerState, enabled = true): void { return () => { cancelled = true; unlisten?.(); + // Best-effort — this tool's tools stop answering the moment + // `mcp:call` is unlistened anyway; unregistering just keeps + // `list_tools` honest about what will actually answer right now. + sdk.native.invoke('mcp_unregister_tools', { pluginId: sdk.id }).catch(() => {}); }; }, [enabled, sdk]); } diff --git a/src/components/tools/mockserver/mcpTools.ts b/src/components/tools/mockserver/mcpTools.ts new file mode 100644 index 00000000..898bc491 --- /dev/null +++ b/src/components/tools/mockserver/mcpTools.ts @@ -0,0 +1,107 @@ +// MCP tool catalogue for this plugin — bundled with it, not with the +// platform. Registered once (mcp_register_tools) when this bridge mounts +// its `mcp:call` listener, so devtool-mcp-server.rs's dynamic +// `list_tools()` (no compiled-in tool list of its own) can advertise these +// to an MCP client. Kept in sync BY HAND with `buildHandlers()` in +// ./mcpBridge.ts. +export interface McpToolDef { + name: string; + description: string; + inputSchema: Record; +} + +export const MOCK_SERVER_MCP_TOOLS: McpToolDef[] = [ + // ── Mock Server ────────────────────────────────────────────────── + // Only answers while DevTool is open with the Mock Server tool on + // screen — same contract as the API Client tools above, for the same + // reason (the stub list and bind config live in that mounted + // component's state, not anywhere the sidecar can reach on its own). + { + "name": "mock_get_config", + "description": "Get the mock server's bind (host/port), fallback response, running status, and a summarized stub list (id/enabled/name/method/path/mode/status — not matchers/headers/body/script). Use mock_get_stub for one stub's full definition.", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "mock_get_stub", + "description": "Get one stub's full definition: matchers, response mode, status, headers, body (or script for scripted responses), delay.", + "inputSchema": { "type": "object", "properties": { "stubId": { "type": "string" } }, "required": ["stubId"] } + }, + { + "name": "mock_add_stub", + "description": "Add a new stub. `stub` is a partial Stub object for the initial values (defaults: enabled=true, method=GET, mode=static — see get_scripting_reference for field shapes). Returns the created stub, appended to the end; reorder with mock_move_stub since stub order matters (first match wins).", + "inputSchema": { "type": "object", "properties": { "stub": { "type": "object" } } } + }, + { + "name": "mock_update_stub", + "description": "Patch a stub in place. `patch` is a partial Stub object — only the fields you include are changed.", + "inputSchema": { + "type": "object", + "properties": { "stubId": { "type": "string" }, "patch": { "type": "object" } }, + "required": ["stubId", "patch"] + } + }, + { + "name": "mock_duplicate_stub", + "description": "Duplicate a stub (fresh id, \" copy\") as the next stub right after the original. Returns the new stub.", + "inputSchema": { "type": "object", "properties": { "stubId": { "type": "string" } }, "required": ["stubId"] } + }, + { + "name": "mock_delete_stub", + "description": "Delete a stub by id.", + "inputSchema": { "type": "object", "properties": { "stubId": { "type": "string" } }, "required": ["stubId"] } + }, + { + "name": "mock_move_stub", + "description": "Move a stub up or down one position relative to its siblings. Stub order matters — the first enabled stub whose matchers all pass wins — so this is how to reprioritize overlapping stubs.", + "inputSchema": { + "type": "object", + "properties": { "stubId": { "type": "string" }, "direction": { "type": "string", "enum": ["up", "down"] } }, + "required": ["stubId", "direction"] + } + }, + { + "name": "mock_set_fallback", + "description": "Patch the \"no stub matched\" response. `patch` may include any of notFoundStatus (number), notFoundBody (string), notFoundContentType (string).", + "inputSchema": { "type": "object", "properties": { "patch": { "type": "object" } }, "required": ["patch"] } + }, + { + "name": "mock_set_bind", + "description": "Set the host and/or port the server binds to on the next mock_start. Does NOT hot-swap an already-running server (unlike stubs/fallback, which apply live) — call mock_stop then mock_start to rebind.", + "inputSchema": { "type": "object", "properties": { "host": { "type": "string" }, "port": { "type": "number" } } } + }, + { + "name": "mock_start", + "description": "Start the mock server with the current config (stubs + bind address). Returns the resulting status (running/host/port). Errors if the port is already in use.", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "mock_stop", + "description": "Stop the mock server.", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "mock_status", + "description": "Get the mock server's current running status (running/host/port), without the config/stub list mock_get_config also returns.", + "inputSchema": { "type": "object", "properties": {} } + }, + { + "name": "mock_test_script", + "description": "Run a Rhai response script against a synthetic request, without saving it to a stub — for iterating before mock_add_stub/mock_update_stub. `sample` is { method, path, query, headers, params, body } (all optional, defaults to GET /); see get_scripting_reference for the req/return shape.", + "inputSchema": { + "type": "object", + "properties": { "script": { "type": "string" }, "sample": { "type": "object" } }, + "required": ["script"] + } + }, + { + "name": "mock_get_request_log", + "description": "Get the most recent requests the mock server handled (newest first, capped at `limit`, default 50), each with which stub matched (or null for the fallback), status, timing, and truncated request/response bodies.", + "inputSchema": { "type": "object", "properties": { "limit": { "type": "number" } } } + }, + { + "name": "mock_clear_request_log", + "description": "Clear the mock server's request log.", + "inputSchema": { "type": "object", "properties": {} } + }, + +]; diff --git a/src/components/tools/rabbit/ConfirmDialog.tsx b/src/components/tools/rabbit/ConfirmDialog.tsx deleted file mode 100644 index c97f74e4..00000000 --- a/src/components/tools/rabbit/ConfirmDialog.tsx +++ /dev/null @@ -1,4 +0,0 @@ -// Re-exported from the shared foundation (src/components/ui/confirm-dialog.tsx) -// so existing imports of './ConfirmDialog' inside the rabbit/ tool keep working -// without every call site needing to change its import path. -export { ConfirmDialog, type ConfirmDialogProps } from '@/components/ui/confirm-dialog'; diff --git a/src/components/tools/rabbit/ConnectionForm.tsx b/src/components/tools/rabbit/ConnectionForm.tsx deleted file mode 100644 index c5c1aacc..00000000 --- a/src/components/tools/rabbit/ConnectionForm.tsx +++ /dev/null @@ -1,374 +0,0 @@ -import { useState, useEffect } from 'react'; -import { ModalShell } from '@/components/ui/modal-shell'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Textarea } from '@/components/ui/textarea'; -import { Switch } from '@/components/ui/switch'; -import { Info, CheckCircle2 } from 'lucide-react'; -import { Spinner } from '@/components/ui/spinner'; -import { Callout } from '@/components/ui/callout'; -import { CollapsibleSection } from '@/components/ui/collapsible-section'; -import { EMPTY_CONNECTION, type RabbitConnection } from './types'; -import { useRabbitApi } from './api_sdk'; -import { rabbitMgmt } from './api'; - -interface ConnectionFormProps { - initial?: RabbitConnection | null; - onSave: (config: RabbitConnection) => Promise; - onCancel: () => void; -} - -const defaultPort = (tls: boolean) => (tls ? 5671 : 5672); - -/** - * Parse a comma/newline-separated host list ("127.0.0.1:5672, broker2:5672") - * into the canonical fields: the first entry is the primary host+port, the rest - * are failover endpoints (`extraHosts`). A bare host falls back to the AMQP - * default port for the current TLS setting. - */ -function parseHostsText(text: string, tls: boolean): { host: string; amqpPort: number; extraHosts: string[] } { - const one = (e: string): { host: string; port: number } => { - const i = e.lastIndexOf(':'); - if (i > 0) { - const h = e.slice(0, i).trim(); - const p = Number(e.slice(i + 1).trim()); - if (h && Number.isInteger(p) && p > 0 && p <= 65535) return { host: h, port: p }; - } - return { host: e, port: defaultPort(tls) }; - }; - const entries = text.split(/[,\n]/).map((s) => s.trim()).filter(Boolean).map(one); - if (entries.length === 0) return { host: '', amqpPort: defaultPort(tls), extraHosts: [] }; - return { - host: entries[0].host, - amqpPort: entries[0].port, - extraHosts: entries.slice(1).map((x) => `${x.host}:${x.port}`), - }; -} - -/** Render the canonical host fields back into the editable "host:port, …" string. */ -function hostsToText(c: { host: string; amqpPort: number; extraHosts?: string[] | null }): string { - return [c.host ? `${c.host}:${c.amqpPort}` : '', ...(c.extraHosts ?? [])].filter(Boolean).join(', '); -} - -export function ConnectionForm({ initial, onSave, onCancel }: ConnectionFormProps) { - const rabbitApi = useRabbitApi(); - const [form, setForm] = useState(initial ?? EMPTY_CONNECTION); - const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(false); - const [tested, setTested] = useState<'ok' | null>(null); - const [error, setError] = useState(''); - const [showAdvanced, setShowAdvanced] = useState(false); - const [uri, setUri] = useState(''); - const [showUri, setShowUri] = useState(false); - // The editable "host:port, host:port" string; parsed into form.host/amqpPort/extraHosts. - const [hostsText, setHostsText] = useState(() => hostsToText(initial ?? EMPTY_CONNECTION)); - - useEffect(() => { - const c = initial ?? EMPTY_CONNECTION; - setForm(c); - setHostsText(hostsToText(c)); - setError(''); - setTested(null); - }, [initial]); - - const set = (k: keyof RabbitConnection, v: string | number | boolean | null) => { - setForm((f) => ({ ...f, [k]: v })); - setTested(null); - }; - - const onHostsChange = (text: string) => { - setHostsText(text); - setForm((f) => ({ ...f, ...parseHostsText(text, f.useTls) })); - setTested(null); - }; - - // Re-default portless host entries when TLS flips (5672 ↔ 5671). - const onTlsChange = (v: boolean) => { - setForm((f) => ({ ...f, useTls: v, ...parseHostsText(hostsText, v) })); - setTested(null); - }; - - /** - * Parse an amqp(s):// URI — including a comma-separated multi-host authority - * (amqp://u:p@h1:5672,h2:5672/vhost) — into the host list, credentials, vhost - * and TLS, and reflect it in the visible Host(s) field. - */ - const applyUri = () => { - const m = /^(amqps?):\/\/(?:([^:@/]*)(?::([^@/]*))?@)?([^/]+)(?:\/(.*))?$/i.exec(uri.trim()); - if (!m) { - setError('Could not parse AMQP URI — expected amqp(s)://user:pass@host:port[,host:port]/vhost'); - return; - } - const tls = m[1].toLowerCase() === 'amqps'; - const parsed = parseHostsText(m[4], tls); - setHostsText(hostsToText(parsed)); - setForm((f) => ({ - ...f, - ...parsed, - username: m[2] ? decodeURIComponent(m[2]) : f.username, - password: m[3] ? decodeURIComponent(m[3]) : f.password, - vhost: m[5] != null && m[5] !== '' ? decodeURIComponent(m[5]) : f.vhost, - useTls: tls, - })); - setUri(''); - setShowUri(false); - setError(''); - setTested(null); - }; - - const amqpOnly = !!form.amqpOnly; - - const validate = (): string | null => { - if (!form.name.trim()) return 'Name is required'; - if (!form.host.trim()) return 'At least one host is required'; - if (!amqpOnly && (!form.port || form.port < 1 || form.port > 65535)) return 'Management port must be 1–65535'; - if (!form.amqpPort || form.amqpPort < 1 || form.amqpPort > 65535) return 'AMQP port must be 1–65535'; - return null; - }; - - const handleTest = async () => { - const v = validate(); - if (v) { setError(v); return; } - setTesting(true); - setError(''); - setTested(null); - try { - // The AMQP connection is the core path — always verify it. If the optional - // management API is enabled, verify that too (it needs the plugin). - await rabbitApi.amqpTest(form); - if (!amqpOnly) await rabbitMgmt.testConnection(form); - setTested('ok'); - } catch (e) { - setError(String(e instanceof Error ? e.message : e)); - } finally { - setTesting(false); - } - }; - - const handleSubmit = async () => { - const v = validate(); - if (v) { setError(v); return; } - setSaving(true); - setError(''); - try { - await onSave(form); - } catch (e) { - setError(String(e)); - } finally { - setSaving(false); - } - }; - - // ModalShell portals to , so the fixed overlay escapes the tool's - // entrance-animation wrapper (an animating `transform` ancestor would offset - // a fixed child). - return ( - - -
- - -
- - } - > -
-
- - set('name', e.target.value)} - placeholder="Local RabbitMQ" - className="mt-1" - /> -
- - {/* AMQP addresses — the only required part. Accepts a comma-separated list. */} -
-
- - -
- onHostsChange(e.target.value)} - placeholder="127.0.0.1:5672, broker2:5672" - className="mt-1 font-mono text-sm" - /> -

- host:port, comma-separated. The first is primary; the rest are tried on failover. Default port {defaultPort(form.useTls)} ({form.useTls ? 'amqps' : 'amqp'}). -

- {showUri && ( -
- setUri(e.target.value)} - placeholder="amqp://user:pass@host1:5672,host2:5672/vhost" - className="font-mono text-xs" - autoFocus - /> - -
- )} -
- -
-
- - set('username', e.target.value)} - placeholder="guest" - className="mt-1 font-mono text-sm" - /> -
-
- - set('password', e.target.value)} - placeholder="guest" - className="mt-1 font-mono text-sm" - /> -
-
- -
-
- -

- Connect over amqps (and https for the management API). -

-
- -
- - {/* Management API — optional. Powers browse-all lists, overview and - connections; never required to publish/consume/request over AMQP. */} -
-
-
- -

- Adds browse-all queues & exchanges, the overview dashboard and connections — needs the RabbitMQ management plugin. Leave off to work over AMQP with typed names. -

-
- set('amqpOnly', !v)} aria-label="Enable management API" /> -
- {!amqpOnly && ( -
- - set('port', Number(e.target.value))} - className="mt-1 font-mono text-sm h-ctl w-32" - /> -

- Default 15672 on {form.host || 'host'} ({form.useTls ? 'https' : 'http'}). -

-
- )} -
- - {/* Advanced / TLS */} - -
-
- - set('heartbeat', e.target.value ? Number(e.target.value) : null)} - placeholder="30" className="mt-1 font-mono text-xs h-ctl" - /> -
-
- - set('connectionName', e.target.value || null)} - placeholder="devtool" className="mt-1 font-mono text-xs h-ctl" - /> -
-
-
- - set('vhost', e.target.value)} - placeholder="/" - className="mt-1 font-mono text-xs h-ctl" - /> -

Default /. Add multiple hosts in the Addresses field above for HA failover.

-
-
- -