From 1b5950016c53cc540ba6d6e7f19735e15df9b613 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 16:36:07 +0000 Subject: [PATCH 01/27] Move Redis/RabbitMQ/Container Manager to developer-desktop-util-plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These three tools were already Tier-B sidecar plugins, so they're a natural fit for the existing install-from-URL mechanism (docs/plugin-sdk/05-external-install.md) instead of being compiled into every build for users who don't need a Redis/RabbitMQ/Docker client. - Remove src/plugins/{redis-client,rabbit-client,container-manager}, their src/components/tools/* sources, sidecar bins, and the RabbitMQ Rust integration test/docker harness (moved to the plugin repo). - Extend the external-install SDK bridge (window.__DEVTOOL_VENDOR__.platform) and RemotePluginManifest (installer.ts + artifact_installer.rs) so a URL-installed plugin can actually use sdk.storage/sdk.service/etc. and declare a Tier-B service descriptor — neither was possible before, so this migration would have shipped non-functional plugins otherwise. - Split service_host.rs's allowlist into ALLOWED_SERVICES (trust boundary, unchanged) vs BUNDLED_SERVICES (what actually ships in this build), so the three sidecar names stay installable without being force-bundled. - Add a Tailwind classname safelist for the moved tools' UI so their utility classes aren't purged from the compiled CSS before anyone reinstalls them. - Update registry/test fixtures, MCP background bridge, tool groupings, and docs accordingly. --- .github/dependabot.yml | 31 +- docs/ai/CLAUDE.md | 12 +- .../architecture/optional-broker-plugins.md | 79 + docs/plugin-sdk/05-external-install.md | 20 + scripts/prepare-service-sidecars.mjs | 7 +- src-tauri/Cargo.lock | 898 +------- src-tauri/Cargo.toml | 27 - src-tauri/src/artifact_installer.rs | 18 + src-tauri/src/bin/devtool-svc-container.rs | 2032 ----------------- src-tauri/src/bin/devtool-svc-rabbit.rs | 1558 ------------- src-tauri/src/bin/devtool-svc-redis.rs | 1233 ---------- src-tauri/src/service_host.rs | 44 +- src-tauri/tauri.conf.json | 2 +- src-tauri/tests/rabbit_it.rs | 160 -- src/App.tsx | 31 +- src/components/McpBackgroundBridge.tsx | 26 +- src/components/SettingsPlugins.test.tsx | 6 +- .../tools/container/ComposeView.tsx | 382 ---- .../tools/container/ConnectionForm.tsx | 155 -- .../container/ContainerDetailsDialog.tsx | 271 --- .../tools/container/ContainerLogsDialog.tsx | 111 - .../tools/container/ContainerManager.tsx | 189 -- .../container/ContainerResourcesDialog.tsx | 333 --- .../tools/container/ContainersView.tsx | 433 ---- src/components/tools/container/DetailRows.tsx | 68 - .../tools/container/ImageDetailsDialog.tsx | 112 - .../tools/container/ImagesView.test.ts | 31 - src/components/tools/container/ImagesView.tsx | 397 ---- src/components/tools/container/LeftPanel.tsx | 167 -- src/components/tools/container/LogsPanel.tsx | 639 ------ .../tools/container/NetworkDetailsDialog.tsx | 114 - .../tools/container/NetworksView.tsx | 277 --- .../tools/container/OverviewView.tsx | 155 -- .../tools/container/PruneButton.tsx | 91 - .../tools/container/SelectionBar.tsx | 99 - .../tools/container/VolumeDetailsDialog.tsx | 92 - .../tools/container/VolumesView.tsx | 288 --- src/components/tools/container/api_sdk.ts | 16 - src/components/tools/container/format.ts | 13 - .../tools/container/logsPanel.test.ts | 69 - .../tools/container/mcpBridge.test.ts | 334 --- src/components/tools/container/mcpBridge.ts | 279 --- .../tools/container/mcpRuntimeContext.tsx | 31 - src/components/tools/container/types.ts | 592 ----- src/components/tools/container/usage.test.ts | 90 - src/components/tools/container/usage.ts | 92 - .../tools/container/useContainerState.ts | 48 - .../tools/container/useRowSelection.ts | 105 - .../tools/container/useSort.test.ts | 67 - src/components/tools/container/useSort.ts | 60 - src/components/tools/rabbit/ConfirmDialog.tsx | 4 - .../tools/rabbit/ConnectionForm.tsx | 374 --- .../tools/rabbit/ConnectionsView.tsx | 121 - src/components/tools/rabbit/ConsumersView.tsx | 517 ----- .../tools/rabbit/ExchangeListView.tsx | 294 --- src/components/tools/rabbit/ExchangeView.tsx | 183 -- src/components/tools/rabbit/LeftPanel.tsx | 227 -- src/components/tools/rabbit/OverviewView.tsx | 122 - src/components/tools/rabbit/QueueListView.tsx | 311 --- src/components/tools/rabbit/QueueView.tsx | 258 --- src/components/tools/rabbit/RabbitClient.tsx | 269 --- .../tools/rabbit/RabbitInfoModal.tsx | 149 -- .../tools/rabbit/RecentSuggestions.tsx | 47 - src/components/tools/rabbit/RpcView.tsx | 630 ----- src/components/tools/rabbit/api.ts | 223 -- src/components/tools/rabbit/api_sdk.ts | 16 - .../tools/rabbit/consumerStore.test.ts | 149 -- src/components/tools/rabbit/consumerStore.ts | 203 -- src/components/tools/rabbit/format.ts | 47 - .../tools/rabbit/inputHistoryStore.ts | 95 - .../tools/rabbit/knownNamesStore.ts | 98 - src/components/tools/rabbit/mcpBridge.test.ts | 232 -- src/components/tools/rabbit/mcpBridge.ts | 185 -- .../tools/rabbit/mcpRuntimeContext.tsx | 31 - src/components/tools/rabbit/types.test.ts | 241 -- src/components/tools/rabbit/types.ts | 449 ---- src/components/tools/rabbit/useRabbitData.ts | 80 - src/components/tools/rabbit/useRabbitState.ts | 154 -- src/components/tools/redis/AdminView.tsx | 236 -- src/components/tools/redis/CliConsole.tsx | 230 -- src/components/tools/redis/ConfirmDialog.tsx | 1 - src/components/tools/redis/ConnectionForm.tsx | 170 -- src/components/tools/redis/KeyDetailView.tsx | 656 ------ src/components/tools/redis/KeysListView.tsx | 413 ---- src/components/tools/redis/LeftPanel.tsx | 188 -- .../tools/redis/MathConfirmDialog.tsx | 111 - src/components/tools/redis/OverviewView.tsx | 97 - .../tools/redis/PubSubView.test.tsx | 97 - src/components/tools/redis/PubSubView.tsx | 173 -- src/components/tools/redis/RedisClient.tsx | 199 -- src/components/tools/redis/api.ts | 16 - src/components/tools/redis/commands.ts | 139 -- src/components/tools/redis/format.ts | 65 - src/components/tools/redis/mcpBridge.test.ts | 199 -- src/components/tools/redis/mcpBridge.ts | 166 -- .../tools/redis/mcpRuntimeContext.tsx | 29 - src/components/tools/redis/types.test.ts | 203 -- src/components/tools/redis/types.ts | 252 -- src/components/tools/redis/useRedisData.ts | 60 - src/components/tools/redis/useRedisState.ts | 68 - src/lib/liveConnections.test.ts | 10 - src/lib/liveConnections.ts | 2 - src/lib/onboardingToolGroups.ts | 2 +- src/lib/shortcuts.ts | 6 - src/lib/toolGroups.test.ts | 13 +- src/lib/toolGroups.ts | 7 - src/main.tsx | 34 + src/platform/installer.ts | 14 + src/platform/registry.installer.test.ts | 8 +- src/platform/registry.test.ts | 18 +- src/platform/services.test.tsx | 16 +- src/plugins/container-manager/plugin.ts | 65 - src/plugins/rabbit-client/plugin.ts | 40 - src/plugins/redis-client/plugin.ts | 44 - .../externalPluginClassnamesSafelist.ts | 75 + testing/rabbitmq/Dockerfile | 9 - testing/rabbitmq/README.md | 120 - testing/rabbitmq/common.py | 25 - testing/rabbitmq/consumer.py | 25 - testing/rabbitmq/definitions.json | 48 - testing/rabbitmq/docker-compose.yml | 132 -- testing/rabbitmq/fanout_publisher.py | 25 - testing/rabbitmq/producer.py | 33 - testing/rabbitmq/rabbitmq.conf | 2 - testing/rabbitmq/requirements.txt | 5 - testing/rabbitmq/rpc_client.py | 64 - testing/rabbitmq/rpc_server.py | 39 - testing/rabbitmq/spring-rpc/Dockerfile | 15 - testing/rabbitmq/spring-rpc/pom.xml | 70 - .../java/com/example/rpc/Application.java | 13 - .../java/com/example/rpc/RabbitConfig.java | 37 - .../java/com/example/rpc/RpcListener.java | 24 - .../java/com/example/rpc/RpcSelfTest.java | 36 - .../src/main/resources/application.yml | 26 - testing/rabbitmq/topic_publisher.py | 30 - 135 files changed, 364 insertions(+), 22629 deletions(-) create mode 100644 docs/decisions/architecture/optional-broker-plugins.md delete mode 100644 src-tauri/src/bin/devtool-svc-container.rs delete mode 100644 src-tauri/src/bin/devtool-svc-rabbit.rs delete mode 100644 src-tauri/src/bin/devtool-svc-redis.rs delete mode 100644 src-tauri/tests/rabbit_it.rs delete mode 100644 src/components/tools/container/ComposeView.tsx delete mode 100644 src/components/tools/container/ConnectionForm.tsx delete mode 100644 src/components/tools/container/ContainerDetailsDialog.tsx delete mode 100644 src/components/tools/container/ContainerLogsDialog.tsx delete mode 100644 src/components/tools/container/ContainerManager.tsx delete mode 100644 src/components/tools/container/ContainerResourcesDialog.tsx delete mode 100644 src/components/tools/container/ContainersView.tsx delete mode 100644 src/components/tools/container/DetailRows.tsx delete mode 100644 src/components/tools/container/ImageDetailsDialog.tsx delete mode 100644 src/components/tools/container/ImagesView.test.ts delete mode 100644 src/components/tools/container/ImagesView.tsx delete mode 100644 src/components/tools/container/LeftPanel.tsx delete mode 100644 src/components/tools/container/LogsPanel.tsx delete mode 100644 src/components/tools/container/NetworkDetailsDialog.tsx delete mode 100644 src/components/tools/container/NetworksView.tsx delete mode 100644 src/components/tools/container/OverviewView.tsx delete mode 100644 src/components/tools/container/PruneButton.tsx delete mode 100644 src/components/tools/container/SelectionBar.tsx delete mode 100644 src/components/tools/container/VolumeDetailsDialog.tsx delete mode 100644 src/components/tools/container/VolumesView.tsx delete mode 100644 src/components/tools/container/api_sdk.ts delete mode 100644 src/components/tools/container/format.ts delete mode 100644 src/components/tools/container/logsPanel.test.ts delete mode 100644 src/components/tools/container/mcpBridge.test.ts delete mode 100644 src/components/tools/container/mcpBridge.ts delete mode 100644 src/components/tools/container/mcpRuntimeContext.tsx delete mode 100644 src/components/tools/container/types.ts delete mode 100644 src/components/tools/container/usage.test.ts delete mode 100644 src/components/tools/container/usage.ts delete mode 100644 src/components/tools/container/useContainerState.ts delete mode 100644 src/components/tools/container/useRowSelection.ts delete mode 100644 src/components/tools/container/useSort.test.ts delete mode 100644 src/components/tools/container/useSort.ts delete mode 100644 src/components/tools/rabbit/ConfirmDialog.tsx delete mode 100644 src/components/tools/rabbit/ConnectionForm.tsx delete mode 100644 src/components/tools/rabbit/ConnectionsView.tsx delete mode 100644 src/components/tools/rabbit/ConsumersView.tsx delete mode 100644 src/components/tools/rabbit/ExchangeListView.tsx delete mode 100644 src/components/tools/rabbit/ExchangeView.tsx delete mode 100644 src/components/tools/rabbit/LeftPanel.tsx delete mode 100644 src/components/tools/rabbit/OverviewView.tsx delete mode 100644 src/components/tools/rabbit/QueueListView.tsx delete mode 100644 src/components/tools/rabbit/QueueView.tsx delete mode 100644 src/components/tools/rabbit/RabbitClient.tsx delete mode 100644 src/components/tools/rabbit/RabbitInfoModal.tsx delete mode 100644 src/components/tools/rabbit/RecentSuggestions.tsx delete mode 100644 src/components/tools/rabbit/RpcView.tsx delete mode 100644 src/components/tools/rabbit/api.ts delete mode 100644 src/components/tools/rabbit/api_sdk.ts delete mode 100644 src/components/tools/rabbit/consumerStore.test.ts delete mode 100644 src/components/tools/rabbit/consumerStore.ts delete mode 100644 src/components/tools/rabbit/format.ts delete mode 100644 src/components/tools/rabbit/inputHistoryStore.ts delete mode 100644 src/components/tools/rabbit/knownNamesStore.ts delete mode 100644 src/components/tools/rabbit/mcpBridge.test.ts delete mode 100644 src/components/tools/rabbit/mcpBridge.ts delete mode 100644 src/components/tools/rabbit/mcpRuntimeContext.tsx delete mode 100644 src/components/tools/rabbit/types.test.ts delete mode 100644 src/components/tools/rabbit/types.ts delete mode 100644 src/components/tools/rabbit/useRabbitData.ts delete mode 100644 src/components/tools/rabbit/useRabbitState.ts delete mode 100644 src/components/tools/redis/AdminView.tsx delete mode 100644 src/components/tools/redis/CliConsole.tsx delete mode 100644 src/components/tools/redis/ConfirmDialog.tsx delete mode 100644 src/components/tools/redis/ConnectionForm.tsx delete mode 100644 src/components/tools/redis/KeyDetailView.tsx delete mode 100644 src/components/tools/redis/KeysListView.tsx delete mode 100644 src/components/tools/redis/LeftPanel.tsx delete mode 100644 src/components/tools/redis/MathConfirmDialog.tsx delete mode 100644 src/components/tools/redis/OverviewView.tsx delete mode 100644 src/components/tools/redis/PubSubView.test.tsx delete mode 100644 src/components/tools/redis/PubSubView.tsx delete mode 100644 src/components/tools/redis/RedisClient.tsx delete mode 100644 src/components/tools/redis/api.ts delete mode 100644 src/components/tools/redis/commands.ts delete mode 100644 src/components/tools/redis/format.ts delete mode 100644 src/components/tools/redis/mcpBridge.test.ts delete mode 100644 src/components/tools/redis/mcpBridge.ts delete mode 100644 src/components/tools/redis/mcpRuntimeContext.tsx delete mode 100644 src/components/tools/redis/types.test.ts delete mode 100644 src/components/tools/redis/types.ts delete mode 100644 src/components/tools/redis/useRedisData.ts delete mode 100644 src/components/tools/redis/useRedisState.ts delete mode 100644 src/plugins/container-manager/plugin.ts delete mode 100644 src/plugins/rabbit-client/plugin.ts delete mode 100644 src/plugins/redis-client/plugin.ts create mode 100644 src/styles/externalPluginClassnamesSafelist.ts delete mode 100644 testing/rabbitmq/Dockerfile delete mode 100644 testing/rabbitmq/README.md delete mode 100644 testing/rabbitmq/common.py delete mode 100644 testing/rabbitmq/consumer.py delete mode 100644 testing/rabbitmq/definitions.json delete mode 100644 testing/rabbitmq/docker-compose.yml delete mode 100644 testing/rabbitmq/fanout_publisher.py delete mode 100644 testing/rabbitmq/producer.py delete mode 100644 testing/rabbitmq/rabbitmq.conf delete mode 100644 testing/rabbitmq/requirements.txt delete mode 100644 testing/rabbitmq/rpc_client.py delete mode 100644 testing/rabbitmq/rpc_server.py delete mode 100644 testing/rabbitmq/spring-rpc/Dockerfile delete mode 100644 testing/rabbitmq/spring-rpc/pom.xml delete mode 100644 testing/rabbitmq/spring-rpc/src/main/java/com/example/rpc/Application.java delete mode 100644 testing/rabbitmq/spring-rpc/src/main/java/com/example/rpc/RabbitConfig.java delete mode 100644 testing/rabbitmq/spring-rpc/src/main/java/com/example/rpc/RpcListener.java delete mode 100644 testing/rabbitmq/spring-rpc/src/main/java/com/example/rpc/RpcSelfTest.java delete mode 100644 testing/rabbitmq/spring-rpc/src/main/resources/application.yml delete mode 100644 testing/rabbitmq/topic_publisher.py 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/docs/ai/CLAUDE.md b/docs/ai/CLAUDE.md index 2451f602..4dafe32e 100644 --- a/docs/ai/CLAUDE.md +++ b/docs/ai/CLAUDE.md @@ -4,7 +4,15 @@ ## 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, Kafka explorer, API client, mock server, etc.). + +> **Redis/RabbitMQ/Container Manager 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`. 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. **Key Technologies:** - **Frontend**: React 18, TypeScript, Vite 8 (Rolldown bundler) @@ -108,7 +116,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/optional-broker-plugins.md b/docs/decisions/architecture/optional-broker-plugins.md new file mode 100644 index 00000000..75439455 --- /dev/null +++ b/docs/decisions/architecture/optional-broker-plugins.md @@ -0,0 +1,79 @@ +# Redis/RabbitMQ/Container Manager 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. + +## Quyết định + +Chuyển UI + sidecar của cả ba 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). + +Kafka Explorer KHÔNG chuyển theo: 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 một cuộc viết lại Rust lớn hơn hẳn (dựng sidecar +mới nói JSONL). Để sau, theo dõi riêng nếu cầ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 ba 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` vẫn khai đủ tool MCP cho cả ba — lời gọi chỉ không + được trả lời cho tới khi người dùng cài plugin và mở đúng route của nó. +- **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..9aeed6b7 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 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..40f26844 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]] @@ -944,16 +696,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 +704,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer 0.12.1", "crypto-common 0.2.2", - "inout 0.2.2", + "inout", ] [[package]] @@ -985,33 +727,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 +740,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 +752,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 +789,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" @@ -1194,12 +899,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 +908,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" @@ -1296,7 +986,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher 0.5.2", + "cipher", ] [[package]] @@ -1377,12 +1067,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "data-encoding" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" - [[package]] name = "data-url" version = "0.3.2" @@ -1431,44 +1115,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 +1187,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,24 +1194,14 @@ 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", @@ -1582,7 +1209,7 @@ dependencies = [ "rskafka", "serde", "serde_json", - "sha1 0.11.0", + "sha1", "sha2 0.11.0", "sysinfo", "tauri", @@ -1618,7 +1245,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", ] @@ -1939,12 +1566,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 +1576,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 +1630,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 +1702,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" @@ -2539,76 +2132,6 @@ 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" @@ -2730,21 +2253,7 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", - "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", + "want", ] [[package]] @@ -2788,21 +2297,6 @@ dependencies = [ "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", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2993,23 +2487,13 @@ 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", ] @@ -3019,27 +2503,11 @@ 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" @@ -3300,30 +2768,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" @@ -3542,23 +2986,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 +3032,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" @@ -3756,7 +3177,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 +3195,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 +3246,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 +3512,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", ] @@ -4177,29 +3578,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" @@ -4273,16 +3651,6 @@ 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]] @@ -4372,36 +3740,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" @@ -4517,17 +3855,6 @@ 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" @@ -4776,43 +4103,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 +4254,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" @@ -5129,15 +4413,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" @@ -5157,7 +4432,6 @@ version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ - "aws-lc-rs", "log", "once_cell", "ring", @@ -5167,21 +4441,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 +4496,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 +4513,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 +4602,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 +4866,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 +4877,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" @@ -5815,25 +5036,6 @@ dependencies = [ "system-deps", ] -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -6006,12 +5208,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" @@ -6486,19 +5682,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 +5866,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" @@ -7441,12 +6613,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" @@ -8102,34 +7268,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 +7278,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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ead789dd..5142eeda 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -41,20 +41,7 @@ sysinfo = "0.39" axum = "0.8" rhai = "1" 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: @@ -82,20 +69,6 @@ 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" - [features] default = ["custom-protocol"] custom-protocol = ["tauri/custom-protocol"] diff --git a/src-tauri/src/artifact_installer.rs b/src-tauri/src/artifact_installer.rs index ec4fbaf3..6926d44d 100644 --- a/src-tauri/src/artifact_installer.rs +++ b/src-tauri/src/artifact_installer.rs @@ -79,6 +79,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 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/service_host.rs b/src-tauri/src/service_host.rs index 7ec10c68..8f5a90e9 100644 --- a/src-tauri/src/service_host.rs +++ b/src-tauri/src/service_host.rs @@ -56,20 +56,39 @@ pub const SERVICE_PROTOCOL: u32 = 1; 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. +/// +/// `devtool-svc-redis`/`-container`/`-rabbit`: nguồn đã chuyển sang repo +/// `developer-desktop-util-plugin` (không phải ai cũng cần Redis/Docker/ +/// RabbitMQ) — 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"]; +/// 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. +const BUNDLED_SERVICES: &[&str] = &["devtool-svc-echo"]; + #[derive(Debug, Deserialize)] pub struct ServiceRequest { pub protocol: u32, @@ -351,7 +370,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 +590,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.conf.json b/src-tauri/tauri.conf.json index 5810e258..bd06654b 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", 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..d8151a6a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,10 +37,7 @@ 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'; @@ -1251,23 +1248,17 @@ function App() { - - - - - - - - - - - - - - - - - + + + + + + + + + + + diff --git a/src/components/McpBackgroundBridge.tsx b/src/components/McpBackgroundBridge.tsx index dcd077ac..26303dbd 100644 --- a/src/components/McpBackgroundBridge.tsx +++ b/src/components/McpBackgroundBridge.tsx @@ -4,9 +4,17 @@ // 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. +// mockserver/mcpRuntimeContext.tsx, and kafka/mcpRuntimeContext.tsx for why +// that sharing matters. +// +// redis-client/rabbit-client/container-manager 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; 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,8 @@ 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 +41,11 @@ 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/SettingsPlugins.test.tsx b/src/components/SettingsPlugins.test.tsx index 36ea8cf2..3e8e3373 100644 --- a/src/components/SettingsPlugins.test.tsx +++ b/src/components/SettingsPlugins.test.tsx @@ -44,12 +44,12 @@ 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 kafka = PLUGINS.find((p) => p.id === 'kafka-explorer')!; // 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="kafka-explorer"]')!; expect(row).toBeTruthy(); - for (const perm of redis.permissions) { + for (const perm of kafka.permissions) { expect(within(row).getByText(perm), perm).toBeTruthy(); } expect(within(row).getByText(/mcp_respond/)).toBeTruthy(); 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/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.

-
-
- -