Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64751d634b | ||
|
|
38ff20ca80 | ||
|
|
ea376bd0ca | ||
|
|
de3d679a36 | ||
|
|
5690442eb3 | ||
|
|
c4bb1d1d64 | ||
|
|
1559739792 | ||
|
|
1ec954b424 | ||
|
|
e03b72ded2 | ||
|
|
07f422eb99 | ||
|
|
5c7750ca7e | ||
|
|
b939ec258e | ||
|
|
091ffb06a9 | ||
|
|
b91b00ee6b | ||
|
|
d350b02f78 | ||
|
|
d8f43a7138 | ||
|
|
34c3431483 | ||
|
|
faeaa238e9 | ||
|
|
5be4731c4d | ||
|
|
ebc1ac7598 | ||
|
|
570b211e71 | ||
|
|
e61992a911 | ||
|
|
0e79f2193d |
No files matched your search
@@ -9,6 +9,12 @@
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Electron、React、xterm.js で構築された機能豊富な SSH ワークスペース。<br/>
|
||||
🔥 内蔵 AI Agent · 分割ターミナル · Vault ビュー · SFTP ワークフロー · カスタムテーマ — すべてが一つに。
|
||||
@@ -40,7 +46,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
|
||||
---
|
||||
|
||||
@@ -63,13 +69,13 @@
|
||||
|
||||
Catty Agent にサーバーの健全性チェックを依頼すると、適切なコマンドを実行し、出力を分析して数秒で明確なサマリーを提示します。
|
||||
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
|
||||
|
||||
#### 🚀 マルチホスト — Docker Swarm クラスターのセットアップ
|
||||
|
||||
1 つの会話で 2 台のサーバーをまたいで Docker Swarm クラスターをオーケストレーションする Catty Agent をご覧ください。初期化、トークン交換、ノード参加まですべてこなします — あなたは望む結果を伝えるだけ。
|
||||
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
|
||||
|
||||
---
|
||||
|
||||
@@ -150,34 +156,34 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
|
||||
|
||||
メインウィンドウは、長時間の SSH 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
|
||||
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
|
||||
<a name="vault-ビュー"></a>
|
||||
## Vault ビュー
|
||||
|
||||
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
|
||||
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
|
||||
<a name="モニタリングビュー"></a>
|
||||
## モニタリングビュー
|
||||
|
||||
CPU、メモリ、ディスク、ネットワーク、レイテンシーを確認でき、対応する環境では GPU の詳細も表示されます。表示する列の選択、列幅や更新設定の調整ができ、監視対象のホストから直接ターミナルを開くこともできます。
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
|
||||
|
||||
<a name="分割ターミナル"></a>
|
||||
## 分割ターミナル
|
||||
|
||||
分割ペインで複数のサーバー/タスクを同時に扱えます(例:デプロイ + ログ + 監視)。
|
||||
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
|
||||
|
||||
---
|
||||
@@ -69,14 +69,14 @@
|
||||
|
||||
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
|
||||
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
|
||||
|
||||
|
||||
#### 🚀 Multi-Host — Docker Swarm Cluster Setup
|
||||
|
||||
Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one conversation. It handles the init, token exchange, and node joining — you just tell it what you want.
|
||||
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
|
||||
|
||||
|
||||
---
|
||||
@@ -159,10 +159,10 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
|
||||
The main window is designed for long-running SSH workflows: quick access to sessions, navigation, and core tools in one place.
|
||||
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
|
||||
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
|
||||
|
||||
|
||||
@@ -171,10 +171,10 @@ The main window is designed for long-running SSH workflows: quick access to sess
|
||||
|
||||
Organize and navigate your hosts using the view that best fits the moment: grid for overview, list for scanning, tree for structure.
|
||||
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
|
||||
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
|
||||
|
||||
|
||||
@@ -183,9 +183,9 @@ Organize and navigate your hosts using the view that best fits the moment: grid
|
||||
|
||||
Inspect CPU, memory, disk, network, and latency readings, with GPU details where available. Choose visible columns, resize them, adjust refresh controls, and open a terminal directly from a monitored host.
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
|
||||
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ Inspect CPU, memory, disk, network, and latency readings, with GPU details where
|
||||
|
||||
Split panes help you monitor multiple servers/services at the same time (deploy + logs + metrics) without juggling windows.
|
||||
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
一个基于 Electron、React 和 xterm.js 构建的功能丰富的 SSH 工作空间。<br/>
|
||||
🔥 内置 AI Agent · 分屏终端 · Vault 多视图 · SFTP 工作流 · 自定义主题 —— 一应俱全。
|
||||
@@ -40,7 +46,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
|
||||
---
|
||||
|
||||
@@ -63,13 +69,13 @@
|
||||
|
||||
让 Catty Agent 检查服务器健康状态,它会自动运行合适的命令、分析输出并给出清晰总结 —— 几秒内完成。
|
||||
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
|
||||
|
||||
#### 🚀 多主机 —— Docker Swarm 集群搭建
|
||||
|
||||
看 Catty Agent 在一次对话中跨两台服务器编排 Docker Swarm 集群。它负责初始化、令牌交换和节点加入 —— 你只需说出想要的结果。
|
||||
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
|
||||
|
||||
---
|
||||
|
||||
@@ -150,34 +156,34 @@
|
||||
|
||||
主界面围绕长期 SSH 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本。
|
||||
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
|
||||
<a name="vault-视图"></a>
|
||||
## Vault 视图
|
||||
|
||||
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
|
||||
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
|
||||
<a name="监控视图"></a>
|
||||
## 监控视图
|
||||
|
||||
查看 CPU、内存、磁盘、网络和延迟数据;支持的环境还可显示 GPU 详情。你可以选择显示的列、调整列宽和刷新设置,并直接从受监控的主机打开终端。
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
|
||||
|
||||
<a name="分屏终端"></a>
|
||||
## 分屏终端
|
||||
|
||||
分屏适合同时处理多个任务(例如部署 + 日志 + 排障),不用频繁切换窗口。
|
||||
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
以 Electron、React 與 xterm.js 打造,介面精緻、功能完整的 SSH 工作區。<br/>
|
||||
🔥 內建 AI Agent · 分割終端 · Vault 檢視 · SFTP 工作流 · 自訂主題 —— 全部整合在一起。
|
||||
@@ -40,7 +46,7 @@
|
||||
|
||||
---
|
||||
|
||||
<img width="3358" height="1984" alt="image" src="https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00" />
|
||||
<img width="3358" height="1984" alt="image" src="docs/assets/readme/overview.webp" />
|
||||
|
||||
---
|
||||
|
||||
@@ -63,13 +69,13 @@
|
||||
|
||||
請 Catty Agent 檢查伺服器健康狀況,它會自動執行合適的指令、分析輸出,並在幾秒內給你一份清楚的總結。
|
||||
|
||||
<img width="3302" height="1978" alt="image" src="https://github.com/user-attachments/assets/3ec30145-d226-4e26-834b-52ee75b5e205" />
|
||||
<img width="3302" height="1978" alt="image" src="docs/assets/readme/catty-single-host.webp" />
|
||||
|
||||
#### 🚀 多主機 —— 建立 Docker Swarm 叢集
|
||||
|
||||
看 Catty Agent 在一次對話裡,跨兩台伺服器編排出一個 Docker Swarm 叢集。初始化、權杖交換、節點加入都由它處理 —— 你只要說出你想要的結果。
|
||||
|
||||
<img width="3294" height="1974" alt="image" src="https://github.com/user-attachments/assets/ca17e857-6163-4067-a615-cacdfd5be4fc" />
|
||||
<img width="3294" height="1974" alt="image" src="docs/assets/readme/catty-multi-host.webp" />
|
||||
|
||||
---
|
||||
|
||||
@@ -150,34 +156,34 @@
|
||||
|
||||
主視窗是為了長時間的 SSH 工作流程而設計:工作階段、導覽與常用工具都集中在同一個地方。
|
||||
|
||||
<img width="3290" height="1988" alt="image" src="https://github.com/user-attachments/assets/ab3afd3e-a819-46fc-9251-40acb592ba69" />
|
||||
<img width="3290" height="1988" alt="image" src="docs/assets/readme/main-window.webp" />
|
||||
|
||||
<img width="3324" height="1992" alt="image" src="https://github.com/user-attachments/assets/7c7f6e7f-fb20-4574-8e7f-74397682b873" />
|
||||
<img width="3324" height="1992" alt="image" src="docs/assets/readme/main-window-terminal.webp" />
|
||||
|
||||
<a name="vault-檢視"></a>
|
||||
## Vault 檢視
|
||||
|
||||
用最適合當下情境的方式整理與瀏覽主機:網格看全局、清單快速掃視、樹狀理層級。
|
||||
|
||||
<img width="3292" height="1984" alt="image" src="https://github.com/user-attachments/assets/29642143-3fd6-4693-a30c-b7f656053ca3" />
|
||||
<img width="3292" height="1984" alt="image" src="docs/assets/readme/vault-grid.webp" />
|
||||
|
||||
<img width="3322" height="1998" alt="image" src="https://github.com/user-attachments/assets/6041efe2-607e-4314-838c-eba3db4c852c" />
|
||||
<img width="3322" height="1998" alt="image" src="docs/assets/readme/vault-list.webp" />
|
||||
|
||||
<a name="監控檢視"></a>
|
||||
## 監控檢視
|
||||
|
||||
查看 CPU、記憶體、磁碟、網路與延遲資料;支援的環境還可顯示 GPU 詳情。你可以選擇顯示的欄位、調整欄寬與重新整理設定,並直接從受監控的主機開啟終端機。
|
||||
|
||||
<img width="3364" height="1982" alt="image" src="https://github.com/user-attachments/assets/d0f495b5-25c8-494f-bb9c-8c20b0a921b6" />
|
||||
<img width="3364" height="1982" alt="image" src="docs/assets/readme/monitoring.webp" />
|
||||
|
||||
<img width="3362" height="1980" alt="image" src="https://github.com/user-attachments/assets/245c234e-2b22-43f2-a4fd-8057d229c56f" />
|
||||
<img width="3362" height="1980" alt="image" src="docs/assets/readme/monitoring-columns.webp" />
|
||||
|
||||
<a name="分割終端"></a>
|
||||
## 分割終端
|
||||
|
||||
分割窗格讓你同時盯著多台伺服器或多個服務(部署 + 日誌 + 監控指標),不用一直切視窗。
|
||||
|
||||
<img width="1560" height="871" alt="split" src="https://github.com/user-attachments/assets/c93a8523-6256-4bb3-8b6e-d599831f2f9f" />
|
||||
<img width="1560" height="871" alt="split" src="docs/assets/readme/split-terminals.webp" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { useAppStartupEffects } from "./useAppStartupEffects.ts";
|
||||
import { sftpTransferCenterStore } from "../state/sftpTransferCenterStore.ts";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge.ts";
|
||||
|
||||
for (const accept of [false, true]) {
|
||||
test(`app Quit with an unfinished transfer: ${accept ? "confirm quits" : "declining reports \"transfers\" before resuming only transferring and queued tasks"}`, async (t) => {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
const g = globalThis as unknown as { window?: { confirm?: (m: string) => boolean } };
|
||||
const hadWindow = "window" in g; const previousConfirm = g.window?.confirm;
|
||||
if (!g.window) g.window = {};
|
||||
g.window.confirm = () => accept;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let releaseResume!: () => void;
|
||||
const resumeGate = new Promise<void>((resolve) => { releaseResume = resolve; });
|
||||
t.after(async () => {
|
||||
releaseResume();
|
||||
await act(async () => renderer?.unmount());
|
||||
if (hadWindow && g.window) g.window.confirm = previousConfirm; else delete g.window;
|
||||
if (previous) Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", previous);
|
||||
else Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT");
|
||||
});
|
||||
const calls: string[] = [];
|
||||
const reports: Array<[boolean, string | undefined]> = [];
|
||||
let resumeSettled = false;
|
||||
let query!: () => Promise<void>;
|
||||
t.mock.method(sftpTransferCenterStore, "getSnapshot", () => ({
|
||||
tasks: [
|
||||
{ id: "running", status: "transferring" },
|
||||
{ id: "queued", status: "queued" },
|
||||
{ id: "already-paused", status: "paused" },
|
||||
{ id: "pausing", status: "pausing" },
|
||||
{ id: "interrupted", status: "interrupted" },
|
||||
{ id: "child", parentTaskId: "running", status: "transferring" },
|
||||
],
|
||||
}) as unknown as ReturnType<typeof sftpTransferCenterStore.getSnapshot>);
|
||||
t.mock.method(sftpTransferCenterStore, "pause", async (id: string) => { calls.push(`pause:${id}`); });
|
||||
t.mock.method(sftpTransferCenterStore, "resume", async (id: string) => {
|
||||
calls.push(`resume:${id}`);
|
||||
await resumeGate;
|
||||
resumeSettled = true;
|
||||
});
|
||||
t.mock.method(netcattyBridge, "get", () => ({
|
||||
onCheckDirtyEditors: (callback: typeof query) => { query = callback; return () => {}; },
|
||||
reportDirtyEditorsResult: (dirty: boolean, reason?: string) => {
|
||||
calls.push("report");
|
||||
reports.push([dirty, reason]);
|
||||
},
|
||||
}));
|
||||
function Probe() {
|
||||
useAppStartupEffects({ enabled: false, updateState: {}, sessions: [], hosts: [], keys: [], identities: [], knownHosts: [], proxyProfiles: [], groupConfigs: [], workspaces: [], portForwardingRules: [], t: (key: string) => key });
|
||||
return null;
|
||||
}
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
const paused = [
|
||||
"pause:running",
|
||||
"pause:queued",
|
||||
"pause:already-paused",
|
||||
"pause:pausing",
|
||||
"pause:interrupted",
|
||||
];
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
query(),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("dirty check waited on resume")), 1000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
assert.equal(resumeSettled, false, "the dirty-check reply must not wait for resume");
|
||||
if (accept) {
|
||||
assert.deepEqual(reports, [[false, undefined]]);
|
||||
assert.deepEqual(calls, [...paused, "report"], "accepted quit leaves every prompted task paused");
|
||||
} else {
|
||||
assert.deepEqual(reports, [[true, "transfers"]], "main must be told the prompt was already answered");
|
||||
assert.deepEqual(calls, [...paused, "report", "resume:running", "resume:queued"],
|
||||
"report precedes resume, and only transferring/queued rows the prompt paused are resumed");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -356,30 +356,51 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
// on a slow quit and then quitting anyway after the timeout is
|
||||
// exactly the same outcome.
|
||||
let hasDirty = false;
|
||||
let reason: "editors" | "transfers" | undefined;
|
||||
// Captured before pause. Only transferring/queued rows were active
|
||||
// because of this prompt.
|
||||
let tasksToResume: { id: string }[] = [];
|
||||
try {
|
||||
// Pending handoffs retain source contents; accepted handoffs clear them.
|
||||
hasDirty = editorTabStore.getTabs().some((tab) => tab.content !== tab.baselineContent);
|
||||
if (hasDirty) toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
if (hasDirty) {
|
||||
reason = "editors";
|
||||
toast.warning(t('sftp.editor.quitBlockedByDirty'), 'SFTP');
|
||||
}
|
||||
if (!hasDirty) {
|
||||
const unfinishedTasks = sftpTransferCenterStore.getSnapshot().tasks.filter((task) => (
|
||||
!task.parentTaskId && !["completed", "failed", "cancelled"].includes(task.status)
|
||||
));
|
||||
if (unfinishedTasks.length > 0) {
|
||||
const resumable = unfinishedTasks.filter((task) => (
|
||||
task.status === "transferring" || task.status === "queued"
|
||||
));
|
||||
await Promise.allSettled(unfinishedTasks.map((task) => sftpTransferCenterStore.pause(task.id)));
|
||||
hasDirty = !window.confirm(t('sftp.transferCenter.quitConfirm', { count: unfinishedTasks.length }));
|
||||
if (hasDirty) {
|
||||
// The user chose to stay. Main must hear that before any resume:
|
||||
// resume can outlive the 5s dirty-check timeout, and a late reply
|
||||
// lets the app quit anyway. The synchronous dialog is also skipped
|
||||
// because this prompt was already answered.
|
||||
reason = "transfers";
|
||||
tasksToResume = resumable;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[App] dirty-editors check failed:', err);
|
||||
}
|
||||
try {
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty);
|
||||
bridge.reportDirtyEditorsResult?.(hasDirty, reason);
|
||||
} catch (err) {
|
||||
// Reporting itself shouldn't throw, but if the IPC bridge is in a
|
||||
// bad state we'd rather log than bubble out of the listener and
|
||||
// disable the quit guard for the rest of the session.
|
||||
console.error('[App] reportDirtyEditorsResult failed:', err);
|
||||
}
|
||||
if (tasksToResume.length > 0) {
|
||||
void Promise.allSettled(tasksToResume.map((task) => sftpTransferCenterStore.resume(task.id)));
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [enabled, t]);
|
||||
|
||||
@@ -289,6 +289,14 @@ export const enAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': 'e.g. /usr/local/bin/opencode',
|
||||
'ai.opencode.check': 'Check',
|
||||
'ai.opencode.resetPath': 'Reset',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 did not become ready in time. Confirm `opencode serve` works in a terminal, then retry.',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 stopped before the chat could start.',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 refused the connection. Retry the chat.',
|
||||
'ai.opencode.v2.unavailable': 'OpenCode 2 could not be reached. Check that it is installed and try again.',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 could not finish this turn.',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 returned an empty response. Run `opencode` in a terminal to configure authentication and models.',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 could not apply the Netcatty conversation context.',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 could not attach an MCP tool. The chat will continue without it.',
|
||||
|
||||
// AI MiMo Code (Xiaomi MiMo Code CLI — OpenCode fork)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -376,6 +384,22 @@ export const enAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'External MCP help',
|
||||
'ai.externalMcp.security': 'Security',
|
||||
'ai.externalMcp.security.description': 'Listens on 127.0.0.1 with a rotating token, reuses AI Permission Mode for writes, and removes discovery when disabled. No OAuth — this is a local desktop bridge.',
|
||||
"ai.privacy.title": "AI privacy",
|
||||
"ai.privacy.description": "Filters known sensitive text patterns in Netcatty-managed context and tool results for Catty, integrated agents and external MCP clients. Off by default. It cannot detect every secret. Binary tool responses are blocked while enabled.",
|
||||
"ai.privacy.boundary": "Only Netcatty-managed data and tools are covered. Third-party agents' native tools, direct file access and other MCP servers remain outside this boundary.",
|
||||
"ai.privacy.enable": "Enable text redaction",
|
||||
"ai.privacy.privateIps": "Redact private IP addresses",
|
||||
"ai.privacy.privateIps.description": "Also hide private and local IP addresses in returned text.",
|
||||
"ai.privacy.cidrs": "Additional IP ranges (CIDR)",
|
||||
"ai.privacy.cidrs.description": "One IPv4 or IPv6 CIDR per line. Leave empty to use only built-in rules.",
|
||||
"ai.privacy.patterns": "Custom regular expressions",
|
||||
"ai.privacy.patterns.description": "One RE2-compatible pattern per line, without / delimiters or flags. Unsupported or unsafe patterns are rejected.",
|
||||
"ai.privacy.connectionScope": "Placeholders stay stable within one managed conversation or external MCP connection. Original values are restored only through Netcatty-managed tool calls in that same scope.",
|
||||
"ai.privacy.reconnect": "Apply changes, then start a new managed conversation and reconnect external MCP clients. Existing conversations and connections must not reuse placeholders.",
|
||||
"ai.privacy.apply": "Apply",
|
||||
"ai.privacy.applying": "Applying...",
|
||||
"ai.privacy.applied": "AI privacy settings applied. Start a new managed conversation and reconnect external MCP clients.",
|
||||
"ai.privacy.failed": "AI privacy settings were not applied.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Serve the same External MCP tools over Streamable HTTP for clients that cannot launch the stdio CLI, such as Codex inside WSL. Off by default.',
|
||||
'ai.externalMcp.http.enable': 'Enable HTTP',
|
||||
|
||||
@@ -864,8 +864,8 @@ Highlight the focused split pane:
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux session refresh',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'How often to refresh the tmux session list.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh the Docker container list.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh container and pod lists.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker stats refresh',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'How often to refresh Docker container CPU/memory/network stats.',
|
||||
'settings.terminal.serverStats.show': 'Show Server Stats',
|
||||
@@ -1180,6 +1180,7 @@ Highlight the focused split pane:
|
||||
'vault.hosts.moveSelected': 'Move to group ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Move the selected hosts to an existing group, or leave them ungrouped. Selected groups are not moved.',
|
||||
'vault.hosts.moveMultiple.success': 'Moved {count} hosts to {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Host name is required.',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
|
||||
@@ -26,6 +26,11 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Failed to load containers',
|
||||
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
|
||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||
'systemManager.errors.loadKubernetes': 'Failed to load Kubernetes resources',
|
||||
'systemManager.errors.loadPods': 'Failed to load pods',
|
||||
'systemManager.errors.nerdctlUnreachable': "nerdctl can't reach containerd",
|
||||
'systemManager.errors.clusterUnreachable': "Can't reach the cluster for context {{context}}",
|
||||
'systemManager.errors.clusterUnreachableGeneric': "Can't reach the cluster",
|
||||
'systemManager.errors.loadOverview': 'Failed to load system overview',
|
||||
'systemManager.errors.loadGpu': 'Failed to load GPU / NPU stats',
|
||||
'systemManager.errors.loadPorts': 'Failed to load listening ports',
|
||||
@@ -132,7 +137,7 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)',
|
||||
'systemManager.tmux.noWindows': 'No windows',
|
||||
'systemManager.tmux.unavailable': 'tmux is not available on this host',
|
||||
'systemManager.docker.unavailable': 'Docker is not available on this host',
|
||||
'systemManager.docker.unavailable': 'No container runtime (Docker, Podman, nerdctl, or kubectl) was detected on this host',
|
||||
'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none',
|
||||
'systemManager.tmux.lastCommand': 'last command: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No panes',
|
||||
@@ -194,6 +199,36 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} image(s)',
|
||||
'systemManager.docker.start': 'Start',
|
||||
'systemManager.docker.stop': 'Stop',
|
||||
'systemManager.runtime.switch': 'Container runtime',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Context',
|
||||
'systemManager.k8s.namespace': 'Namespace',
|
||||
'systemManager.k8s.empty': 'No pods found',
|
||||
'systemManager.k8s.search': 'Search pods…',
|
||||
'systemManager.k8s.meta': '{{count}} pod(s)',
|
||||
'systemManager.k8s.describe': 'Describe',
|
||||
'systemManager.k8s.delete': 'Delete pod',
|
||||
'systemManager.k8s.confirmDelete': 'Delete pod "{{name}}"?',
|
||||
'systemManager.k8s.restarts': '{{count}} restarts',
|
||||
'systemManager.k8s.chooseContainer': 'Choose a container',
|
||||
'systemManager.k8s.noContext': 'No Kubernetes context on this host',
|
||||
'systemManager.k8s.status.Running': 'Running',
|
||||
'systemManager.k8s.status.Pending': 'Pending',
|
||||
'systemManager.k8s.status.Completed': 'Completed',
|
||||
'systemManager.k8s.status.Succeeded': 'Succeeded',
|
||||
'systemManager.k8s.status.Failed': 'Failed',
|
||||
'systemManager.k8s.status.Unknown': 'Unknown',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': 'CrashLoopBackOff',
|
||||
'systemManager.k8s.status.ContainerCreating': 'ContainerCreating',
|
||||
'systemManager.k8s.status.Terminating': 'Terminating',
|
||||
'systemManager.k8s.status.ImagePullBackOff': 'ImagePullBackOff',
|
||||
'systemManager.k8s.status.ErrImagePull': 'ErrImagePull',
|
||||
'systemManager.k8s.status.Error': 'Error',
|
||||
'systemManager.k8s.status.Evicted': 'Evicted',
|
||||
'systemManager.k8s.status.OOMKilled': 'OOMKilled',
|
||||
|
||||
'systemManager.ports.unavailable': 'No listening-port tools (ss / netstat) detected on this host.',
|
||||
'systemManager.ports.loading': 'Loading listening ports…',
|
||||
|
||||
@@ -367,6 +367,22 @@ export const esAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
|
||||
'ai.externalMcp.security': 'Seguridad',
|
||||
'ai.externalMcp.security.description': 'Escucha en 127.0.0.1 con un token rotativo, reutiliza el modo de permisos de IA para las escrituras y elimina el descubrimiento al desactivarse. Sin OAuth: este es un puente local de escritorio.',
|
||||
"ai.privacy.title": "Privacidad de IA",
|
||||
"ai.privacy.description": "Filtra patrones de texto sensibles conocidos en el contexto y los resultados de herramientas gestionados por Netcatty para Catty, agentes integrados y clientes MCP externos. Desactivado de forma predeterminada. No detecta todos los secretos. Las respuestas binarias de herramientas se bloquean mientras está activo.",
|
||||
"ai.privacy.boundary": "Solo cubre los datos y las herramientas gestionados por Netcatty. Las herramientas nativas de agentes de terceros, su acceso directo a archivos y otros servidores MCP quedan fuera de esta protección.",
|
||||
"ai.privacy.enable": "Activar ocultación de texto",
|
||||
"ai.privacy.privateIps": "Ocultar direcciones IP privadas",
|
||||
"ai.privacy.privateIps.description": "Oculta también las direcciones IP privadas y locales en el texto devuelto.",
|
||||
"ai.privacy.cidrs": "Rangos IP adicionales (CIDR)",
|
||||
"ai.privacy.cidrs.description": "Un CIDR IPv4 o IPv6 por línea. Déjalo vacío para usar solo las reglas integradas.",
|
||||
"ai.privacy.patterns": "Expresiones regulares personalizadas",
|
||||
"ai.privacy.patterns.description": "Un patrón compatible con RE2 por línea, sin delimitadores / ni indicadores. Los patrones incompatibles o inseguros se rechazan.",
|
||||
"ai.privacy.connectionScope": "Los marcadores son estables dentro de una conversación gestionada o una conexión MCP externa. Los valores originales solo se restauran mediante llamadas a herramientas gestionadas por Netcatty dentro del mismo ámbito.",
|
||||
"ai.privacy.reconnect": "Aplica los cambios, inicia una nueva conversación gestionada y vuelve a conectar los clientes MCP externos. Las conversaciones y conexiones existentes no deben reutilizar marcadores.",
|
||||
"ai.privacy.apply": "Aplicar",
|
||||
"ai.privacy.applying": "Aplicando...",
|
||||
"ai.privacy.applied": "Configuración de privacidad de IA aplicada. Inicia una nueva conversación gestionada y vuelve a conectar los clientes MCP externos.",
|
||||
"ai.privacy.failed": "No se aplicó la configuración de privacidad de IA.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Expone las mismas herramientas de MCP externo por Streamable HTTP para clientes que no pueden iniciar el CLI stdio, como Codex dentro de WSL. Desactivado de forma predeterminada.',
|
||||
'ai.externalMcp.http.enable': 'Activar HTTP',
|
||||
|
||||
@@ -779,8 +779,8 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de procesos en el panel lateral del administrador del sistema.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'Actualización de sesiones tmux',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de sesiones tmux.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Actualización de la lista de contenedores Docker',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de contenedores Docker.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Actualización de la lista de contenedores',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar las listas de contenedores y pods.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Actualización de estadísticas Docker',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Con qué frecuencia actualizar las estadísticas de CPU/memoria/red de los contenedores Docker.',
|
||||
'settings.terminal.serverStats.show': 'Mostrar estadísticas del servidor',
|
||||
@@ -1139,6 +1139,7 @@ Resalta el panel dividido enfocado:
|
||||
'vault.hosts.moveSelected': 'Mover al grupo ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Mueve los hosts seleccionados a un grupo existente o los deja sin grupo. Los grupos seleccionados no se mueven.',
|
||||
'vault.hosts.moveMultiple.success': 'Se movieron {count} hosts a {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Se movió {host} a {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Se requiere el nombre del host.',
|
||||
'vault.hosts.empty.title': 'Configura tus hosts',
|
||||
|
||||
@@ -26,6 +26,8 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'No se pudieron cargar los contenedores',
|
||||
'systemManager.errors.loadDockerStats': 'No se pudieron cargar las estadísticas de los contenedores',
|
||||
'systemManager.errors.loadDockerImages': 'No se pudieron cargar las imágenes',
|
||||
'systemManager.errors.loadKubernetes': 'No se pudieron cargar los recursos de Kubernetes',
|
||||
'systemManager.errors.loadPods': 'No se pudieron cargar los pods',
|
||||
'systemManager.errors.loadOverview': 'No se pudo cargar el resumen del sistema',
|
||||
'systemManager.errors.loadGpu': 'No se pudieron cargar las estadísticas de GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
|
||||
@@ -132,7 +134,7 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Nombre de la ventana (opcional)',
|
||||
'systemManager.tmux.noWindows': 'No hay ventanas',
|
||||
'systemManager.tmux.unavailable': 'tmux no está disponible en este host',
|
||||
'systemManager.docker.unavailable': 'Docker no está disponible en este host',
|
||||
'systemManager.docker.unavailable': 'No se detectó ningún runtime de contenedores (Docker, Podman, nerdctl o kubectl) en este host',
|
||||
'systemManager.tmux.windowsMismatch': 'La sesión reporta {{count}} ventana(s), pero list-windows no devolvió ninguna',
|
||||
'systemManager.tmux.lastCommand': 'último comando: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No hay paneles',
|
||||
@@ -194,6 +196,22 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} imagen(es)',
|
||||
'systemManager.docker.start': 'Iniciar',
|
||||
'systemManager.docker.stop': 'Detener',
|
||||
'systemManager.runtime.switch': 'Runtime de contenedores',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Contexto',
|
||||
'systemManager.k8s.namespace': 'Espacio de nombres',
|
||||
'systemManager.k8s.empty': 'No se encontraron pods',
|
||||
'systemManager.k8s.search': 'Buscar pods…',
|
||||
'systemManager.k8s.meta': '{{count}} pod(s)',
|
||||
'systemManager.k8s.describe': 'Describir',
|
||||
'systemManager.k8s.delete': 'Eliminar pod',
|
||||
'systemManager.k8s.confirmDelete': '¿Eliminar el pod "{{name}}"?',
|
||||
'systemManager.k8s.restarts': '{{count}} reinicios',
|
||||
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
|
||||
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
|
||||
|
||||
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
|
||||
'systemManager.ports.loading': 'Cargando puertos en escucha…',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import en from './en.ts';
|
||||
import es from './es.ts';
|
||||
import ru from './ru.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
const KEYS = [
|
||||
'title', 'description', 'boundary', 'enable', 'privateIps', 'privateIps.description',
|
||||
'cidrs', 'cidrs.description', 'patterns', 'patterns.description',
|
||||
'connectionScope', 'reconnect', 'apply', 'applying', 'applied', 'failed',
|
||||
];
|
||||
|
||||
test('Shared AI privacy settings are localized in every supported locale', () => {
|
||||
for (const [name, messages] of Object.entries({ en, es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW })) {
|
||||
assert.deepEqual(
|
||||
KEYS.filter((key) => !messages[`ai.privacy.${key}`]),
|
||||
[],
|
||||
`${name} is missing privacy settings labels`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import en from './en';
|
||||
import zhCN from './zh-CN';
|
||||
import zhTW from './zh-TW';
|
||||
|
||||
const KEYS = [
|
||||
'ai.opencode.v2.startupTimeout',
|
||||
'ai.opencode.v2.exited',
|
||||
'ai.opencode.v2.unauthorized',
|
||||
'ai.opencode.v2.unavailable',
|
||||
'ai.opencode.v2.sessionFailed',
|
||||
'ai.opencode.v2.empty',
|
||||
'ai.opencode.v2.contextSkipped',
|
||||
'ai.opencode.v2.mcpSkipped',
|
||||
];
|
||||
|
||||
test('OpenCode 2 strings exist in English, Simplified Chinese, and Taiwan Chinese', () => {
|
||||
for (const key of KEYS) {
|
||||
assert.equal(typeof en[key], 'string', `en missing ${key}`);
|
||||
assert.equal(typeof zhCN[key], 'string', `zh-CN missing ${key}`);
|
||||
assert.equal(typeof zhTW[key], 'string', `zh-TW missing ${key}`);
|
||||
assert.notEqual(en[key], zhCN[key]);
|
||||
assert.notEqual(en[key], zhTW[key]);
|
||||
assert.notEqual(zhCN[key], zhTW[key]);
|
||||
}
|
||||
});
|
||||
|
||||
test('Taiwan OpenCode 2 wording uses Taiwan terms', () => {
|
||||
const text = KEYS.map((key) => zhTW[key]).join('\n');
|
||||
assert.match(text, /終端機/);
|
||||
assert.match(text, /連線/);
|
||||
assert.match(text, /設定/);
|
||||
assert.match(text, /傳回/);
|
||||
assert.equal(text.includes('终端'), false);
|
||||
assert.equal(text.includes('连接'), false);
|
||||
assert.equal(text.includes('设置'), false);
|
||||
assert.equal(text.includes('返回'), false);
|
||||
});
|
||||
@@ -323,6 +323,22 @@ export const ruAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Справка External MCP',
|
||||
'ai.externalMcp.security': 'Безопасность',
|
||||
'ai.externalMcp.security.description': 'Слушает только 127.0.0.1 с ротацией token, использует AI Permission Mode для записи и удаляет discovery при отключении.',
|
||||
"ai.privacy.title": "Конфиденциальность ИИ",
|
||||
"ai.privacy.description": "Фильтрует известные шаблоны конфиденциального текста в контексте и результатах инструментов, управляемых Netcatty, для Catty, встроенных агентов и внешних MCP-клиентов. По умолчанию отключено. Не обнаруживает все секреты. При включении двоичные ответы инструментов блокируются.",
|
||||
"ai.privacy.boundary": "Защищаются только данные и инструменты, управляемые Netcatty. Собственные инструменты сторонних агентов, их прямой доступ к файлам и другие MCP-серверы не входят в эту область.",
|
||||
"ai.privacy.enable": "Включить скрытие текста",
|
||||
"ai.privacy.privateIps": "Скрывать частные IP-адреса",
|
||||
"ai.privacy.privateIps.description": "Также скрывает частные и локальные IP-адреса в возвращаемом тексте.",
|
||||
"ai.privacy.cidrs": "Дополнительные диапазоны IP (CIDR)",
|
||||
"ai.privacy.cidrs.description": "Один CIDR IPv4 или IPv6 на строку. Оставьте пустым для использования только встроенных правил.",
|
||||
"ai.privacy.patterns": "Пользовательские регулярные выражения",
|
||||
"ai.privacy.patterns.description": "Один RE2-совместимый шаблон на строку, без разделителей / и флагов. Неподдерживаемые или небезопасные шаблоны отклоняются.",
|
||||
"ai.privacy.connectionScope": "Маркеры постоянны в пределах одного управляемого диалога или внешнего MCP-подключения. Исходные значения восстанавливаются только через вызовы инструментов, управляемых Netcatty, в той же области.",
|
||||
"ai.privacy.reconnect": "Примените изменения, начните новый управляемый диалог и переподключите внешние MCP-клиенты. Существующие диалоги и подключения не должны повторно использовать маркеры.",
|
||||
"ai.privacy.apply": "Применить",
|
||||
"ai.privacy.applying": "Применение...",
|
||||
"ai.privacy.applied": "Настройки конфиденциальности ИИ применены. Начните новый управляемый диалог и переподключите внешние MCP-клиенты.",
|
||||
"ai.privacy.failed": "Настройки конфиденциальности ИИ не применены.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Отдает те же инструменты External MCP по Streamable HTTP клиентам, которые не могут запустить stdio CLI, например Codex в WSL. По умолчанию выключено.',
|
||||
'ai.externalMcp.http.enable': 'Включить HTTP',
|
||||
|
||||
@@ -817,8 +817,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'Обновление сессий tmux',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Как часто обновлять список сессий tmux.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров Docker',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять список контейнеров Docker.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять списки контейнеров и подов.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Обновление статистики Docker',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Как часто обновлять CPU/память/сеть контейнеров Docker.',
|
||||
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
|
||||
|
||||
@@ -26,6 +26,8 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
|
||||
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
|
||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||
'systemManager.errors.loadKubernetes': 'Не удалось загрузить ресурсы Kubernetes',
|
||||
'systemManager.errors.loadPods': 'Не удалось загрузить поды',
|
||||
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
|
||||
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
|
||||
@@ -132,7 +134,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)',
|
||||
'systemManager.tmux.noWindows': 'Нет окон',
|
||||
'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте',
|
||||
'systemManager.docker.unavailable': 'Docker недоступен на этом хосте',
|
||||
'systemManager.docker.unavailable': 'На этом хосте не обнаружена среда контейнеров (Docker, Podman, nerdctl или kubectl)',
|
||||
'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул',
|
||||
'systemManager.tmux.lastCommand': 'последняя команда: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'Нет панелей',
|
||||
@@ -194,6 +196,22 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} образов',
|
||||
'systemManager.docker.start': 'Запустить',
|
||||
'systemManager.docker.stop': 'Остановить',
|
||||
'systemManager.runtime.switch': 'Среда выполнения контейнеров',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Контекст',
|
||||
'systemManager.k8s.namespace': 'Пространство имён',
|
||||
'systemManager.k8s.empty': 'Поды не найдены',
|
||||
'systemManager.k8s.search': 'Поиск подов…',
|
||||
'systemManager.k8s.meta': '{{count}} под(ов)',
|
||||
'systemManager.k8s.describe': 'Описание',
|
||||
'systemManager.k8s.delete': 'Удалить под',
|
||||
'systemManager.k8s.confirmDelete': 'Удалить под «{{name}}»?',
|
||||
'systemManager.k8s.restarts': '{{count}} перезапусков',
|
||||
'systemManager.k8s.chooseContainer': 'Выберите контейнер',
|
||||
'systemManager.k8s.noContext': 'На этом хосте нет контекста Kubernetes',
|
||||
|
||||
'systemManager.ports.unavailable': 'На этом хосте не обнаружены инструменты портов (ss / netstat).',
|
||||
'systemManager.ports.loading': 'Загрузка слушающих портов…',
|
||||
|
||||
@@ -131,6 +131,7 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.hosts.moveSelected': 'Переместить в группу ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Переместить выбранные хосты в существующую группу или оставить без группы. Выбранные группы не перемещаются.',
|
||||
'vault.hosts.moveMultiple.success': 'Перемещено {count} хостов в {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} хостов',
|
||||
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
|
||||
'vault.hosts.empty.title': 'Настройте свои хосты',
|
||||
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
|
||||
|
||||
@@ -289,6 +289,14 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
|
||||
'ai.opencode.check': '检查',
|
||||
'ai.opencode.resetPath': '重置',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 未能及时就绪。请先在终端确认 `opencode serve` 可以启动,然后重试。',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 在对话开始前已退出。',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 拒绝了连接。请重试这次对话。',
|
||||
'ai.opencode.v2.unavailable': '无法连接到 OpenCode 2。请确认已安装后再试。',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 未能完成这一轮对话。',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 没有返回内容。请在终端运行 `opencode` 完成登录和模型配置。',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 无法应用 Netcatty 的对话上下文。',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 无法接入 MCP 工具,对话会继续,但没有该工具。',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -376,6 +384,22 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '仅监听 127.0.0.1,使用轮换 token,写操作复用 AI 权限模式,关闭时删除 discovery。不做 OAuth——这是本机桌面桥接。',
|
||||
"ai.privacy.title": "AI 隐私",
|
||||
"ai.privacy.description": "为 Catty、集成 Agent 和外部 MCP 客户端过滤 Netcatty 管理的上下文及工具结果中已知的敏感文本模式。默认关闭,无法识别所有机密。启用时会阻止二进制工具响应。",
|
||||
"ai.privacy.boundary": "仅覆盖 Netcatty 管理的数据和工具。第三方 Agent 的原生工具、直接读取的文件及其他 MCP 服务器不在保护范围内。",
|
||||
"ai.privacy.enable": "启用文本脱敏",
|
||||
"ai.privacy.privateIps": "隐藏私有 IP 地址",
|
||||
"ai.privacy.privateIps.description": "同时隐藏返回文本中的私有和本地 IP 地址。",
|
||||
"ai.privacy.cidrs": "附加 IP 网段(CIDR)",
|
||||
"ai.privacy.cidrs.description": "每行一个 IPv4 或 IPv6 CIDR。留空则仅使用内置规则。",
|
||||
"ai.privacy.patterns": "自定义正则表达式",
|
||||
"ai.privacy.patterns.description": "每行一个兼容 RE2 的模式,不含 / 分隔符或标志。不支持或不安全的模式会被拒绝。",
|
||||
"ai.privacy.connectionScope": "占位符在同一个受管对话或外部 MCP 连接内保持稳定。只有同一范围内的 Netcatty 受管工具调用才能还原原始值。",
|
||||
"ai.privacy.reconnect": "应用更改后,请新建受管对话并重新连接外部 MCP 客户端。已有对话和连接不得复用占位符。",
|
||||
"ai.privacy.apply": "应用",
|
||||
"ai.privacy.applying": "正在应用...",
|
||||
"ai.privacy.applied": "AI 隐私设置已应用。请新建受管对话并重新连接外部 MCP 客户端。",
|
||||
"ai.privacy.failed": "AI 隐私设置未应用。",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '用 Streamable HTTP 提供同一套对外 MCP 工具,方便无法启动 stdio CLI 的客户端,例如 WSL 里的 Codex。默认关闭。',
|
||||
'ai.externalMcp.http.enable': '启用 HTTP',
|
||||
|
||||
@@ -785,6 +785,7 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'vault.hosts.moveSelected': '移动到分组 ({count})',
|
||||
'vault.hosts.moveSelected.description': '将选中的主机移动到现有分组,也可移出分组。选中的分组本身不会移动。',
|
||||
'vault.hosts.moveMultiple.success': '已将 {count} 个主机移动到 {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} 个主机',
|
||||
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
|
||||
@@ -26,6 +26,11 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '加载容器列表失败',
|
||||
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
|
||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||
'systemManager.errors.loadKubernetes': '无法加载 Kubernetes 资源',
|
||||
'systemManager.errors.loadPods': '无法加载 Pod',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl 无法连接 containerd',
|
||||
'systemManager.errors.clusterUnreachable': '无法连接到上下文 {{context}} 的集群',
|
||||
'systemManager.errors.clusterUnreachableGeneric': '无法连接到集群',
|
||||
'systemManager.errors.loadOverview': '加载系统概览失败',
|
||||
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
|
||||
'systemManager.errors.loadPorts': '加载监听端口失败',
|
||||
@@ -132,7 +137,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': '窗口名称(可选)',
|
||||
'systemManager.tmux.noWindows': '没有窗口',
|
||||
'systemManager.tmux.unavailable': '此主机未检测到 tmux',
|
||||
'systemManager.docker.unavailable': '此主机未检测到 Docker',
|
||||
'systemManager.docker.unavailable': '此主机未检测到容器运行时(Docker、Podman、nerdctl 或 kubectl)',
|
||||
'systemManager.tmux.windowsMismatch': '会话显示有 {{count}} 个窗口,但 list-windows 未返回任何窗口',
|
||||
'systemManager.tmux.lastCommand': '最后执行的命令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '没有面板',
|
||||
@@ -194,6 +199,36 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} 个镜像',
|
||||
'systemManager.docker.start': '启动',
|
||||
'systemManager.docker.stop': '停止',
|
||||
'systemManager.runtime.switch': '容器运行时',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': '上下文',
|
||||
'systemManager.k8s.namespace': '命名空间',
|
||||
'systemManager.k8s.empty': '未找到 Pod',
|
||||
'systemManager.k8s.search': '搜索 Pod…',
|
||||
'systemManager.k8s.meta': '{{count}} 个 Pod',
|
||||
'systemManager.k8s.describe': '描述',
|
||||
'systemManager.k8s.delete': '删除 Pod',
|
||||
'systemManager.k8s.confirmDelete': '确定删除 Pod「{{name}}」?',
|
||||
'systemManager.k8s.restarts': '重启 {{count}} 次',
|
||||
'systemManager.k8s.chooseContainer': '选择容器',
|
||||
'systemManager.k8s.noContext': '此主机没有 Kubernetes 上下文',
|
||||
'systemManager.k8s.status.Running': '运行中',
|
||||
'systemManager.k8s.status.Pending': '等待中',
|
||||
'systemManager.k8s.status.Completed': '已完成',
|
||||
'systemManager.k8s.status.Succeeded': '已成功',
|
||||
'systemManager.k8s.status.Failed': '失败',
|
||||
'systemManager.k8s.status.Unknown': '未知',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': '崩溃循环',
|
||||
'systemManager.k8s.status.ContainerCreating': '创建中',
|
||||
'systemManager.k8s.status.Terminating': '终止中',
|
||||
'systemManager.k8s.status.ImagePullBackOff': '镜像拉取退避',
|
||||
'systemManager.k8s.status.ErrImagePull': '拉取镜像失败',
|
||||
'systemManager.k8s.status.Error': '错误',
|
||||
'systemManager.k8s.status.Evicted': '已驱逐',
|
||||
'systemManager.k8s.status.OOMKilled': '内存不足',
|
||||
|
||||
'systemManager.ports.unavailable': '此主机未检测到端口工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在加载监听端口…',
|
||||
|
||||
@@ -487,8 +487,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系统管理侧栏中进程列表的刷新频率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 会话刷新间隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 会话列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表刷新间隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表刷新间隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器与 Pod 列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 性能数据刷新间隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/内存/网络指标的刷新频率。',
|
||||
|
||||
|
||||
@@ -280,6 +280,14 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
|
||||
'ai.opencode.check': '檢查',
|
||||
'ai.opencode.resetPath': '重設',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 未能及時就緒。請先在終端機確認 `opencode serve` 可以啟動,然後重試。',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 在對話開始前已結束。',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 拒絕了連線。請重試這次對話。',
|
||||
'ai.opencode.v2.unavailable': '無法連線到 OpenCode 2。請確認已安裝後再試。',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 未能完成這一輪對話。',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 沒有傳回內容。請在終端機執行 `opencode` 完成登入和模型設定。',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 無法套用 Netcatty 的對話內容。',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 無法掛接 MCP 工具,對話會繼續,但沒有該工具。',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -366,6 +374,22 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '對外 MCP 使用說明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '僅監聽 127.0.0.1,使用輪替 token,寫入操作重用 AI 權限模式,關閉時刪除 discovery。',
|
||||
"ai.privacy.title": "AI 隱私",
|
||||
"ai.privacy.description": "為 Catty、整合 Agent 和外部 MCP 用戶端過濾 Netcatty 管理的上下文及工具結果中已知的敏感文字模式。預設關閉,無法識別所有機密。啟用時會封鎖二進位工具回應。",
|
||||
"ai.privacy.boundary": "僅涵蓋 Netcatty 管理的資料和工具。第三方 Agent 的原生工具、直接讀取的檔案及其他 MCP 伺服器不在保護範圍內。",
|
||||
"ai.privacy.enable": "啟用文字去識別化",
|
||||
"ai.privacy.privateIps": "隱藏私有 IP 位址",
|
||||
"ai.privacy.privateIps.description": "同時隱藏回傳文字中的私有和本機 IP 位址。",
|
||||
"ai.privacy.cidrs": "額外 IP 網段(CIDR)",
|
||||
"ai.privacy.cidrs.description": "每行一個 IPv4 或 IPv6 CIDR。留空則僅使用內建規則。",
|
||||
"ai.privacy.patterns": "自訂正規表示式",
|
||||
"ai.privacy.patterns.description": "每行一個相容 RE2 的模式,不含 / 分隔符號或旗標。不支援或不安全的模式會遭拒絕。",
|
||||
"ai.privacy.connectionScope": "預留位置在同一個受管對話或外部 MCP 連線內保持穩定。只有同一範圍內的 Netcatty 受管工具呼叫才能還原原始值。",
|
||||
"ai.privacy.reconnect": "套用變更後,請建立新的受管對話並重新連接外部 MCP 用戶端。現有對話和連線不得重複使用預留位置。",
|
||||
"ai.privacy.apply": "套用",
|
||||
"ai.privacy.applying": "正在套用...",
|
||||
"ai.privacy.applied": "AI 隱私設定已套用。請建立新的受管對話並重新連接外部 MCP 用戶端。",
|
||||
"ai.privacy.failed": "AI 隱私設定未套用。",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '以 Streamable HTTP 提供同一套對外 MCP 工具,給無法啟動 stdio CLI 的用戶端使用,例如 WSL 裡的 Codex。預設關閉。',
|
||||
'ai.externalMcp.http.enable': '啟用 HTTP',
|
||||
|
||||
@@ -787,6 +787,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'vault.hosts.moveSelected': '移動到群組 ({count})',
|
||||
'vault.hosts.moveSelected.description': '將選取的主機移動到現有群組,也可移出群組。選取的群組本身不會移動。',
|
||||
'vault.hosts.moveMultiple.success': '已將 {count} 個主機移動到 {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} 個主機',
|
||||
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
|
||||
'vault.hosts.empty.title': '設定你的主機',
|
||||
|
||||
@@ -26,6 +26,11 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '載入容器列表失敗',
|
||||
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
|
||||
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
|
||||
'systemManager.errors.loadKubernetes': '無法載入 Kubernetes 資源',
|
||||
'systemManager.errors.loadPods': '無法載入 Pod',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl 無法連線到 containerd',
|
||||
'systemManager.errors.clusterUnreachable': '無法連到內容 {{context}} 的叢集',
|
||||
'systemManager.errors.clusterUnreachableGeneric': '無法連到叢集',
|
||||
'systemManager.errors.loadOverview': '載入系統概覽失敗',
|
||||
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
|
||||
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
|
||||
@@ -132,7 +137,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': '視窗名稱(可選)',
|
||||
'systemManager.tmux.noWindows': '沒有視窗',
|
||||
'systemManager.tmux.unavailable': '此主機未偵測到 tmux',
|
||||
'systemManager.docker.unavailable': '此主機未偵測到 Docker',
|
||||
'systemManager.docker.unavailable': '此主機未偵測到容器執行環境(Docker、Podman、nerdctl 或 kubectl)',
|
||||
'systemManager.tmux.windowsMismatch': '工作階段顯示有 {{count}} 個視窗,但 list-windows 未傳回任何視窗',
|
||||
'systemManager.tmux.lastCommand': '最後執行的指令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '沒有窗格',
|
||||
@@ -194,6 +199,36 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} 個映像',
|
||||
'systemManager.docker.start': '啟動',
|
||||
'systemManager.docker.stop': '停止',
|
||||
'systemManager.runtime.switch': '容器執行環境',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': '內容',
|
||||
'systemManager.k8s.namespace': '命名空間',
|
||||
'systemManager.k8s.empty': '沒有 Pod',
|
||||
'systemManager.k8s.search': '搜尋 Pod…',
|
||||
'systemManager.k8s.meta': '{{count}} 個 Pod',
|
||||
'systemManager.k8s.describe': '描述',
|
||||
'systemManager.k8s.delete': '刪除 Pod',
|
||||
'systemManager.k8s.confirmDelete': '確定刪除 Pod「{{name}}」?',
|
||||
'systemManager.k8s.restarts': '重新啟動 {{count}} 次',
|
||||
'systemManager.k8s.chooseContainer': '選擇容器',
|
||||
'systemManager.k8s.noContext': '此主機沒有 Kubernetes 內容',
|
||||
'systemManager.k8s.status.Running': '執行中',
|
||||
'systemManager.k8s.status.Pending': '等待中',
|
||||
'systemManager.k8s.status.Completed': '已完成',
|
||||
'systemManager.k8s.status.Succeeded': '已成功',
|
||||
'systemManager.k8s.status.Failed': '失敗',
|
||||
'systemManager.k8s.status.Unknown': '未知',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': '當機重試',
|
||||
'systemManager.k8s.status.ContainerCreating': '建立中',
|
||||
'systemManager.k8s.status.Terminating': '終止中',
|
||||
'systemManager.k8s.status.ImagePullBackOff': '映像拉取退避',
|
||||
'systemManager.k8s.status.ErrImagePull': '拉取映像失敗',
|
||||
'systemManager.k8s.status.Error': '錯誤',
|
||||
'systemManager.k8s.status.Evicted': '已驅逐',
|
||||
'systemManager.k8s.status.OOMKilled': '記憶體不足',
|
||||
|
||||
'systemManager.ports.unavailable': '此主機未偵測到連接埠工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在載入監聽連接埠…',
|
||||
|
||||
@@ -487,8 +487,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系統管理側欄中程序列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 工作階段重新整理間隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 工作階段列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器與 Pod 列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 效能資料重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/記憶體/網路指標的重新整理頻率。',
|
||||
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAiPrivacyService } from '../../electron/shared/aiPrivacyService.cjs';
|
||||
import {
|
||||
hasReversibleModelPrivacy,
|
||||
setReversibleModelPrivacy,
|
||||
} from '../../infrastructure/ai/harness/modelSecretRedaction';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
cleanupClosedTerminalSessions,
|
||||
cleanupDeletedAIChatSessions,
|
||||
cleanupOrphanedAISessions,
|
||||
cleanupSdkAgentSessions,
|
||||
latestAIActiveSessionMapSnapshot,
|
||||
latestAISessionsSnapshot,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAISessionsSnapshot,
|
||||
} from './aiStateSnapshots';
|
||||
|
||||
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
|
||||
@@ -61,6 +72,92 @@ function makeSession(id: string, updatedAt: number, messages: unknown[]): AISess
|
||||
} as never;
|
||||
}
|
||||
|
||||
test('orphan cleanup preserves resumable privacy maps until explicit chat deletion', (t) => {
|
||||
const service = createAiPrivacyService({ maxSessions: 3 });
|
||||
service.configure({ enabled: true });
|
||||
const sessions = [
|
||||
{ ...makeSession('privacy-orphan', 1, []), scope: { type: 'terminal' as const, targetId: 'closed' } },
|
||||
{ ...makeSession('privacy-live', 2, []), scope: { type: 'terminal' as const, targetId: 'open' } },
|
||||
{ ...makeSession('privacy-resumed', 3, []), scope: { type: 'terminal' as const, targetId: 'closed' } },
|
||||
];
|
||||
const aliases = sessions.map(session => {
|
||||
setReversibleModelPrivacy(session.id, true);
|
||||
return service.redact(session.id, '8.8.8.8');
|
||||
});
|
||||
const sdkCleanups: string[] = [];
|
||||
const privacyCleanups: string[] = [];
|
||||
const outputCleanups: string[] = [];
|
||||
const previousWindow = globalThis.window;
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
dispatchEvent: () => true,
|
||||
netcatty: {
|
||||
aiSdkAgentCleanup: async (id: string) => {
|
||||
sdkCleanups.push(id);
|
||||
return { ok: true };
|
||||
},
|
||||
aiPrivacyClearSession: async (id: string) => {
|
||||
privacyCleanups.push(id);
|
||||
service.clearSession(id);
|
||||
return { ok: true };
|
||||
},
|
||||
deleteChatToolOutputsTemp: async (id: string) => {
|
||||
outputCleanups.push(id);
|
||||
return { deletedCount: 1 };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
setLatestAIActiveSessionMapSnapshot({
|
||||
'terminal:closed': 'privacy-orphan',
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
|
||||
try {
|
||||
cleanupOrphanedAISessions(new Set(['open', 'resumed-into']));
|
||||
|
||||
assert.equal(latestAISessionsSnapshot, sessions);
|
||||
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
assert.deepEqual(sdkCleanups, ['privacy-orphan']);
|
||||
assert.deepEqual(privacyCleanups, []);
|
||||
assert.deepEqual(outputCleanups, []);
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
assert.equal(hasReversibleModelPrivacy(session.id), true);
|
||||
assert.equal(service.redact(session.id, '8.8.8.8'), aliases[index]);
|
||||
assert.equal(service.restore(session.id, aliases[index]), '8.8.8.8');
|
||||
}
|
||||
assert.throws(() => service.checkSession('privacy-next'), /session limit reached/);
|
||||
|
||||
cleanupDeletedAIChatSessions(['privacy-orphan']);
|
||||
|
||||
assert.deepEqual(privacyCleanups, ['privacy-orphan']);
|
||||
assert.deepEqual(outputCleanups, ['privacy-orphan']);
|
||||
assert.equal(hasReversibleModelPrivacy('privacy-orphan'), false);
|
||||
assert.equal(service.hasSession('privacy-orphan'), false);
|
||||
assert.throws(() => service.checkSession('privacy-orphan'), /start a new chat/);
|
||||
assert.equal(service.checkSession('privacy-next'), true);
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
if (session.id === 'privacy-orphan') continue;
|
||||
assert.equal(hasReversibleModelPrivacy(session.id), true);
|
||||
assert.equal(service.restore(session.id, aliases[index]), '8.8.8.8');
|
||||
}
|
||||
} finally {
|
||||
for (const session of sessions) setReversibleModelPrivacy(session.id, false);
|
||||
setLatestAISessionsSnapshot(previousSessions ?? []);
|
||||
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
isMissingDirectoryReplacePathError,
|
||||
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
|
||||
} from "./directoryReplacePromotion";
|
||||
import { unwrapSftpIpcError } from "./errors";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
export interface DedicatedResumeDeps {
|
||||
@@ -179,9 +180,11 @@ export async function openTransferSftpSession(
|
||||
// Restart/resume owns an independent SSH dial. Normal pooled transfer opens
|
||||
// leave reuseTransport enabled so the main-process registry can reuse an
|
||||
// authenticated endpoint even when no terminal tab exists.
|
||||
// No SFTP-page host-key dialog owns this session id. An untrusted key must
|
||||
// fail the transfer immediately instead of sitting on the verifier TTL.
|
||||
const openOpts = wantDedicated
|
||||
? { ...credentials, reuseTransport: false as const }
|
||||
: credentials;
|
||||
? { ...credentials, reuseTransport: false as const, hostKeyUnattended: true as const }
|
||||
: { ...credentials, hostKeyUnattended: true as const };
|
||||
|
||||
// The main-process auth driver owns method ordering, fallback, MFA and
|
||||
// retry. A renderer-side key-then-password retry performs a second full SSH
|
||||
@@ -740,7 +743,7 @@ async function resumeSingleFileWithDedicatedSession(
|
||||
const err = error as Error & { dedicatedAttention?: boolean; resetCheckpoint?: boolean };
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(error),
|
||||
error: unwrapSftpIpcError(error),
|
||||
needsAttention: !!err.dedicatedAttention,
|
||||
resetCheckpoint: !!err.resetCheckpoint,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { canRetryFromCheckpoint } from "./retryFromCheckpoint";
|
||||
|
||||
const failed = (over: Partial<TransferTask> = {}): TransferTask => ({
|
||||
id: "t", fileName: "big.bin", sourcePath: "/l/big.bin", targetPath: "/r/big.bin",
|
||||
sourceConnectionId: "local", targetConnectionId: "remote", direction: "upload",
|
||||
status: "failed", totalBytes: 100, transferredBytes: 40, checkpointBytes: 40,
|
||||
speed: 0, startTime: 1, isDirectory: false, error: "SFTP channel closed", ...over,
|
||||
});
|
||||
|
||||
test("canRetryFromCheckpoint only for a top-level file that lost its connection with progress", () => {
|
||||
assert.equal(canRetryFromCheckpoint(failed()), true);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ error: "read ECONNRESET" })), true);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ checkpointBytes: 0 })), false, "nothing to resume");
|
||||
assert.equal(canRetryFromCheckpoint(failed({ error: "Permission denied" })), false, "content errors restart");
|
||||
assert.equal(canRetryFromCheckpoint(failed({ isDirectory: true })), false);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ parentTaskId: "dir" })), false);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ status: "cancelled" })), false);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ retryable: false })), false);
|
||||
assert.equal(canRetryFromCheckpoint(failed({ direction: "download" })), false, "downloads keep the owner retry");
|
||||
assert.equal(canRetryFromCheckpoint(failed({ direction: "remote-to-remote" })), false, "remote copies keep the owner retry");
|
||||
assert.equal(canRetryFromCheckpoint(failed({ direction: "local-copy" })), false);
|
||||
assert.equal(canRetryFromCheckpoint(undefined), false);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
|
||||
// Failures that only mean "the SSH/SFTP connection went away". The staged
|
||||
// .part is intact up to the checkpoint, so Retry can continue from it instead
|
||||
// of restarting at byte 0 (which also strands the old stage on the server,
|
||||
// because the new attempt stages under a fresh transfer id).
|
||||
const SESSION_LOSS_ERROR = /SFTP (channel closed|session lost)|ECONNRESET|EPIPE|Not connected|No response from server|Keepalive timeout|connection (lost|closed|reset)/i;
|
||||
|
||||
/**
|
||||
* True when Retry should resume the same task from its checkpoint.
|
||||
* Only local-to-remote uploads (`direction === "upload"`) do this. Downloads
|
||||
* and remote copies have no unfingerprinted stage to keep, so they stay on
|
||||
* the owner's fresh retry.
|
||||
*/
|
||||
export function canRetryFromCheckpoint(task: TransferTask | undefined): boolean {
|
||||
if (!task) return false;
|
||||
return task.status === "failed"
|
||||
&& !task.isDirectory
|
||||
&& !task.parentTaskId
|
||||
&& task.retryable !== false
|
||||
&& !task.conflict
|
||||
&& task.direction === "upload"
|
||||
&& (task.checkpointBytes ?? 0) > 0
|
||||
&& SESSION_LOSS_ERROR.test(task.error ?? "");
|
||||
}
|
||||
@@ -334,6 +334,111 @@ test("live directory download rejects a Windows backslash traversal entry", asyn
|
||||
}
|
||||
});
|
||||
|
||||
test("a file batch that rejects mid-walk does not emit unhandledRejection", async () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousLocalStorage = (globalThis as { localStorage?: unknown }).localStorage;
|
||||
const previousActEnvironment = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const root = {
|
||||
...rootTask(),
|
||||
targetPath: "C:\\Users\\alice\\Downloads\\folder",
|
||||
};
|
||||
let tasks: TransferTask[] = [root];
|
||||
const transfersRef = { current: tasks };
|
||||
const setTransfers = (update: React.SetStateAction<TransferTask[]>) => {
|
||||
tasks = typeof update === "function" ? update(tasks) : update;
|
||||
transfersRef.current = tasks;
|
||||
};
|
||||
let releaseLater!: () => void;
|
||||
const laterGate = new Promise<void>((resolve) => {
|
||||
releaseLater = resolve;
|
||||
});
|
||||
let laterListingStarted!: () => void;
|
||||
const laterListing = new Promise<void>((resolve) => {
|
||||
laterListingStarted = resolve;
|
||||
});
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
netcatty: {
|
||||
mkdirLocal: async () => undefined,
|
||||
statLocal: async () => ({ type: "directory" }),
|
||||
},
|
||||
};
|
||||
(globalThis as { localStorage?: unknown }).localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
};
|
||||
|
||||
let operations: ReturnType<typeof useSftpDirectoryTransferOps> | undefined;
|
||||
let renderer: ReactTestRenderer | null = null;
|
||||
const Probe = () => {
|
||||
operations = useSftpDirectoryTransferOps({
|
||||
ownerId: "owner",
|
||||
cancelledTasksRef: { current: new Set() },
|
||||
pausedTasksRef: { current: new Set() },
|
||||
waitUntilTransferResumed: async () => undefined,
|
||||
activeChildIdsRef: { current: new Map() },
|
||||
transfersRef,
|
||||
setTransfers,
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async (_sftpId: string, path: string) => {
|
||||
if (path === "/source") return [directoryEntry("bad"), directoryEntry("later")];
|
||||
if (path === "/source/bad") return [fileEntry("..\\outside.txt")];
|
||||
laterListingStarted();
|
||||
await laterGate;
|
||||
return [];
|
||||
},
|
||||
});
|
||||
return null;
|
||||
};
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (reason: unknown) => {
|
||||
unhandled.push(reason);
|
||||
};
|
||||
const rejectionText = (reason: unknown) => (
|
||||
reason instanceof Error ? reason.message : String(reason)
|
||||
);
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
|
||||
let running: Promise<number> | undefined;
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(Probe));
|
||||
});
|
||||
assert.ok(operations);
|
||||
running = operations.transferDirectory(
|
||||
root,
|
||||
"source-sftp",
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
"auto",
|
||||
"auto",
|
||||
root.id,
|
||||
);
|
||||
await laterListing;
|
||||
// The bad directory's batch is already queued. End this turn while the
|
||||
// sibling listing is still blocked so an unhandled rejection can surface.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(unhandled.map(rejectionText), []);
|
||||
releaseLater();
|
||||
await assert.rejects(running, /unsafe transfer path/i);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(unhandled.map(rejectionText), []);
|
||||
} finally {
|
||||
releaseLater();
|
||||
await running?.catch(() => {});
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
await act(async () => {
|
||||
renderer?.unmount();
|
||||
});
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { localStorage?: unknown }).localStorage = previousLocalStorage;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
}
|
||||
});
|
||||
|
||||
for (const ownerChange of ["same", "changed-active", "changed-completed"] as const) {
|
||||
test(`live folder settles superseded ownership without clobbering its replacement: ${ownerChange}`, async () => {
|
||||
const { sftpTransferCenterStore } = await import("../sftpTransferCenterStore");
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
DEFAULT_SFTP_DIRECTORY_LISTING_CONCURRENCY,
|
||||
resolveSftpDirectoryListingConcurrency,
|
||||
resolveSftpSkipUnchangedEnabled,
|
||||
resolveSftpTransferConcurrency,
|
||||
runSftpTransferWorkers,
|
||||
} from "./transferConcurrency";
|
||||
import { getSftpTransferResourceKeys, globalSftpTransferScheduler } from "./globalTransferScheduler";
|
||||
@@ -78,11 +79,30 @@ function createDirectoryListingGate(concurrency = DEFAULT_SFTP_DIRECTORY_LISTING
|
||||
};
|
||||
}
|
||||
|
||||
/** Observed at queue time so a mid-walk rejection is not unhandled. */
|
||||
type QueuedFileBatch =
|
||||
| { ok: true; errors: number }
|
||||
| { ok: false; error: unknown };
|
||||
|
||||
type DirectoryDiscoveryProgress = {
|
||||
discoveredFiles: number;
|
||||
nextEntryIndex: number;
|
||||
/** Tree-wide semaphore so nested parallel walks cannot multiply listings. */
|
||||
listingGate?: ReturnType<typeof createDirectoryListingGate>;
|
||||
/**
|
||||
* Tree-wide file-transfer gate (transfer concurrency setting). Each directory
|
||||
* hands its files to workers that share this gate, and the walk does not wait
|
||||
* for a directory's files before moving on, so there is no per-directory
|
||||
* barrier: trees of many small directories keep N files in flight.
|
||||
*/
|
||||
fileGate?: ReturnType<typeof createDirectoryListingGate>;
|
||||
/**
|
||||
* Per-directory file batches. Each promise is settled when queued; the root
|
||||
* walk inspects the outcomes after discovery.
|
||||
*/
|
||||
pendingFileBatches?: Promise<QueuedFileBatch>[];
|
||||
/** First session loss seen by any batch; later batches stop claiming files. */
|
||||
sessionLostError?: Error | null;
|
||||
};
|
||||
|
||||
function isCancelledLocalOrGlobal(
|
||||
@@ -527,7 +547,7 @@ export function useSftpDirectoryTransferOps({
|
||||
};
|
||||
|
||||
/** Returns number of failed child file transfers */
|
||||
const transferDirectory = async (
|
||||
const walkDirectory = async (
|
||||
task: TransferTask,
|
||||
sourceSftpId: string | null,
|
||||
targetSftpId: string | null,
|
||||
@@ -541,7 +561,8 @@ export function useSftpDirectoryTransferOps({
|
||||
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
|
||||
discoveryProgress?: DirectoryDiscoveryProgress,
|
||||
traversalBudget?: SftpDirectoryTraversalBudget,
|
||||
) => {
|
||||
rootProgress?: DirectoryDiscoveryProgress,
|
||||
): Promise<number> => {
|
||||
// Check if task or root task was cancelled before starting
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
@@ -552,11 +573,12 @@ export function useSftpDirectoryTransferOps({
|
||||
// as soon as each directory is listed; compressed upload is a separate path.
|
||||
// UI should treat totalBytes as "discovered so far", not a fixed grand total.
|
||||
let totalErrors = 0;
|
||||
const progress = discoveryProgress ?? {
|
||||
const progress: DirectoryDiscoveryProgress = discoveryProgress ?? rootProgress ?? {
|
||||
discoveredFiles: 0,
|
||||
nextEntryIndex: 0,
|
||||
listingGate: createDirectoryListingGate(resolveSftpDirectoryListingConcurrency()),
|
||||
};
|
||||
if (!progress.pendingFileBatches) progress.pendingFileBatches = [];
|
||||
if (!progress.listingGate) {
|
||||
progress.listingGate = createDirectoryListingGate(resolveSftpDirectoryListingConcurrency());
|
||||
}
|
||||
@@ -684,7 +706,7 @@ export function useSftpDirectoryTransferOps({
|
||||
};
|
||||
|
||||
const isSymlink = dir.type === "symlink";
|
||||
totalErrors += await transferDirectory(
|
||||
totalErrors += await walkDirectory(
|
||||
childTask,
|
||||
sourceSftpId,
|
||||
targetSftpId,
|
||||
@@ -721,11 +743,21 @@ export function useSftpDirectoryTransferOps({
|
||||
let sessionLostError: Error | null = null;
|
||||
const directoryEntryBase = progress.nextEntryIndex;
|
||||
progress.nextEntryIndex += regularFiles.length;
|
||||
const directoryFiles = regularFiles;
|
||||
if (!progress.fileGate) {
|
||||
// Same setting runSftpTransferWorkers reads, created on first file.
|
||||
progress.fileGate = createDirectoryListingGate(resolveSftpTransferConcurrency(
|
||||
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
));
|
||||
}
|
||||
const fileGate = progress.fileGate;
|
||||
|
||||
const fileBatch = (async (): Promise<number> => {
|
||||
await runSftpTransferWorkers(
|
||||
regularFiles,
|
||||
directoryFiles,
|
||||
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
|
||||
async (file, fileIndex) => {
|
||||
async (file, fileIndex) => fileGate.run(async () => {
|
||||
if (!sessionLostError && progress.sessionLostError) sessionLostError = progress.sessionLostError;
|
||||
if (sessionLostError) throw sessionLostError;
|
||||
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
@@ -993,6 +1025,7 @@ export function useSftpDirectoryTransferOps({
|
||||
);
|
||||
if (isSessionError(err) && !sessionLostError) {
|
||||
sessionLostError = err instanceof Error ? err : new Error(message);
|
||||
progress.sessionLostError = progress.sessionLostError ?? sessionLostError;
|
||||
// Fail remaining queued siblings quickly with a clear cause.
|
||||
setTransfers((prev) => prev.map((t) => (
|
||||
t.parentTaskId === rootTaskId
|
||||
@@ -1015,10 +1048,10 @@ export function useSftpDirectoryTransferOps({
|
||||
await waitWhileTransferPaused(rootTaskId);
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
beforeClaim: async () => {
|
||||
if (sessionLostError) return;
|
||||
if (sessionLostError || progress.sessionLostError) return;
|
||||
await waitWhileTransferPaused(rootTaskId);
|
||||
},
|
||||
},
|
||||
@@ -1030,17 +1063,69 @@ export function useSftpDirectoryTransferOps({
|
||||
throw err;
|
||||
});
|
||||
|
||||
totalErrors += errors.length;
|
||||
if (sessionLostError) {
|
||||
logger.warn("[SFTP] Directory transfer stopped early: session lost", sessionLostError.message);
|
||||
} else if (errors.length > 0) {
|
||||
logger.debug?.("[SFTP] Some files in directory transfer failed", errors);
|
||||
}
|
||||
return errors.length;
|
||||
})();
|
||||
// Do not wait here: the walk continues into sibling directories while
|
||||
// this directory's files drain through the shared fileGate. Settle the
|
||||
// batch now so a failure during that walk (unsafe filename, unexpected
|
||||
// worker error) is handled. The root drain still surfaces the error.
|
||||
progress.pendingFileBatches.push(fileBatch.then(
|
||||
(errors): QueuedFileBatch => ({ ok: true, errors }),
|
||||
(error: unknown): QueuedFileBatch => ({ ok: false, error }),
|
||||
));
|
||||
}
|
||||
|
||||
return totalErrors;
|
||||
};
|
||||
|
||||
const transferDirectory = async (
|
||||
...args: Parameters<typeof walkDirectory>
|
||||
): Promise<number> => {
|
||||
const [, , , , , , , , , , , discoveryProgress, traversalBudget] = args;
|
||||
if (discoveryProgress) return walkDirectory(...args);
|
||||
// Root of a folder task: own the tree-wide progress so we can drain every
|
||||
// directory's file batch, even when the walk itself throws, before the
|
||||
// caller decides the folder's final state.
|
||||
const rootProgress: DirectoryDiscoveryProgress = {
|
||||
discoveredFiles: 0,
|
||||
nextEntryIndex: 0,
|
||||
listingGate: createDirectoryListingGate(resolveSftpDirectoryListingConcurrency()),
|
||||
pendingFileBatches: [],
|
||||
};
|
||||
let walkErrors = 0;
|
||||
let walkError: unknown = null;
|
||||
try {
|
||||
const [task, sourceSftpId, targetSftpId, sourceIsLocal, targetIsLocal, sourceEncoding,
|
||||
targetEncoding, rootTaskId, sameHost, symlinkDepth, followSymlinks] = args;
|
||||
walkErrors = await walkDirectory(
|
||||
task, sourceSftpId, targetSftpId, sourceIsLocal, targetIsLocal, sourceEncoding,
|
||||
targetEncoding, rootTaskId, sameHost, symlinkDepth, followSymlinks,
|
||||
undefined, traversalBudget, rootProgress,
|
||||
);
|
||||
} catch (error) {
|
||||
walkError = error;
|
||||
}
|
||||
let fileErrors = 0;
|
||||
let batchError: unknown = null;
|
||||
const batches = rootProgress.pendingFileBatches!;
|
||||
for (let index = 0; index < batches.length; index += 1) {
|
||||
const outcome = await batches[index];
|
||||
if (outcome.ok) {
|
||||
fileErrors += outcome.errors;
|
||||
} else {
|
||||
batchError = batchError ?? outcome.error;
|
||||
}
|
||||
}
|
||||
if (walkError) throw walkError;
|
||||
if (batchError) throw batchError;
|
||||
return walkErrors + fileErrors;
|
||||
};
|
||||
|
||||
|
||||
return { transferFile, transferDirectory };
|
||||
}
|
||||
@@ -38,6 +38,20 @@ test("runWithTransferRetry does not retry permanent errors", async () => {
|
||||
assert.equal(attempts, 1);
|
||||
});
|
||||
|
||||
test("a dead SFTP channel is not retried into another dial (#3310)", async () => {
|
||||
assert.equal(isTransientTransferError(new Error("SFTP channel closed")), false);
|
||||
assert.equal(isTransientTransferError(new Error("SFTP session not found")), true);
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
() => runWithTransferRetry(async () => {
|
||||
attempts += 1;
|
||||
throw new Error("SFTP channel closed");
|
||||
}, { retries: 1, delayMs: 0 }),
|
||||
/SFTP channel closed/,
|
||||
);
|
||||
assert.equal(attempts, 1, "channel death must not schedule a reconnect");
|
||||
});
|
||||
|
||||
test("runWithTransferRetry does not retry cancel", async () => {
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
|
||||
@@ -6,6 +6,9 @@ import { isSessionError } from "./errors";
|
||||
* path) should not be retried.
|
||||
*/
|
||||
export function isTransientTransferError(err: unknown): boolean {
|
||||
// The channel is gone. Retrying opens a new dial, and an OTP host then
|
||||
// sits on keyboard-interactive while the transfer stays active.
|
||||
if (err instanceof Error && /sftp channel closed/i.test(err.message)) return false;
|
||||
if (isSessionError(err)) return true;
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
|
||||
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
|
||||
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
|
||||
import { unwrapSftpIpcError } from "./errors";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
@@ -957,7 +958,7 @@ export const useSftpConnections = ({
|
||||
if (!isTargetConnectionAtPath(startPath)) return;
|
||||
updateTargetTab((prev) => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : "Failed to list directory",
|
||||
error: unwrapSftpIpcError(err) || "Failed to list directory",
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
@@ -1263,14 +1264,14 @@ export const useSftpConnections = ({
|
||||
? {
|
||||
...prev.connection,
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
error: unwrapSftpIpcError(err) || "Connection failed",
|
||||
}
|
||||
: null,
|
||||
files: isReconnectAttempt ? [] : prev.files,
|
||||
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
|
||||
error: isReconnectAttempt
|
||||
? "sftp.error.reconnectFailed"
|
||||
: (err instanceof Error ? err.message : "Connection failed"),
|
||||
: (unwrapSftpIpcError(err) || "Connection failed"),
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/** @test lifecycle repaints coalesce to one frame and still settle the final status */
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
|
||||
import type { TransferTask } from "../../../domain/models";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { latchTransferPauseTree, releaseTransferPauseTree } from "./transferPauseLatch";
|
||||
import { useSftpTransfers } from "./useSftpTransfers";
|
||||
|
||||
type Frame = { id: number; callback: (time: number) => void; cancelled: boolean };
|
||||
|
||||
function installFrameQueue() {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
const previousActFlag = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const frames: Frame[] = [];
|
||||
let nextId = 1;
|
||||
const requestAnimationFrame = (callback: (time: number) => void) => {
|
||||
const frame: Frame = { id: nextId++, callback, cancelled: false };
|
||||
frames.push(frame);
|
||||
return frame.id;
|
||||
};
|
||||
const cancelAnimationFrame = (id: number) => {
|
||||
const frame = frames.find((candidate) => candidate.id === id);
|
||||
if (frame) frame.cancelled = true;
|
||||
};
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
netcatty: {},
|
||||
requestAnimationFrame,
|
||||
cancelAnimationFrame,
|
||||
};
|
||||
return {
|
||||
frames,
|
||||
flush: async () => {
|
||||
const pending = frames.splice(0, frames.length).filter((frame) => !frame.cancelled);
|
||||
await act(async () => {
|
||||
for (const frame of pending) frame.callback(0);
|
||||
});
|
||||
return pending.length;
|
||||
},
|
||||
restore: () => {
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousActFlag;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeTask(id: string, status: TransferTask["status"], error?: string): TransferTask {
|
||||
return {
|
||||
id,
|
||||
fileName: `${id}.txt`,
|
||||
sourcePath: `/source/${id}.txt`,
|
||||
targetPath: `/target/${id}.txt`,
|
||||
sourceConnectionId: "local",
|
||||
targetConnectionId: "remote",
|
||||
direction: "upload",
|
||||
status,
|
||||
error,
|
||||
totalBytes: 10,
|
||||
transferredBytes: status === "completed" ? 10 : 2,
|
||||
speed: status === "transferring" ? 1 : 0,
|
||||
startTime: 1,
|
||||
isDirectory: false,
|
||||
resumable: true,
|
||||
};
|
||||
}
|
||||
|
||||
function mount(ownerId: string) {
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: () => null,
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
get ops() {
|
||||
return ops;
|
||||
},
|
||||
render: async () => {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
},
|
||||
unmount: async () => {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
renderer = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("lifecycle frames keep the final completion, pause, cancel, and error status", async () => {
|
||||
const ownerId = "lifecycle-flush-final-status";
|
||||
const queue = installFrameQueue();
|
||||
const view = mount(ownerId);
|
||||
const taskId = "lifecycle-row";
|
||||
try {
|
||||
await view.render();
|
||||
const stages: Array<{ status: TransferTask["status"]; error?: string }> = [
|
||||
{ status: "transferring" },
|
||||
{ status: "paused" },
|
||||
{ status: "cancelled" },
|
||||
{ status: "failed", error: "connection reset" },
|
||||
{ status: "completed" },
|
||||
];
|
||||
for (const stage of stages) {
|
||||
await act(async () => {
|
||||
sftpTransferCenterStore.publishOwner(ownerId, [makeTask(taskId, stage.status, stage.error)]);
|
||||
});
|
||||
const paintsNow = stage.status !== "transferring";
|
||||
if (paintsNow) {
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0);
|
||||
} else {
|
||||
assert.notEqual(view.ops?.transfers.find((row) => row.id === taskId)?.status, stage.status);
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 1);
|
||||
assert.equal(await queue.flush(), 1);
|
||||
}
|
||||
const painted = view.ops?.transfers.find((row) => row.id === taskId);
|
||||
assert.equal(painted?.status, stage.status);
|
||||
assert.equal(painted?.error, stage.error);
|
||||
assert.equal(
|
||||
sftpTransferCenterStore.getOwnerTasks(ownerId).find((row) => row.id === taskId)?.status,
|
||||
stage.status,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
sftpTransferCenterStore.dismiss(taskId);
|
||||
await view.unmount();
|
||||
queue.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("parent pause paints while a child frame is pending and a stale publish cannot reopen it", async () => {
|
||||
const ownerId = "lifecycle-flush-parent-pause";
|
||||
const queue = installFrameQueue();
|
||||
const view = mount(ownerId);
|
||||
const parentId = "folder-parent";
|
||||
const childId = "folder-child";
|
||||
const parent = (status: TransferTask["status"]): TransferTask => ({
|
||||
...makeTask(parentId, status),
|
||||
isDirectory: true,
|
||||
progressMode: "files",
|
||||
lifecycleEpoch: 4,
|
||||
});
|
||||
const child = (status: TransferTask["status"]): TransferTask => ({
|
||||
...makeTask(childId, status),
|
||||
parentTaskId: parentId,
|
||||
lifecycleEpoch: 4,
|
||||
});
|
||||
try {
|
||||
await view.render();
|
||||
sftpTransferCenterStore.publishOwner(ownerId, [parent("transferring"), child("transferring")]);
|
||||
assert.equal(view.ops?.transfers.length, 0, "child lifecycle churn stays on the pending frame");
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 1);
|
||||
|
||||
// Pause lands while that frame is still queued. Latch afterwards: the
|
||||
// control-epoch guard drops a panel publish that arrives already latched,
|
||||
// and Resume is the equal-epoch republish that happens only after unlatch.
|
||||
await act(async () => {
|
||||
sftpTransferCenterStore.publishOwner(ownerId, [parent("paused"), child("paused")]);
|
||||
});
|
||||
assert.equal(view.ops?.transfers.find((row) => row.id === parentId)?.status, "paused");
|
||||
assert.equal(view.ops?.transfers.find((row) => row.id === childId)?.status, "paused");
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0, "pause cancels the pre-pause frame");
|
||||
|
||||
latchTransferPauseTree(parentId, [childId]);
|
||||
await act(async () => {
|
||||
sftpTransferCenterStore.publishOwner(ownerId, [parent("transferring"), child("paused")]);
|
||||
});
|
||||
await queue.flush();
|
||||
assert.equal(sftpTransferCenterStore.getTask(parentId)?.status, "paused");
|
||||
assert.equal(sftpTransferCenterStore.getTask(childId)?.status, "paused");
|
||||
const paintedParent = view.ops?.transfers.find((row) => row.id === parentId);
|
||||
assert.equal(paintedParent?.status, "paused");
|
||||
assert.equal(
|
||||
view.ops?.transfers.filter((row) => (
|
||||
!row.parentTaskId && ["pending", "queued", "transferring", "pausing"].includes(row.status)
|
||||
)).length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
releaseTransferPauseTree(parentId, [childId]);
|
||||
sftpTransferCenterStore.dismiss(parentId);
|
||||
await view.unmount();
|
||||
queue.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("unmount cancels the pending lifecycle frame and remount reads the settled store row", async () => {
|
||||
const ownerId = "lifecycle-flush-unmount";
|
||||
const queue = installFrameQueue();
|
||||
const view = mount(ownerId);
|
||||
const taskId = "lifecycle-unmount-row";
|
||||
try {
|
||||
await view.render();
|
||||
sftpTransferCenterStore.publishOwner(ownerId, [makeTask(taskId, "failed", "write failed")]);
|
||||
assert.equal(view.ops?.transfers.length, 0);
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 1);
|
||||
|
||||
await view.unmount();
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0);
|
||||
assert.equal(await queue.flush(), 0);
|
||||
|
||||
await view.render();
|
||||
const painted = view.ops?.transfers.find((row) => row.id === taskId);
|
||||
assert.equal(painted?.status, "failed");
|
||||
assert.equal(painted?.error, "write failed");
|
||||
assert.equal(queue.frames.filter((frame) => !frame.cancelled).length, 0);
|
||||
} finally {
|
||||
sftpTransferCenterStore.dismiss(taskId);
|
||||
await view.unmount();
|
||||
queue.restore();
|
||||
}
|
||||
});
|
||||
@@ -56,6 +56,166 @@ test(`failed ${direction} connection replaces Waiting with the actual error (#36
|
||||
});
|
||||
}
|
||||
|
||||
test("upload open uses known hosts trusted after the transfer callback was created (#3310)", async () => {
|
||||
const seen: string[] = [];
|
||||
let fingerprint = "";
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let firstStart: ReturnType<typeof useSftpTransfers>["startTransfer"] | undefined;
|
||||
const restore = installGlobals({});
|
||||
function Probe() {
|
||||
const captured = fingerprint;
|
||||
const acquireTransferSession = React.useCallback(async () => {
|
||||
seen.push(captured);
|
||||
throw new Error(`known-hosts:${captured || "empty"}`);
|
||||
}, [captured]);
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "known-hosts-after-trust-3310",
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession,
|
||||
});
|
||||
if (!firstStart) firstStart = ops.startTransfer;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
fingerprint = "trusted-this-session";
|
||||
await act(async () => { renderer?.update(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await firstStart!(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.deepEqual(seen, ["trusted-this-session"]);
|
||||
const task = sftpTransferCenterStore.getOwnerTasks("known-hosts-after-trust-3310")[0];
|
||||
assert.equal(task?.error, "known-hosts:trusted-this-session");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("channel death fails the upload without a second session open (#3310)", async () => {
|
||||
let acquires = 0;
|
||||
let streams = 0;
|
||||
const restore = installGlobals({
|
||||
statLocal: async () => ({ size: 48, lastModified: 1 }),
|
||||
statSftp: async () => null,
|
||||
startStreamTransfer: async () => {
|
||||
streams += 1;
|
||||
return { error: "SFTP channel closed" };
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const ownerId = "channel-death-no-reconnect-3310";
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession: async () => {
|
||||
acquires += 1;
|
||||
return {
|
||||
sftpId: "sftp-borrowed",
|
||||
release: () => undefined,
|
||||
discard: () => undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.equal(streams, 1, "channel death must not start another transfer dial");
|
||||
assert.equal(acquires, 2, "work lease plus the file lease, and no retry open");
|
||||
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
|
||||
assert.equal(task?.status, "failed");
|
||||
assert.equal(task?.error, "SFTP channel closed");
|
||||
assert.equal(
|
||||
sftpTransferCenterStore.getOwnerTasks(ownerId)
|
||||
.filter((row) => ["pending", "queued", "transferring", "pausing"].includes(row.status))
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("transfer failure strips the Electron IPC wrapper (#3310)", async () => {
|
||||
const restore = installGlobals({});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const ownerId = "ipc-prefix-3310";
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession: async () => {
|
||||
throw new Error("Error invoking remote method 'netcatty:sftp:open': Error: Host key verification required for 127.0.0.1");
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
|
||||
assert.equal(task?.error, "Host key verification required for 127.0.0.1");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
type StartOptions = {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
@@ -280,6 +440,9 @@ test("stat-less remote source keeps the listing size in the overwrite dialog and
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
// Lifecycle paints coalesce to one frame. The plan lives on the ref
|
||||
// immediately; React state catches up on the queued animation frame.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
assert.equal(started.length, 0, "an unresolved conflict must not start the transfer");
|
||||
|
||||
@@ -24,7 +24,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { SftpPane } from "./types";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import { isMissingStatError, unwrapSftpIpcError } from "./errors";
|
||||
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
|
||||
import { useSftpTransferConflictOps } from "./transferConflictOps";
|
||||
import { useSftpTransferTaskOps } from "./transferTaskOps";
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
resolveDirectDirectoryDownloadFinalStatus,
|
||||
} from "./downloadTransferTask";
|
||||
import {
|
||||
isTransferOrRootPauseLatched,
|
||||
releaseTransferPauseTree,
|
||||
waitUntilTransferPauseReleased,
|
||||
} from "./transferPauseLatch";
|
||||
@@ -158,6 +159,89 @@ export function finishTransferTask(
|
||||
return status;
|
||||
}
|
||||
|
||||
const STICKY_CONTROL_STATUSES = new Set<TransferStatus>([
|
||||
"paused",
|
||||
"pausing",
|
||||
"cancelled",
|
||||
"completed",
|
||||
"failed",
|
||||
"interrupted",
|
||||
"attention",
|
||||
]);
|
||||
|
||||
const LIVE_QUEUE_STATUSES = new Set<TransferStatus>([
|
||||
"transferring",
|
||||
"pending",
|
||||
"queued",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Pause, resume, and cancel paint on the same turn. Per-file complete/queue
|
||||
* churn stays on the animation frame so a folder upload does not commit once
|
||||
* per child.
|
||||
*/
|
||||
export function shouldPaintTransferControlNow(
|
||||
previous: readonly TransferTask[],
|
||||
next: readonly TransferTask[],
|
||||
): boolean {
|
||||
const prevById = new Map(previous.map((row) => [row.id, row]));
|
||||
return next.some((row) => {
|
||||
const prevRow = prevById.get(row.id);
|
||||
if (prevRow?.status === row.status) return false;
|
||||
if (row.status === "paused" || row.status === "pausing" || row.status === "cancelled") return true;
|
||||
if (prevRow && (prevRow.status === "paused" || prevRow.status === "pausing" || prevRow.status === "cancelled")) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
!row.parentTaskId
|
||||
&& prevRow
|
||||
&& (STICKY_CONTROL_STATUSES.has(row.status) || STICKY_CONTROL_STATUSES.has(prevRow.status))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Child lifecycle updates clone the ref they saw. If that clone still says the
|
||||
* folder is transferring after the store already paused it, publishing the
|
||||
* clone reopens the parent. An explicit status edit (resume, retry) is kept.
|
||||
*/
|
||||
export function reconcilePanelRowsWithStore(
|
||||
previous: readonly TransferTask[],
|
||||
next: readonly TransferTask[],
|
||||
storeRows: readonly TransferTask[],
|
||||
): TransferTask[] {
|
||||
const prevById = new Map(previous.map((row) => [row.id, row]));
|
||||
const storeById = new Map(storeRows.map((row) => [row.id, row]));
|
||||
let changed = false;
|
||||
const reconciled = next.map((row) => {
|
||||
const storeRow = storeById.get(row.id);
|
||||
const prevRow = prevById.get(row.id);
|
||||
if (!storeRow || !STICKY_CONTROL_STATUSES.has(storeRow.status) || !LIVE_QUEUE_STATUSES.has(row.status)) {
|
||||
return row;
|
||||
}
|
||||
// A full-list write can disagree with the ref only because the ref already
|
||||
// moved to the store's pause. That is a stale snapshot, not Resume.
|
||||
// Resume releases the latch before it republishes transferring.
|
||||
const rootId = row.parentTaskId ?? row.id;
|
||||
const latchedReopen = isTransferOrRootPauseLatched(rootId, row.id);
|
||||
if (prevRow && prevRow.status !== row.status && !latchedReopen) return row;
|
||||
if (row.status === storeRow.status) return row;
|
||||
changed = true;
|
||||
return {
|
||||
...row,
|
||||
status: storeRow.status,
|
||||
speed: 0,
|
||||
error: storeRow.error,
|
||||
endTime: storeRow.endTime,
|
||||
pauseUnavailableReason: storeRow.pauseUnavailableReason,
|
||||
};
|
||||
});
|
||||
return changed ? reconciled : next as TransferTask[];
|
||||
}
|
||||
|
||||
/** User-facing path exclusivity notice (toast + logs). */
|
||||
function notifyPathConflict(
|
||||
existing: Pick<TransferTask, "fileName" | "status">,
|
||||
@@ -191,6 +275,21 @@ export const useSftpTransfers = ({
|
||||
const { t } = useI18n();
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
// startTransfer keeps the processTransfer closure from its first render.
|
||||
// Read the latest acquire fn so a host key trusted after that render is
|
||||
// included in the transfer open, instead of the empty list from mount.
|
||||
const acquireTransferSessionRef = useRef(acquireTransferSession);
|
||||
acquireTransferSessionRef.current = acquireTransferSession;
|
||||
const acquireTransferSessionLive = useCallback<NonNullable<UseSftpTransfersParams["acquireTransferSession"]>>(
|
||||
(hostId, transferId, connectHost) => {
|
||||
const acquire = acquireTransferSessionRef.current;
|
||||
if (!acquire) {
|
||||
return Promise.reject(new Error("SFTP transfer session acquisition is unavailable"));
|
||||
}
|
||||
return acquire(hostId, transferId, connectHost);
|
||||
},
|
||||
[],
|
||||
);
|
||||
// Ref so progress flushes always see the latest visibility without rebinding.
|
||||
const surfaceVisibleRef = useRef(surfaceVisible);
|
||||
surfaceVisibleRef.current = surfaceVisible;
|
||||
@@ -222,29 +321,85 @@ export const useSftpTransfers = ({
|
||||
const transfersRef = useRef(transfers);
|
||||
const conflictsRef = useRef(conflicts);
|
||||
conflictsRef.current = conflicts;
|
||||
// When the retained panel is re-opened, catch up React state from the ref that
|
||||
// kept receiving progress while the surface was hidden.
|
||||
useEffect(() => {
|
||||
if (!surfaceVisible) return;
|
||||
setTransfersState(transfersRef.current);
|
||||
}, [surfaceVisible]);
|
||||
|
||||
// Keep owner lifecycle changes in the global transfer store. Byte progress
|
||||
// arrives independently through the unified process-wide event stream.
|
||||
//
|
||||
// Do not pre-assign transfersRef before publishing the same array reference —
|
||||
// identity equality skips the store merge (avoids store↔listener loops).
|
||||
// Lifecycle writers (child add/start/complete) fire several times per file.
|
||||
// Repainting the whole SFTP view synchronously on each one saturates the
|
||||
// renderer on folders with many small files, which then throttles the
|
||||
// transfer itself. transfersRef/store stay authoritative; React state
|
||||
// catches up once per frame, except pause/resume/cancel and folder terminal
|
||||
// rows, which paint immediately.
|
||||
//
|
||||
// A frame queued for child churn must not commit a pre-pause ref after the
|
||||
// store has already paused the parent. The flush re-reads the store, and a
|
||||
// synchronous control paint bumps paintEpoch so that queued frame no-ops.
|
||||
const stateFlushHandleRef = useRef<{ cancel: () => void } | null>(null);
|
||||
const paintEpochRef = useRef(0);
|
||||
const cancelScheduledTransferPaint = useCallback(() => {
|
||||
stateFlushHandleRef.current?.cancel();
|
||||
stateFlushHandleRef.current = null;
|
||||
}, []);
|
||||
const paintTransfersNow = useCallback((rows: TransferTask[]) => {
|
||||
cancelScheduledTransferPaint();
|
||||
paintEpochRef.current += 1;
|
||||
transfersRef.current = rows;
|
||||
setTransfersState(rows);
|
||||
}, [cancelScheduledTransferPaint]);
|
||||
const scheduleTransfersStateFlush = useCallback(() => {
|
||||
if (stateFlushHandleRef.current) return;
|
||||
const epochAtSchedule = paintEpochRef.current;
|
||||
const flush = () => {
|
||||
stateFlushHandleRef.current = null;
|
||||
// A pause/resume/cancel/terminal paint landed after this frame was queued.
|
||||
if (paintEpochRef.current !== epochAtSchedule) return;
|
||||
const storeRows = sftpTransferCenterStore.getOwnerTasks(ownerId);
|
||||
const painted = reconcilePanelRowsWithStore(transfersRef.current, transfersRef.current, storeRows);
|
||||
paintEpochRef.current += 1;
|
||||
transfersRef.current = painted;
|
||||
setTransfersState(painted);
|
||||
};
|
||||
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
|
||||
const id = window.requestAnimationFrame(flush);
|
||||
stateFlushHandleRef.current = { cancel: () => window.cancelAnimationFrame(id) };
|
||||
} else {
|
||||
const id = setTimeout(flush, 16);
|
||||
stateFlushHandleRef.current = { cancel: () => clearTimeout(id) };
|
||||
}
|
||||
}, [ownerId]);
|
||||
useEffect(() => () => {
|
||||
cancelScheduledTransferPaint();
|
||||
}, [cancelScheduledTransferPaint]);
|
||||
// When the retained panel is re-opened, catch up React state from the ref that
|
||||
// kept receiving progress while the surface was hidden. Cancel any frame that
|
||||
// was queued against the hidden snapshot so it cannot paint over this catch-up.
|
||||
useEffect(() => {
|
||||
if (!surfaceVisible) return;
|
||||
paintTransfersNow(transfersRef.current);
|
||||
}, [paintTransfersNow, surfaceVisible]);
|
||||
const setTransfers = useCallback((update: SetStateAction<TransferTask[]>) => {
|
||||
const prev = transfersRef.current;
|
||||
const next = typeof update === "function"
|
||||
? (update as (value: TransferTask[]) => TransferTask[])(prev)
|
||||
: update;
|
||||
if (next === prev) return;
|
||||
sftpTransferCenterStore.publishOwner(ownerId, next);
|
||||
const reconciled = reconcilePanelRowsWithStore(
|
||||
prev,
|
||||
next,
|
||||
sftpTransferCenterStore.getOwnerTasks(ownerId),
|
||||
);
|
||||
if (reconciled === prev) return;
|
||||
sftpTransferCenterStore.publishOwner(ownerId, reconciled);
|
||||
const canonical = sftpTransferCenterStore.getOwnerTasks(ownerId);
|
||||
if (shouldPaintTransferControlNow(prev, canonical)) {
|
||||
paintTransfersNow(canonical);
|
||||
return;
|
||||
}
|
||||
transfersRef.current = canonical;
|
||||
setTransfersState(canonical);
|
||||
}, [ownerId]);
|
||||
scheduleTransfersStateFlush();
|
||||
}, [ownerId, paintTransfersNow, scheduleTransfersStateFlush]);
|
||||
|
||||
// Coalesce progress-only patches to one paint per animation frame. Without
|
||||
// this, each stream tick maps the full transfer list + publishOwner + store
|
||||
@@ -276,9 +431,19 @@ export const useSftpTransfers = ({
|
||||
const prevPending = pendingProgressByIdRef.current.get(taskId) || {};
|
||||
pendingProgressByIdRef.current.set(taskId, { ...prevPending, ...updates });
|
||||
// Keep ref hot for cancel/pause checks without waiting for paint.
|
||||
transfersRef.current = transfersRef.current.map((row) => (
|
||||
row.id === taskId ? { ...row, ...updates } : row
|
||||
));
|
||||
// A byte tick that still says "transferring" must not reopen a latched pause.
|
||||
transfersRef.current = transfersRef.current.map((row) => {
|
||||
if (row.id !== taskId) return row;
|
||||
const merged = { ...row, ...updates };
|
||||
const latched = isTransferOrRootPauseLatched(row.parentTaskId ?? row.id, row.id);
|
||||
if (
|
||||
(latched || row.status === "paused" || row.status === "pausing")
|
||||
&& (updates.status === "transferring" || updates.status === "pending" || updates.status === "queued")
|
||||
) {
|
||||
return { ...merged, status: row.status, speed: 0 };
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
scheduleProgressFlush();
|
||||
}, [scheduleProgressFlush]);
|
||||
useEffect(() => () => {
|
||||
@@ -468,7 +633,7 @@ export const useSftpTransfers = ({
|
||||
setTransfers,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
acquireTransferSession,
|
||||
acquireTransferSession: acquireTransferSession ? acquireTransferSessionLive : undefined,
|
||||
});
|
||||
|
||||
const processTransfer = async (
|
||||
@@ -602,8 +767,9 @@ export const useSftpTransfers = ({
|
||||
// survive browse park and tab hide (pool holders, not panel map ids).
|
||||
if (!sourcePane.connection?.isLocal) {
|
||||
const sourceHostId = task.sourceHostId || sourcePane.connection?.hostId;
|
||||
if (acquireTransferSession && sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(sourceHostId, `${task.id}:work-source`);
|
||||
const acquireSourceSession = acquireTransferSessionRef.current;
|
||||
if (acquireSourceSession && sourceHostId) {
|
||||
sourceWorkLease = await acquireSourceSession(sourceHostId, `${task.id}:work-source`);
|
||||
sourceSftpId = sourceWorkLease.sftpId;
|
||||
}
|
||||
if (!sourceSftpId) {
|
||||
@@ -615,8 +781,9 @@ export const useSftpTransfers = ({
|
||||
|
||||
if (!targetPane.connection?.isLocal) {
|
||||
const targetHostId = task.targetHostId || targetPane.connection?.hostId;
|
||||
if (acquireTransferSession && targetHostId) {
|
||||
targetWorkLease = await acquireTransferSession(targetHostId, `${task.id}:work-target`);
|
||||
const acquireTargetSession = acquireTransferSessionRef.current;
|
||||
if (acquireTargetSession && targetHostId) {
|
||||
targetWorkLease = await acquireTargetSession(targetHostId, `${task.id}:work-target`);
|
||||
targetSftpId = targetWorkLease.sftpId;
|
||||
}
|
||||
if (!targetSftpId) {
|
||||
@@ -1055,8 +1222,9 @@ export const useSftpTransfers = ({
|
||||
const next = transfersRef.current.map((candidate) => (
|
||||
candidate.id === task.id ? canonicalTask : candidate
|
||||
));
|
||||
transfersRef.current = next;
|
||||
setTransfersState(next);
|
||||
// Terminal rows used to setState directly and leave a child frame
|
||||
// queued. That frame later painted the pre-terminal ref.
|
||||
paintTransfersNow(next);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1122,7 +1290,7 @@ export const useSftpTransfers = ({
|
||||
|
||||
updateTask({
|
||||
status: "failed",
|
||||
error: err instanceof Error ? err.message : "Transfer failed",
|
||||
error: unwrapSftpIpcError(err) || "Transfer failed",
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
@@ -1507,8 +1675,8 @@ export const useSftpTransfers = ({
|
||||
try {
|
||||
const result = await retryExternalDragDropFileUpload(task, {
|
||||
getBrowseSftpId: (connectionId) => sftpSessionsRef.current.get(connectionId),
|
||||
acquireTransferSession: acquireTransferSession
|
||||
? (hostId, id) => acquireTransferSession(hostId, id)
|
||||
acquireTransferSession: acquireTransferSessionRef.current
|
||||
? (hostId, id) => acquireTransferSessionLive(hostId, id)
|
||||
: undefined,
|
||||
startStreamTransfer: async (options) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -2000,8 +2168,9 @@ export const useSftpTransfers = ({
|
||||
let sourceWorkLease: TransferConnectionLease | null = null;
|
||||
let workingSourceSftpId = params.sftpId;
|
||||
try {
|
||||
if (acquireTransferSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(
|
||||
const acquireDownloadSession = acquireTransferSessionRef.current;
|
||||
if (acquireDownloadSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireDownloadSession(
|
||||
params.sourceHostId,
|
||||
`${task.id}:work-source`,
|
||||
);
|
||||
@@ -2067,7 +2236,7 @@ export const useSftpTransfers = ({
|
||||
const isCancelled = cancelledTasksRef.current.has(task.id);
|
||||
// Clean up cancelled task tracking to prevent memory leak
|
||||
if (isCancelled) cancelledTasksRef.current.delete(task.id);
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const errMsg = unwrapSftpIpcError(err);
|
||||
patchDownload({
|
||||
status: isCancelled ? "cancelled" : "failed",
|
||||
error: isCancelled ? undefined : errMsg,
|
||||
@@ -2106,11 +2275,16 @@ export const useSftpTransfers = ({
|
||||
// panel list; byte-only ticks update refs without repainting the whole panel.
|
||||
useEffect(() => {
|
||||
return sftpTransferCenterStore.subscribe(() => {
|
||||
const previous = transfersRef.current;
|
||||
const ownerRows = sftpTransferCenterStore.getSnapshot().tasks.filter((task) => task.ownerId === ownerId);
|
||||
transfersRef.current = ownerRows;
|
||||
setTransfersState(ownerRows);
|
||||
if (shouldPaintTransferControlNow(previous, ownerRows)) {
|
||||
paintTransfersNow(ownerRows);
|
||||
return;
|
||||
}
|
||||
scheduleTransfersStateFlush();
|
||||
});
|
||||
}, [ownerId]);
|
||||
}, [ownerId, paintTransfersNow, scheduleTransfersStateFlush]);
|
||||
|
||||
useEffect(() => sftpTransferCenterStore.subscribeProgress(() => {
|
||||
const liveById = new Map(sftpTransferCenterStore.getSnapshot().tasks.map((task) => [task.id, task]));
|
||||
|
||||
@@ -97,6 +97,85 @@ test("background-agent retry after restart uses fresh dedicated recovery instead
|
||||
assert.equal(store.getTask("background-retry")?.status, "completed");
|
||||
});
|
||||
|
||||
for (const [label, error, expectResume] of [
|
||||
["dead channel", "SFTP channel closed", true],
|
||||
["lost session", "SFTP session lost. Please reconnect.", true],
|
||||
["content failure", "Permission denied", false],
|
||||
] as const) {
|
||||
test(`panel Retry after ${label} ${expectResume ? "resumes the same task from its checkpoint" : "keeps the restart-from-0 retry"}`, async () => {
|
||||
const controllerRetries: string[] = [];
|
||||
const dedicatedTasks: TransferTask[] = [];
|
||||
const store = createSftpTransferCenterStore();
|
||||
store.registerOwner("panel-a", {
|
||||
pause: async () => {},
|
||||
resume: async () => {},
|
||||
cancel: async () => {},
|
||||
retry: async (id) => { controllerRetries.push(id); },
|
||||
prioritize: async () => {},
|
||||
dismiss: () => {},
|
||||
});
|
||||
store.setDedicatedResumeHandler(async (task) => {
|
||||
dedicatedTasks.push(task);
|
||||
return { success: true };
|
||||
});
|
||||
store.publishOwner("panel-a", [{
|
||||
...makeTask("dropped", "failed"),
|
||||
totalBytes: 100,
|
||||
checkpointBytes: 37,
|
||||
transferredBytes: 37,
|
||||
error,
|
||||
endTime: Date.now(),
|
||||
}]);
|
||||
|
||||
await store.retry("dropped");
|
||||
|
||||
if (expectResume) {
|
||||
assert.deepEqual(controllerRetries, [], "no fresh-id restart");
|
||||
assert.equal(dedicatedTasks.length, 1);
|
||||
assert.equal(dedicatedTasks[0]?.id, "dropped", "same id keeps the same staged .part");
|
||||
assert.equal(dedicatedTasks[0]?.checkpointBytes, 37);
|
||||
assert.equal(dedicatedTasks[0]?.reconnectRequired, true);
|
||||
} else {
|
||||
assert.deepEqual(controllerRetries, ["dropped"]);
|
||||
assert.equal(dedicatedTasks.length, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("panel Retry after a dropped download or remote copy keeps the owner restart", async () => {
|
||||
for (const direction of ["download", "remote-to-remote"] as const) {
|
||||
const controllerRetries: string[] = [];
|
||||
const dedicatedTasks: TransferTask[] = [];
|
||||
const store = createSftpTransferCenterStore();
|
||||
store.registerOwner("panel-a", {
|
||||
pause: async () => {},
|
||||
resume: async () => {},
|
||||
cancel: async () => {},
|
||||
retry: async (id) => { controllerRetries.push(id); },
|
||||
prioritize: async () => {},
|
||||
dismiss: () => {},
|
||||
});
|
||||
store.setDedicatedResumeHandler(async (task) => {
|
||||
dedicatedTasks.push(task);
|
||||
return { success: true };
|
||||
});
|
||||
store.publishOwner("panel-a", [{
|
||||
...makeTask("dropped", "failed"),
|
||||
direction,
|
||||
totalBytes: 100,
|
||||
checkpointBytes: 37,
|
||||
transferredBytes: 37,
|
||||
error: "SFTP channel closed",
|
||||
endTime: Date.now(),
|
||||
}]);
|
||||
|
||||
await store.retry("dropped");
|
||||
|
||||
assert.deepEqual(controllerRetries, ["dropped"], direction);
|
||||
assert.equal(dedicatedTasks.length, 0, direction);
|
||||
}
|
||||
});
|
||||
|
||||
test("resume without an owner uses a live backend transfer session when available", async (t) => {
|
||||
const resumeCalls: string[] = [];
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "./sftp/globalSftpTransferControl";
|
||||
import { restoreSftpTransferHistoryCooperatively } from "./sftp/transferHistoryRestoreMigration";
|
||||
import { cancelExternalUploadRuntime } from "./sftp/externalUploadRuntime";
|
||||
import { canRetryFromCheckpoint } from "./sftp/retryFromCheckpoint";
|
||||
|
||||
type Listener = () => void;
|
||||
type TaskSettlementObserver = {
|
||||
@@ -365,9 +366,13 @@ export function mergeOwnerPublishedTask(
|
||||
} else if (
|
||||
existingPaused
|
||||
&& (incoming.status === "transferring" || incoming.status === "pending" || incoming.status === "queued")
|
||||
// Only a strictly older epoch is a stale snapshot. Equal/missing epochs must
|
||||
// allow intentional Resume (publishOwner after unlatch) to re-open the row.
|
||||
&& incomingLifecycleEpoch < existingLifecycleEpoch
|
||||
// A strictly older epoch is a stale snapshot. Equal epochs are how Resume
|
||||
// republishes after the latch drops. While the pause latch is still held,
|
||||
// that republish is a pre-pause panel snapshot and must not reopen the row.
|
||||
&& (
|
||||
incomingLifecycleEpoch < existingLifecycleEpoch
|
||||
|| isTransferOrRootPauseLatched(existing.parentTaskId ?? existing.id, existing.id)
|
||||
)
|
||||
) {
|
||||
// Soft-drain / late progress publish must not un-pause without resume epoch.
|
||||
merged.status = existing.status;
|
||||
@@ -2052,6 +2057,24 @@ export function createSftpTransferCenterStore(persistence?: StorePersistence): S
|
||||
const controller = ownerId === "background-agent"
|
||||
? undefined
|
||||
: controllers.get(ownerId ?? "");
|
||||
// Live panel task whose connection dropped mid-file: the stage is valid
|
||||
// up to the checkpoint. Continue through the reconnect-resume path a
|
||||
// restored task uses (same id, so the same staged .part) instead of a
|
||||
// fresh id from byte 0, which strands the old stage on the server.
|
||||
// Orphaned rows (no live owner) keep the restart-from-0 contract below.
|
||||
if (controller && canRetryFromCheckpoint(task)) {
|
||||
tasks = tasks.map((candidate) => candidate.id === taskId ? {
|
||||
...candidate,
|
||||
status: "interrupted" as const,
|
||||
error: undefined,
|
||||
reconnectRequired: true,
|
||||
speed: 0,
|
||||
endTime: undefined,
|
||||
} : candidate);
|
||||
emit();
|
||||
await this.resume(taskId);
|
||||
return;
|
||||
}
|
||||
if (controller) {
|
||||
await controller.retry(taskId);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ContainerRuntimeId, ContainerSourceId } from '../../../domain/systemManager/containerRuntime';
|
||||
import { isContainerSource } from '../../../domain/systemManager/containerRuntime';
|
||||
import { STORAGE_KEY_CONTAINER_RUNTIME } from '../../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
export interface ContainerRuntimePreference {
|
||||
source: ContainerSourceId;
|
||||
engine?: ContainerRuntimeId;
|
||||
context?: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
type PreferenceStore = Record<string, ContainerRuntimePreference>;
|
||||
|
||||
const CONTAINER_ENGINES = new Set<ContainerRuntimeId>(['docker', 'podman', 'nerdctl']);
|
||||
|
||||
function readStore(): PreferenceStore {
|
||||
const value = localStorageAdapter.read<PreferenceStore>(STORAGE_KEY_CONTAINER_RUNTIME);
|
||||
return value && typeof value === 'object' ? value : {};
|
||||
}
|
||||
|
||||
export function readContainerRuntimePreference(hostId: string): ContainerRuntimePreference | null {
|
||||
if (!hostId) return null;
|
||||
const entry = readStore()[hostId];
|
||||
if (!entry || !isContainerSource(entry.source)) return null;
|
||||
return {
|
||||
source: entry.source,
|
||||
engine: entry.engine && CONTAINER_ENGINES.has(entry.engine) ? entry.engine : undefined,
|
||||
context: typeof entry.context === 'string' ? entry.context : undefined,
|
||||
namespace: typeof entry.namespace === 'string' ? entry.namespace : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function writeContainerRuntimePreference(hostId: string, entry: ContainerRuntimePreference): void {
|
||||
if (!hostId || !isContainerSource(entry.source)) return;
|
||||
const store = readStore();
|
||||
store[hostId] = entry;
|
||||
localStorageAdapter.write(STORAGE_KEY_CONTAINER_RUNTIME, store);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { useSystemManagerBackend } from '../useSystemManagerBackend';
|
||||
import { useStableTranslate } from '../useSystemManager';
|
||||
import type {
|
||||
ContainerRuntimeId,
|
||||
ContainerSourceId,
|
||||
KubeContextInfo,
|
||||
} from '../../../domain/systemManager/containerRuntime';
|
||||
import {
|
||||
describeRuntimeFailure,
|
||||
firstContainerRuntime,
|
||||
isAutomaticRuntimeFallback,
|
||||
isKubectlForbiddenError,
|
||||
reconcileContainerSource,
|
||||
sourcesFromCapabilities,
|
||||
} from '../../../domain/systemManager/containerRuntime';
|
||||
import type { SessionCapabilities } from '../../../domain/systemManager/types';
|
||||
import {
|
||||
readContainerRuntimePreference,
|
||||
writeContainerRuntimePreference,
|
||||
} from './containerRuntimePreference';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
interface UseContainerRuntimeSelectionOptions {
|
||||
sessionId: string;
|
||||
hostKey: string;
|
||||
capabilities?: SessionCapabilities;
|
||||
backend: Backend;
|
||||
}
|
||||
|
||||
export function useContainerRuntimeSelection({
|
||||
sessionId,
|
||||
hostKey,
|
||||
capabilities,
|
||||
backend,
|
||||
}: UseContainerRuntimeSelectionOptions) {
|
||||
const stableT = useStableTranslate();
|
||||
const detected = sourcesFromCapabilities(capabilities);
|
||||
const detectedKey = detected.join('|');
|
||||
const [source, setSource] = useState<ContainerSourceId | null>(() => {
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
return reconcileContainerSource(detected, null, remembered);
|
||||
});
|
||||
const [contextName, setContextName] = useState<string | null>(() => (
|
||||
readContainerRuntimePreference(hostKey)?.context ?? null
|
||||
));
|
||||
const [namespaceName, setNamespaceName] = useState<string | null>(() => (
|
||||
readContainerRuntimePreference(hostKey)?.namespace ?? null
|
||||
));
|
||||
const [contexts, setContexts] = useState<KubeContextInfo[]>([]);
|
||||
const [namespaces, setNamespaces] = useState<string[]>([]);
|
||||
const [scopeError, setScopeError] = useState<string | null>(null);
|
||||
const [scopeNonce, setScopeNonce] = useState(0);
|
||||
const skipPersist = useRef(true);
|
||||
const namespaceNameRef = useRef(namespaceName);
|
||||
namespaceNameRef.current = namespaceName;
|
||||
|
||||
useEffect(() => {
|
||||
skipPersist.current = true;
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
setSource(reconcileContainerSource(detected, null, remembered));
|
||||
setContextName(remembered?.context ?? null);
|
||||
setNamespaceName(remembered?.namespace ?? null);
|
||||
setContexts([]);
|
||||
setNamespaces([]);
|
||||
setScopeError(null);
|
||||
// Detection is reconciled by the next effect so a host switch does not
|
||||
// keep the previous host's in-memory selection.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hostKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
setSource((current) => reconcileContainerSource(
|
||||
available,
|
||||
current,
|
||||
readContainerRuntimePreference(hostKey),
|
||||
));
|
||||
}, [capabilities, detectedKey, hostKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipPersist.current) {
|
||||
skipPersist.current = false;
|
||||
return;
|
||||
}
|
||||
if (!source) return;
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
if (isAutomaticRuntimeFallback(remembered, source, available)) return;
|
||||
const engine = source === 'kubernetes'
|
||||
? (remembered?.engine ?? firstContainerRuntime(available) ?? undefined)
|
||||
: source;
|
||||
writeContainerRuntimePreference(hostKey, {
|
||||
source,
|
||||
engine,
|
||||
context: contextName || undefined,
|
||||
namespace: namespaceName || undefined,
|
||||
});
|
||||
}, [capabilities, contextName, hostKey, namespaceName, source]);
|
||||
|
||||
useEffect(() => {
|
||||
if (source !== 'kubernetes') return undefined;
|
||||
let cancelled = false;
|
||||
void backend.kubectl({ sessionId, op: 'contexts' }).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.success || !result.contexts) {
|
||||
setContexts([]);
|
||||
setScopeError(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadKubernetes'),
|
||||
contextName,
|
||||
));
|
||||
return;
|
||||
}
|
||||
setScopeError(null);
|
||||
const available = result.contexts;
|
||||
const kept = contextName && available.some((item) => item.name === contextName)
|
||||
? contextName
|
||||
: null;
|
||||
const next = kept
|
||||
?? available.find((item) => item.current)?.name
|
||||
?? available[0]?.name
|
||||
?? null;
|
||||
setContexts(available);
|
||||
setContextName(next);
|
||||
if (!kept) {
|
||||
const configured = available.find((item) => item.name === next)?.namespace || 'default';
|
||||
setNamespaceName(next ? configured : null);
|
||||
setNamespaces([]);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [backend, contextName, sessionId, source, scopeNonce, stableT]);
|
||||
|
||||
useEffect(() => {
|
||||
if (source !== 'kubernetes' || !contextName) return undefined;
|
||||
let cancelled = false;
|
||||
void backend.kubectl({
|
||||
sessionId,
|
||||
op: 'namespaces',
|
||||
context: contextName,
|
||||
}).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.success || !result.namespaces) {
|
||||
const message = result.error || '';
|
||||
if (isKubectlForbiddenError(message)) {
|
||||
const configured = contexts.find((item) => item.name === contextName)?.namespace || 'default';
|
||||
const fallback = namespaceNameRef.current || configured;
|
||||
setScopeError(null);
|
||||
setNamespaceName((current) => current || fallback);
|
||||
setNamespaces([fallback]);
|
||||
return;
|
||||
}
|
||||
setScopeError(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadKubernetes'),
|
||||
contextName,
|
||||
));
|
||||
return;
|
||||
}
|
||||
setScopeError(null);
|
||||
setNamespaces(result.namespaces);
|
||||
setNamespaceName((current) => {
|
||||
if (current && result.namespaces?.includes(current)) return current;
|
||||
const configured = contexts.find((item) => item.name === contextName)?.namespace;
|
||||
if (configured && result.namespaces?.includes(configured)) return configured;
|
||||
if (result.namespaces?.includes('default')) return 'default';
|
||||
return result.namespaces?.[0] ?? null;
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [backend, contextName, contexts, sessionId, source, scopeNonce, stableT]);
|
||||
|
||||
const selectSource = useCallback((value: string) => {
|
||||
if (value !== 'docker' && value !== 'podman' && value !== 'nerdctl' && value !== 'kubernetes') return;
|
||||
setSource(value);
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
const engine = value === 'kubernetes'
|
||||
? (remembered?.engine ?? firstContainerRuntime(available) ?? undefined)
|
||||
: value;
|
||||
writeContainerRuntimePreference(hostKey, {
|
||||
source: value,
|
||||
engine,
|
||||
context: contextName || undefined,
|
||||
namespace: namespaceName || undefined,
|
||||
});
|
||||
}, [capabilities, contextName, hostKey, namespaceName]);
|
||||
|
||||
const selectContext = useCallback((value: string) => {
|
||||
setContextName(value);
|
||||
const configured = contexts.find((item) => item.name === value)?.namespace ?? null;
|
||||
setNamespaceName(configured);
|
||||
}, [contexts]);
|
||||
|
||||
const reloadScopes = useCallback(() => {
|
||||
setScopeNonce((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
const runtime: ContainerRuntimeId = source && source !== 'kubernetes' ? source : 'docker';
|
||||
const showRuntimeSwitcher = detected.length > 1;
|
||||
|
||||
return {
|
||||
source,
|
||||
selectSource,
|
||||
contextName,
|
||||
selectContext,
|
||||
namespaceName,
|
||||
setNamespaceName,
|
||||
contexts,
|
||||
namespaces,
|
||||
scopeError,
|
||||
reloadScopes,
|
||||
runtime,
|
||||
detected,
|
||||
showRuntimeSwitcher,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, STORAGE_KEY_AI_PRIVACY, STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, STORAGE_KEY_AI_EXTERNAL_MCP_MODE } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
readExternalMcpHttpAllowCustomBind,
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
} from "./useExternalMcpToggleState.ts";
|
||||
import {
|
||||
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
|
||||
applyAIPrivacySettings,
|
||||
readAIPrivacySettings,
|
||||
applyExternalMcpRedactionSettings,
|
||||
readExternalMcpRedactionSettings,
|
||||
bumpExternalMcpEnableGenerationForTests,
|
||||
createExternalMcpStartupSyncPlan,
|
||||
getExternalMcpEnableGenerationForTests,
|
||||
@@ -116,11 +120,12 @@ describe('useExternalMcpToggleState helpers', () => {
|
||||
externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => {
|
||||
received.push(config.sessionIdleTimeoutMinutes);
|
||||
manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout');
|
||||
syncExternalMcpConfig(bridge);
|
||||
await syncExternalMcpConfig(bridge);
|
||||
assert.deepEqual(received, [expected, expected]);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
|
||||
assert.equal(
|
||||
@@ -452,3 +457,334 @@ describe("external MCP HTTP settings", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Shared AI privacy settings', () => {
|
||||
const settings = {
|
||||
enabled: true,
|
||||
redactPrivateIps: true,
|
||||
cidrs: ['192.0.2.0/24'],
|
||||
patterns: ['INTERNAL_[A-Z0-9]+'],
|
||||
};
|
||||
|
||||
it('defaults off and persists only after successful bridge validation', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), {
|
||||
enabled: false, redactPrivateIps: false, cidrs: [], patterns: [],
|
||||
});
|
||||
let resolveConfig: (value: unknown) => void;
|
||||
const apply = applyExternalMcpRedactionSettings(settings, {
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config, { redaction: settings });
|
||||
return new Promise((resolve) => { resolveConfig = resolve; });
|
||||
},
|
||||
});
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), null);
|
||||
resolveConfig!({ ok: true });
|
||||
await apply;
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_PRIVACY), settings);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION), null);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reads legacy privacy preferences until a validated shared preference is applied', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, settings);
|
||||
assert.deepEqual(readAIPrivacySettings(), settings);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), null, 'reads must not migrate or discard data');
|
||||
const changed = { ...settings, redactPrivateIps: false };
|
||||
await applyAIPrivacySettings(changed, { externalMcpSetConfig: async () => ({ ok: true }) });
|
||||
assert.deepEqual(readAIPrivacySettings(), changed);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), changed, 'legacy API must read the same shared policy');
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_PRIVACY), changed);
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION), settings, 'keep legacy storage intact');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('never falls back to legacy settings when a shared preference exists, even if corrupt', () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, settings);
|
||||
const disabled = { enabled: false, redactPrivateIps: false, cidrs: [], patterns: [] };
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, disabled);
|
||||
assert.deepEqual(readAIPrivacySettings(), disabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
assert.throws(readAIPrivacySettings, /Invalid saved AI privacy settings/);
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_PRIVACY);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, '{');
|
||||
assert.throws(readAIPrivacySettings, /Invalid saved AI privacy settings/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('restores the shared policy even when External MCP is disabled', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, false);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const calls: string[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config.redaction, settings);
|
||||
calls.push('shared-policy');
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(`external:${enabled}`); },
|
||||
});
|
||||
assert.deepEqual(calls, ['shared-policy', 'external:false']);
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.deepEqual(readAIPrivacySettings(), settings);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not persist rejected, unverified or failed privacy changes', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const changed = { ...settings, patterns: ['['] };
|
||||
for (const result of [{ ok: false, error: 'Invalid regular expression' }, { ok: false, error: '' }, undefined, {}]) {
|
||||
await assert.rejects(applyExternalMcpRedactionSettings(changed, {
|
||||
externalMcpSetConfig: async () => result,
|
||||
}));
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
}
|
||||
await assert.rejects(applyExternalMcpRedactionSettings(changed, {
|
||||
externalMcpSetConfig: async () => { throw new Error('Disconnected'); },
|
||||
}), /Disconnected/);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('syncs saved privacy settings before persistent startup enable', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const calls: string[] = [];
|
||||
await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config.redaction, settings);
|
||||
calls.push('config');
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(`enabled:${enabled}`); },
|
||||
});
|
||||
assert.deepEqual(calls, ['config', 'enabled:true']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed at startup when saved privacy settings are malformed', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
for (const saved of ['{', 'null', '{}', '{"enabled":"true"}', '{"enabled":false,"redactPrivateIps":false,"cidrs":[],"patterns":[2]}', '{"enabled":false,"redactPrivateIps":false,"cidrs":[],"patterns":[],"unexpected":true}']) {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, saved);
|
||||
const enabledCalls: boolean[] = [];
|
||||
let configCalls = 0;
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async (config: unknown) => {
|
||||
assert.deepEqual(config, { redaction: null });
|
||||
configCalls += 1;
|
||||
return { ok: false, error: 'Shared AI privacy configuration is unavailable.' };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => { enabledCalls.push(enabled); },
|
||||
};
|
||||
const plan = await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.match(plan.configError!, /Invalid saved/);
|
||||
assert.equal(configCalls, 1, 'invalidate shared privacy instead of applying unprotected defaults');
|
||||
assert.deepEqual(enabledCalls, [false]);
|
||||
assert.equal(readExternalMcpStoredEnabled(), false);
|
||||
await assert.rejects(syncExternalMcpConfig(bridge), /Invalid saved/);
|
||||
assert.equal(configCalls, 2);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed when the backend rejects or cannot verify startup config', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
for (const result of [{ ok: false, error: 'Invalid CIDR' }, { ok: false, error: '' }, undefined, {}]) {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const enabledCalls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => result,
|
||||
externalMcpSetEnabled: async (enabled) => { enabledCalls.push(enabled); },
|
||||
});
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.ok(plan.configError);
|
||||
assert.deepEqual(enabledCalls, [false]);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the corrupt-settings error if shared policy invalidation cannot reach the bridge', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
const calls: boolean[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async (config: unknown) => {
|
||||
assert.deepEqual(config, { redaction: null });
|
||||
throw new Error('Disconnected');
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => { calls.push(enabled); },
|
||||
};
|
||||
const plan = await syncExternalMcpStartupState(bridge);
|
||||
assert.match(plan.configError!, /Invalid saved AI privacy settings/);
|
||||
assert.deepEqual(calls, [false]);
|
||||
await assert.rejects(syncExternalMcpConfig(bridge), /Invalid saved AI privacy settings/);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not enable from a stale privacy snapshot changed during config sync', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
const calls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(enabled); },
|
||||
});
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.match(plan.configError!, /changed during startup/);
|
||||
await assert.rejects(syncExternalMcpConfig({
|
||||
externalMcpSetConfig: async () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, { ...settings, cidrs: [] });
|
||||
return { ok: true };
|
||||
},
|
||||
}), /settings changed/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed on a startup config transport failure', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
const calls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => { throw new Error('Disconnected'); },
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(enabled); },
|
||||
});
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.equal(plan.configError, 'Disconnected');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('External MCP manual enable privacy gate', () => {
|
||||
it('waits for validated config and keeps rejected or superseded enables off', async () => {
|
||||
const React = await import('react');
|
||||
const { act, create } = await import('react-test-renderer');
|
||||
const { useExternalMcpToggleState } = await import('./useExternalMcpToggleState.ts');
|
||||
const restoreStorage = installMemoryLocalStorage();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
const events = new EventTarget();
|
||||
let resolveConfig!: (value: unknown) => void;
|
||||
const calls: string[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async () => {
|
||||
calls.push('config');
|
||||
return new Promise((resolve) => { resolveConfig = resolve; });
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => {
|
||||
calls.push(`enabled:${enabled}`);
|
||||
return { ok: true, enabled };
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { value: true, configurable: true });
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {
|
||||
netcatty: bridge,
|
||||
location: { hash: '#/settings' },
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
setInterval,
|
||||
clearInterval,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
let hook!: ReturnType<typeof useExternalMcpToggleState>;
|
||||
const Capture = () => { hook = useExternalMcpToggleState(); return null; };
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
await act(async () => { renderer = create(React.createElement(Capture)); });
|
||||
let pending!: Promise<void>;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
assert.deepEqual(calls, ['config']);
|
||||
assert.equal(hook.enabled, false);
|
||||
assert.equal(readExternalMcpStoredEnabled(), false);
|
||||
await act(async () => { resolveConfig({ ok: false, error: 'Invalid privacy rules' }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:false']);
|
||||
assert.equal(hook.enabled, false);
|
||||
assert.match(hook.error!, /Invalid privacy rules/);
|
||||
|
||||
calls.length = 0;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
await act(async () => { await hook.setEnabled(false); });
|
||||
await act(async () => { resolveConfig({ ok: true }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:false']);
|
||||
assert.equal(hook.enabled, false, 'a newer disable wins over a pending enable');
|
||||
|
||||
calls.length = 0;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
await act(async () => { resolveConfig({ ok: true }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:true']);
|
||||
assert.equal(hook.enabled, true);
|
||||
assert.equal(readExternalMcpStoredEnabled(), true);
|
||||
assert.equal(hook.error, null);
|
||||
} finally {
|
||||
if (renderer) await act(async () => renderer!.unmount());
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (previousAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousAct);
|
||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_PRIVACY,
|
||||
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
@@ -39,6 +41,7 @@ let externalMcpStartupReadyWaitPromise: Promise<void> | null = null;
|
||||
let externalMcpStartupReadyWaitResolve: (() => void) | null = null;
|
||||
/** Bumps on every intentional enable/disable so stale startup applies lose. */
|
||||
let externalMcpEnableGeneration = 0;
|
||||
let externalMcpConfigError: string | null = null;
|
||||
|
||||
function getWindowHash(hash?: string): string {
|
||||
if (typeof hash === 'string') return hash;
|
||||
@@ -76,6 +79,7 @@ export function resetExternalMcpStartupReadyForTests(): void {
|
||||
externalMcpStartupReadyWaitPromise = null;
|
||||
externalMcpStartupReadyWaitResolve = null;
|
||||
externalMcpEnableGeneration = 0;
|
||||
externalMcpConfigError = null;
|
||||
externalMcpStartupSyncPromise = null;
|
||||
}
|
||||
|
||||
@@ -110,6 +114,138 @@ export function waitForExternalMcpStartupReady(hash?: string): Promise<void> {
|
||||
return externalMcpStartupReadyWaitPromise;
|
||||
}
|
||||
|
||||
export type AIPrivacySettings = {
|
||||
enabled: boolean;
|
||||
redactPrivateIps: boolean;
|
||||
cidrs: string[];
|
||||
patterns: string[];
|
||||
};
|
||||
|
||||
export function defaultAIPrivacySettings(): AIPrivacySettings {
|
||||
return { enabled: false, redactPrivateIps: false, cidrs: [], patterns: [] };
|
||||
}
|
||||
|
||||
/** Missing settings are opt-out; corrupt settings must never silently opt out. */
|
||||
export function readAIPrivacySettings(): AIPrivacySettings {
|
||||
// The shared key wins, including invalid data: never fall back to a weaker legacy policy.
|
||||
// Leave the legacy value intact for older installs; Apply writes only the validated shared key.
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY)
|
||||
?? localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION);
|
||||
if (stored === null) return defaultAIPrivacySettings();
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(stored);
|
||||
} catch {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
const settings = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(settings).some((key) => !['enabled', 'redactPrivateIps', 'cidrs', 'patterns'].includes(key))
|
||||
|| typeof settings.enabled !== 'boolean'
|
||||
|| typeof settings.redactPrivateIps !== 'boolean'
|
||||
|| !Array.isArray(settings.cidrs) || !settings.cidrs.every((item) => typeof item === 'string')
|
||||
|| !Array.isArray(settings.patterns) || !settings.patterns.every((item) => typeof item === 'string')
|
||||
) {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
redactPrivateIps: settings.redactPrivateIps,
|
||||
cidrs: [...settings.cidrs],
|
||||
patterns: [...settings.patterns],
|
||||
};
|
||||
}
|
||||
|
||||
function configResponseError(
|
||||
result: unknown,
|
||||
unverifiedMessage = 'External MCP settings could not be verified. The bridge has not been enabled.',
|
||||
): string | null {
|
||||
if (result && typeof result === 'object' && 'ok' in result && result.ok === true) return null;
|
||||
if (result && typeof result === 'object' && 'error' in result && typeof result.error === 'string' && result.error) {
|
||||
return result.error;
|
||||
}
|
||||
return unverifiedMessage;
|
||||
}
|
||||
|
||||
/** Validate in the main process before committing any privacy preference. */
|
||||
export async function applyAIPrivacySettings(
|
||||
settings: AIPrivacySettings,
|
||||
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
|
||||
): Promise<void> {
|
||||
const result = await bridge?.externalMcpSetConfig?.({ redaction: settings });
|
||||
const error = configResponseError(result, 'AI privacy settings could not be verified. Try applying them again.');
|
||||
if (error) throw new Error(error);
|
||||
if (!localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings)) {
|
||||
throw new Error('AI privacy settings could not be saved. Apply them again before restarting.');
|
||||
}
|
||||
externalMcpConfigError = null;
|
||||
emitAIStateChanged(STORAGE_KEY_AI_PRIVACY);
|
||||
}
|
||||
|
||||
function readAIPrivacyState() {
|
||||
try {
|
||||
return { settings: readAIPrivacySettings(), error: null as string | null };
|
||||
} catch (error) {
|
||||
return {
|
||||
settings: defaultAIPrivacySettings(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function useAIPrivacyState() {
|
||||
const [state, setState] = useState(readAIPrivacyState);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const apply = useCallback(async (settings: AIPrivacySettings): Promise<boolean> => {
|
||||
setApplying(true);
|
||||
try {
|
||||
await applyAIPrivacySettings(settings);
|
||||
setState({ settings, error: null });
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
return false;
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === STORAGE_KEY_AI_PRIVACY || event.key === STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) {
|
||||
setState(readAIPrivacyState());
|
||||
}
|
||||
};
|
||||
const onLocalChange = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key === STORAGE_KEY_AI_PRIVACY || key === STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) {
|
||||
setState(readAIPrivacyState());
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
window.addEventListener(AI_STATE_CHANGED_EVENT, onLocalChange);
|
||||
return () => {
|
||||
window.removeEventListener('storage', onStorage);
|
||||
window.removeEventListener(AI_STATE_CHANGED_EVENT, onLocalChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { ...state, applying, apply };
|
||||
}
|
||||
|
||||
/** Compatibility exports for existing External MCP configuration callers. One shared policy only. */
|
||||
export type ExternalMcpRedactionSettings = AIPrivacySettings;
|
||||
export const defaultExternalMcpRedactionSettings = defaultAIPrivacySettings;
|
||||
export const readExternalMcpRedactionSettings = readAIPrivacySettings;
|
||||
export const applyExternalMcpRedactionSettings = applyAIPrivacySettings;
|
||||
export const useExternalMcpRedactionState = useAIPrivacyState;
|
||||
|
||||
export type ExternalMcpHttpSettings = {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
@@ -119,9 +255,11 @@ export type ExternalMcpHttpSettings = {
|
||||
};
|
||||
|
||||
type ExternalMcpConfig = {
|
||||
mode: ExternalMcpMode;
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
mode?: ExternalMcpMode;
|
||||
idleTimeoutMinutes?: number;
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
/** null invalidates shared privacy when persisted settings cannot be read safely. */
|
||||
redaction?: AIPrivacySettings | null;
|
||||
httpEnabled?: boolean;
|
||||
httpPort?: number;
|
||||
httpAllowCustomBind?: boolean;
|
||||
@@ -146,6 +284,7 @@ export type ExternalMcpStartupSyncPlan = {
|
||||
runtimeEnabled: boolean;
|
||||
storedEnabled: boolean;
|
||||
shouldPersistStoredEnabled: boolean;
|
||||
configError?: string;
|
||||
};
|
||||
|
||||
export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode {
|
||||
@@ -385,6 +524,7 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
redaction = defaultAIPrivacySettings(),
|
||||
http = {
|
||||
enabled: false,
|
||||
port: DEFAULT_EXTERNAL_MCP_HTTP_PORT,
|
||||
@@ -398,6 +538,7 @@ export function createExternalMcpStartupSyncPlan({
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
http?: ExternalMcpHttpSettings;
|
||||
redaction?: AIPrivacySettings;
|
||||
}): ExternalMcpStartupSyncPlan {
|
||||
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
|
||||
const storedEnabled = runtimeEnabled;
|
||||
@@ -406,6 +547,7 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
redaction,
|
||||
...httpConfigFromSettings(http),
|
||||
},
|
||||
runtimeEnabled,
|
||||
@@ -415,23 +557,44 @@ export function createExternalMcpStartupSyncPlan({
|
||||
}
|
||||
|
||||
export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
|
||||
return createExternalMcpStartupSyncPlan({
|
||||
let redaction = defaultAIPrivacySettings();
|
||||
let configError: string | undefined;
|
||||
try {
|
||||
redaction = readAIPrivacySettings();
|
||||
} catch (error) {
|
||||
configError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
const plan = createExternalMcpStartupSyncPlan({
|
||||
enabled: readExternalMcpStoredEnabled(),
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
http: readExternalMcpHttpSettings(),
|
||||
redaction,
|
||||
});
|
||||
return configError ? { ...plan, runtimeEnabled: false, configError } : plan;
|
||||
}
|
||||
|
||||
export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void {
|
||||
const http = readExternalMcpHttpSettings();
|
||||
void bridge?.externalMcpSetConfig?.({
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
...httpConfigFromSettings(http),
|
||||
});
|
||||
export async function syncExternalMcpConfig(
|
||||
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
|
||||
): Promise<void> {
|
||||
const plan = readExternalMcpStartupSyncPlan();
|
||||
if (plan.configError) {
|
||||
// A corrupt saved policy must block all managed agents, not only External MCP.
|
||||
// Keep the original recovery error even if notifying the main process fails.
|
||||
try {
|
||||
await bridge?.externalMcpSetConfig?.({ redaction: null });
|
||||
} catch {
|
||||
// The saved policy still needs user repair regardless of bridge availability.
|
||||
}
|
||||
throw new Error(plan.configError);
|
||||
}
|
||||
const result = await bridge?.externalMcpSetConfig?.(plan.config);
|
||||
const error = configResponseError(result);
|
||||
if (error) throw new Error(error);
|
||||
if (JSON.stringify(readAIPrivacySettings()) !== JSON.stringify(plan.config.redaction)) {
|
||||
throw new Error('AI privacy settings changed. Try enabling the bridge again.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -453,10 +616,18 @@ export async function syncExternalMcpStartupState(
|
||||
} catch {
|
||||
// Permission sync is best-effort; the main process keeps its safe default.
|
||||
}
|
||||
let configError = initialPlan.configError;
|
||||
try {
|
||||
await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
|
||||
} catch {
|
||||
// Config sync is best-effort; continue with enable/disable reconcile.
|
||||
if (configError) {
|
||||
// Explicit invalidation is distinct from a rejected draft Apply, which keeps
|
||||
// the previous working policy. Never replace corrupt saved data with defaults.
|
||||
await Promise.resolve(bridge?.externalMcpSetConfig?.({ redaction: null }));
|
||||
} else {
|
||||
const result = await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
|
||||
configError = configResponseError(result) ?? undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
configError ??= error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
// A user toggle during config await invalidates this startup apply.
|
||||
@@ -465,6 +636,18 @@ export async function syncExternalMcpStartupState(
|
||||
}
|
||||
|
||||
const plan = readExternalMcpStartupSyncPlan();
|
||||
if (JSON.stringify(plan.config.redaction) !== JSON.stringify(initialPlan.config.redaction)) {
|
||||
configError = 'AI privacy settings changed during startup. Enable the bridge again.';
|
||||
}
|
||||
if (configError || plan.configError) {
|
||||
// A rejected or unverified config must never expose an unprotected runtime.
|
||||
plan.configError = configError || plan.configError;
|
||||
plan.runtimeEnabled = false;
|
||||
plan.storedEnabled = false;
|
||||
plan.shouldPersistStoredEnabled = readExternalMcpStoredEnabled();
|
||||
}
|
||||
externalMcpConfigError = plan.configError ?? null;
|
||||
emitAIStateChanged(STORAGE_KEY_AI_PRIVACY);
|
||||
if (plan.shouldPersistStoredEnabled) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, plan.storedEnabled);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
@@ -516,24 +699,47 @@ export function useExternalMcpToggleState() {
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
}, []);
|
||||
|
||||
const setEnabled = useCallback((nextEnabled: boolean) => {
|
||||
persistEnabled(nextEnabled);
|
||||
// Peer session windows can mirror the stored switch, but they must not own
|
||||
// the main-process External MCP lifecycle.
|
||||
if (isPeerSessionWindow) return;
|
||||
externalMcpEnableGeneration += 1;
|
||||
// Re-emit after the lifecycle IPC settles so consumers that refetch runtime
|
||||
// status (e.g. Tool Access guidance) do not act on the pre-IPC storage
|
||||
// event, which can still observe the previous enabled state.
|
||||
void Promise.resolve(netcattyBridge.get()?.externalMcpSetEnabled?.(nextEnabled)).finally(() => {
|
||||
const [error, setError] = useState<string | null>(() => externalMcpConfigError);
|
||||
|
||||
const setEnabled = useCallback(async (nextEnabled: boolean) => {
|
||||
// Peer session windows mirror storage, but never own the main lifecycle.
|
||||
if (isPeerSessionWindow) {
|
||||
persistEnabled(nextEnabled);
|
||||
return;
|
||||
}
|
||||
if (!nextEnabled) persistEnabled(false);
|
||||
const generation = ++externalMcpEnableGeneration;
|
||||
setError(null);
|
||||
externalMcpConfigError = null;
|
||||
const bridge = netcattyBridge.get();
|
||||
try {
|
||||
// Even a manual enable must apply and verify saved privacy settings first.
|
||||
if (nextEnabled) await syncExternalMcpConfig(bridge);
|
||||
if (generation !== externalMcpEnableGeneration) return;
|
||||
const result = await bridge?.externalMcpSetEnabled?.(nextEnabled);
|
||||
if (nextEnabled && result && typeof result === 'object' && 'ok' in result && result.ok === false) {
|
||||
throw new Error('error' in result && typeof result.error === 'string' && result.error
|
||||
? result.error : 'External MCP could not be enabled.');
|
||||
}
|
||||
if (generation === externalMcpEnableGeneration) persistEnabled(nextEnabled);
|
||||
} catch (cause) {
|
||||
if (generation !== externalMcpEnableGeneration) return;
|
||||
externalMcpConfigError = cause instanceof Error ? cause.message : String(cause);
|
||||
setError(externalMcpConfigError);
|
||||
if (nextEnabled) {
|
||||
persistEnabled(false);
|
||||
await Promise.resolve(bridge?.externalMcpSetEnabled?.(false)).catch(() => undefined);
|
||||
}
|
||||
} finally {
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
});
|
||||
}
|
||||
}, [isPeerSessionWindow, persistEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncFromStorage = () => {
|
||||
const nextEnabled = readExternalMcpStoredEnabled();
|
||||
setEnabledRaw(nextEnabled);
|
||||
setError(externalMcpConfigError);
|
||||
};
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
@@ -542,7 +748,7 @@ export function useExternalMcpToggleState() {
|
||||
};
|
||||
const handleLocalStateChanged = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
|
||||
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED && key !== STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) return;
|
||||
syncFromStorage();
|
||||
};
|
||||
|
||||
@@ -587,5 +793,5 @@ export function useExternalMcpToggleState() {
|
||||
};
|
||||
}, [enabled, isPeerSessionWindow, persistEnabled]);
|
||||
|
||||
return { enabled, setEnabled };
|
||||
return { enabled, setEnabled, error };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
|
||||
import type {
|
||||
DockerContainerAction,
|
||||
DockerImageManageAction,
|
||||
@@ -94,23 +95,27 @@ export function useSystemManagerBackend() {
|
||||
return bridge.tmuxAction(options);
|
||||
}, []);
|
||||
|
||||
const listDockerContainers = useCallback(async (sessionId: string) => {
|
||||
const listDockerContainers = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listDockerContainers) {
|
||||
return { success: false as const, error: 'listDockerContainers unavailable' };
|
||||
}
|
||||
return bridge.listDockerContainers(sessionId);
|
||||
return bridge.listDockerContainers(sessionId, runtime);
|
||||
}, []);
|
||||
|
||||
const listDockerImages = useCallback(async (sessionId: string) => {
|
||||
const listDockerImages = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listDockerImages) {
|
||||
return { success: false as const, error: 'listDockerImages unavailable' };
|
||||
}
|
||||
return bridge.listDockerImages(sessionId);
|
||||
return bridge.listDockerImages(sessionId, runtime);
|
||||
}, []);
|
||||
|
||||
const getDockerStats = useCallback(async (options: { sessionId: string; ids?: string[] }) => {
|
||||
const getDockerStats = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
ids?: string[];
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getDockerStats) {
|
||||
return { success: false as const, error: 'getDockerStats unavailable' };
|
||||
@@ -155,7 +160,11 @@ export function useSystemManagerBackend() {
|
||||
return bridge.systemServiceAction(options);
|
||||
}, []);
|
||||
|
||||
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
|
||||
const dockerInspect = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerInspect) {
|
||||
return { success: false as const, error: 'dockerInspect unavailable' };
|
||||
@@ -163,7 +172,11 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerInspect(options);
|
||||
}, []);
|
||||
|
||||
const dockerImageInspect = useCallback(async (options: { sessionId: string; imageId: string }) => {
|
||||
const dockerImageInspect = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
imageId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerImageInspect) {
|
||||
return { success: false as const, error: 'dockerImageInspect unavailable' };
|
||||
@@ -176,6 +189,7 @@ export function useSystemManagerBackend() {
|
||||
containerId: string;
|
||||
action: DockerContainerAction;
|
||||
newName?: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerAction) {
|
||||
@@ -184,7 +198,10 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerAction(options);
|
||||
}, []);
|
||||
|
||||
const dockerImageAction = useCallback(async (options: { sessionId: string } & DockerImageManageAction) => {
|
||||
const dockerImageAction = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
} & DockerImageManageAction) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerImageAction) {
|
||||
return { success: false as const, error: 'dockerImageAction unavailable' };
|
||||
@@ -192,6 +209,21 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerImageAction(options);
|
||||
}, []);
|
||||
|
||||
const kubectl = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
op: 'contexts' | 'namespaces' | 'pods' | 'delete';
|
||||
context?: string;
|
||||
namespace?: string;
|
||||
pod?: string;
|
||||
shell?: string;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.kubectl) {
|
||||
return { success: false as const, error: 'kubectl unavailable' };
|
||||
}
|
||||
return bridge.kubectl(options);
|
||||
}, []);
|
||||
|
||||
const openTerminalPopup = useCallback(async (
|
||||
payload: Parameters<NonNullable<NetcattyBridge['openTerminalPopup']>>[0],
|
||||
) => {
|
||||
@@ -223,6 +255,7 @@ export function useSystemManagerBackend() {
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
dockerImageAction,
|
||||
kubectl,
|
||||
openTerminalPopup,
|
||||
}), [
|
||||
probeSystemCapabilities,
|
||||
@@ -245,6 +278,7 @@ export function useSystemManagerBackend() {
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
dockerImageAction,
|
||||
kubectl,
|
||||
openTerminalPopup,
|
||||
]);
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
filterVaultImportKeyPassphrasesAgainstExisting,
|
||||
mergeVaultImportIssues,
|
||||
resolveVaultImportKeyPassphraseConflicts,
|
||||
toVaultImportExistingHostRef,
|
||||
type VaultImportFormat,
|
||||
} from "../../domain/vaultImport";
|
||||
import type { GroupConfig, Host, ManagedSource, SSHKey } from "../../types";
|
||||
@@ -175,6 +176,7 @@ export function useVaultImportHandlers({
|
||||
files,
|
||||
encoding: options?.encoding,
|
||||
masterPassword: options?.masterPassword,
|
||||
existingHosts: hostsRef.current.map(toVaultImportExistingHostRef),
|
||||
signal,
|
||||
onProgress: (progress) => {
|
||||
if (!signal.aborted) updateProgress(progress);
|
||||
|
||||
@@ -235,6 +235,7 @@ test("HostTreeView exposes selected hosts to keyboard and assistive technology",
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*data-tree-depth="0"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*aria-level="1"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*tabindex="0"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*draggable="true"/);
|
||||
});
|
||||
|
||||
test("HostTreeView exposes only one tree item in the tab order", () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '../domain/hostClickBehavior';
|
||||
import { sortByVaultOrder } from '../domain/vaultOrder';
|
||||
import { getVaultHostRowSubtitle } from '../domain/localShellHost';
|
||||
import { isVaultHostDraggable } from '../domain/vaultHostDrag';
|
||||
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
|
||||
import { GroupConfig, GroupNode, Host } from '../types';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
@@ -25,6 +26,8 @@ import { ContextMenu, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { HostNotesIndicator } from './host/HostNotesIndicator';
|
||||
import { Button } from './ui/button';
|
||||
import { handleVaultHostDropToGroup } from './vault/vaultReorderDrag';
|
||||
import { beginVaultHostCardDrag } from './vault/vaultHostDragImage';
|
||||
import { VaultTreeGroupRow, VaultTreeItemRow } from './vault/VaultTreeRow';
|
||||
|
||||
const getTreeGroupDropIntent = (
|
||||
@@ -293,6 +296,7 @@ interface HostTreeViewProps {
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
@@ -334,6 +338,7 @@ interface TreeNodeProps {
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
@@ -381,6 +386,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
@@ -526,9 +532,14 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverDropTarget?.(null);
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
if (handleVaultHostDropToGroup({
|
||||
dataTransfer: e.dataTransfer,
|
||||
groupPath: node.path,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState: () => {},
|
||||
})) return;
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
if (groupPath && getTreeGroupDropIntent(e.currentTarget, e.clientY) === "inside") {
|
||||
moveGroup(groupPath, node.path);
|
||||
}
|
||||
@@ -598,6 +609,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveHostsToGroup={moveHostsToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
@@ -774,11 +786,16 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
? 0
|
||||
: -1}
|
||||
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => {
|
||||
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData("host-id", host.id);
|
||||
const ids = beginVaultHostCardDrag({
|
||||
dataTransfer: e.dataTransfer,
|
||||
hostId: host.id,
|
||||
isMultiSelectMode: Boolean(isMultiSelectMode),
|
||||
selectedHostIds,
|
||||
countLabel: (count) => t("vault.hosts.dragSelectionCount", { count }),
|
||||
});
|
||||
if (!ids) e.preventDefault();
|
||||
}}
|
||||
onClick={() => {
|
||||
const action = resolveHostActivateAction({
|
||||
@@ -909,6 +926,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
@@ -1219,6 +1237,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveHostsToGroup={moveHostsToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
@@ -1311,6 +1330,7 @@ function hostTreeViewAreEqual(prev: HostTreeViewProps, next: HostTreeViewProps):
|
||||
&& prev.onEditGroup === next.onEditGroup
|
||||
&& prev.onDeleteGroup === next.onDeleteGroup
|
||||
&& prev.moveHostToGroup === next.moveHostToGroup
|
||||
&& prev.moveHostsToGroup === next.moveHostsToGroup
|
||||
&& prev.moveGroup === next.moveGroup
|
||||
&& prev.commitInlineGroupRename === next.commitInlineGroupRename
|
||||
&& prev.cancelInlineGroupEdit === next.cancelInlineGroupEdit
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Search,
|
||||
Terminal,
|
||||
TerminalSquare,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
@@ -609,8 +610,8 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
className={rowClass}
|
||||
onClick={() => handleItemSelect(item)}
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground">
|
||||
<Terminal size={16} />
|
||||
<div className="shrink-0 flex items-center justify-center bg-primary text-primary-foreground h-5 w-5 rounded-[5px]">
|
||||
<Zap className="h-3 w-3" fill="currentColor" />
|
||||
</div>
|
||||
<span className="min-w-0 truncate text-sm font-medium">{item.title}</span>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,15 @@ test("plugin importer merge and commit workflow is owned by application state",
|
||||
assert.match(pluginImporterCommitHookSource, /buildPluginImporterSafePreview/);
|
||||
});
|
||||
|
||||
test("host list section receives marquee selection and batched group move", () => {
|
||||
const sectionStart = vaultViewLayoutSource.indexOf("<VaultHostListSection");
|
||||
const sectionEnd = vaultViewLayoutSource.indexOf("/>", sectionStart);
|
||||
const section = vaultViewLayoutSource.slice(sectionStart, sectionEnd);
|
||||
assert.match(section, /\bmoveHostsToGroup,/);
|
||||
assert.match(section, /\bsetIsMultiSelectMode,/);
|
||||
assert.match(section, /\bsetSelectedHostIds,/);
|
||||
});
|
||||
|
||||
test("Vault import entry is disabled while an import is running", () => {
|
||||
assert.match(vaultViewLayoutSource, /disabled=\{importProgress\?\.status === "running"\}/);
|
||||
assert.match(vaultViewLayoutSource, /if \(importProgress\?\.status === "running"\) return;/);
|
||||
|
||||
@@ -51,6 +51,7 @@ import { CopilotCliCard } from "./ai/CopilotCliCard";
|
||||
import { CodebuddyCard } from "./ai/CodebuddyCard";
|
||||
import { SafetySettings } from "./ai/SafetySettings";
|
||||
import { ExternalMcpCard } from "./ai/ExternalMcpCard";
|
||||
import { AIPrivacySection } from "./ai/AIPrivacySection";
|
||||
import { ToolAccessGuidance } from "./ai/ToolAccessGuidance";
|
||||
import { PermissionGrantsSettings } from "./ai/PermissionGrantsSettings";
|
||||
import { useAIPermissionGrantsState } from "../../../application/state/useAIPermissionGrantsState";
|
||||
@@ -1297,6 +1298,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<AIPrivacySection />
|
||||
|
||||
<SettingsSection anchorId="ai-external-mcp" title={t('ai.externalMcp.title')}>
|
||||
<ExternalMcpCard />
|
||||
</SettingsSection>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { I18nProvider } from '../../../../application/i18n/I18nProvider';
|
||||
import { STORAGE_KEY_AI_PRIVACY } from '../../../../infrastructure/config/storageKeys';
|
||||
import { AIPrivacySection } from './AIPrivacySection';
|
||||
|
||||
function installEnvironment(setConfig: (config: unknown) => Promise<unknown>) {
|
||||
const values = new Map<string, string>();
|
||||
const events = new EventTarget();
|
||||
const globals = {
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
localStorage: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
},
|
||||
window: {
|
||||
netcatty: { externalMcpSetConfig: setConfig },
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
},
|
||||
};
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [name, value] of Object.entries(globals)) {
|
||||
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
|
||||
}
|
||||
return {
|
||||
values,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function view() {
|
||||
return <I18nProvider locale="en"><AIPrivacySection /></I18nProvider>;
|
||||
}
|
||||
|
||||
test('privacy settings default off and explain their limits and connection scope', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const markup = JSON.stringify(renderer!.toJSON());
|
||||
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === false).length, 2);
|
||||
assert.match(markup, /cannot detect every secret/);
|
||||
assert.match(markup, /AI privacy/);
|
||||
assert.match(markup, /Catty, integrated agents and external MCP clients/);
|
||||
assert.match(markup, /Only Netcatty-managed data and tools are covered/);
|
||||
assert.match(markup, /native tools, direct file access and other MCP servers/);
|
||||
assert.match(markup, /Binary tool responses are blocked/);
|
||||
assert.match(markup, /one managed conversation or external MCP connection/);
|
||||
assert.match(markup, /reconnect external MCP clients/);
|
||||
assert.match(markup, /start a new managed conversation/);
|
||||
assert.match(markup, /Additional IP ranges/);
|
||||
assert.match(markup, /Custom regular expressions/);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('AI privacy is shared Tool Access UI, not nested under External MCP', async () => {
|
||||
const settingsTab = await readFile(new URL('../SettingsAITab.tsx', import.meta.url), 'utf8');
|
||||
const externalCard = await readFile(new URL('./ExternalMcpCard.tsx', import.meta.url), 'utf8');
|
||||
const toolsTab = settingsTab.split('<TabsContent value="tools"')[1].split('</TabsContent>')[0];
|
||||
assert.match(toolsTab, /<\/SettingsSection>\s*<AIPrivacySection \/>\s*<SettingsSection anchorId="ai-external-mcp"/);
|
||||
assert.doesNotMatch(externalCard, /AIPrivacySection|ExternalMcpRedactionSection/);
|
||||
});
|
||||
|
||||
test('privacy drafts apply together, retain rejected input and persist only validated settings', async () => {
|
||||
const calls: unknown[] = [];
|
||||
let accepted = false;
|
||||
const env = installEnvironment(async (config) => {
|
||||
calls.push(config);
|
||||
return accepted ? { ok: true } : { ok: false, error: 'Invalid privacy rules.' };
|
||||
});
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const root = renderer!.root;
|
||||
const privacySwitch = () => root.findAllByType('button').find((node) => (
|
||||
node.props['aria-label'] === 'Enable text redaction'
|
||||
))!;
|
||||
const apply = () => root.findAllByType('button').find((node) => node.props.children === 'Apply')!;
|
||||
await act(async () => { privacySwitch().props.onClick(); });
|
||||
await act(async () => {
|
||||
root.findAllByType('textarea')[0].props.onChange({ currentTarget: { value: '192.0.2.0/24\n\n2001:db8::/32' } });
|
||||
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: '[' } });
|
||||
});
|
||||
assert.equal(calls.length, 0, 'draft edits must not change runtime or storage');
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
await act(async () => { apply().props.onClick(); });
|
||||
assert.equal(root.findAllByType('textarea')[1].props.value, '[');
|
||||
assert.match(root.findByProps({ role: 'alert' }).children.join(''), /Invalid privacy rules/);
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
|
||||
accepted = true;
|
||||
await act(async () => {
|
||||
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: 'INTERNAL_[A-Z0-9]+' } });
|
||||
});
|
||||
await act(async () => { apply().props.onClick(); });
|
||||
const saved = JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!);
|
||||
assert.deepEqual(saved, {
|
||||
enabled: true,
|
||||
redactPrivateIps: false,
|
||||
cidrs: ['192.0.2.0/24', '2001:db8::/32'],
|
||||
patterns: ['INTERNAL_[A-Z0-9]+'],
|
||||
});
|
||||
assert.deepEqual(calls[1], { redaction: saved });
|
||||
assert.equal(root.findAllByProps({ role: 'alert' }).length, 0);
|
||||
assert.match(root.findByProps({ role: 'status' }).children.join(''), /new managed conversation/);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed saved privacy settings remain visible as a recoverable error', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
env.values.set(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
await act(async () => { renderer = create(view()); });
|
||||
assert.match(renderer!.root.findByProps({ role: 'alert' }).children.join(''), /Invalid saved AI privacy settings/);
|
||||
assert.ok(renderer!.root.findAllByType('button').some((node) => node.props.children === 'Apply'));
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('privacy apply disables draft controls until shared validation settles', async () => {
|
||||
let resolveConfig!: (result: unknown) => void;
|
||||
const env = installEnvironment(() => new Promise((resolve) => { resolveConfig = resolve; }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const root = renderer!.root;
|
||||
await act(async () => {
|
||||
root.findAllByType('button').find((node) => node.props['aria-label'] === 'Enable text redaction')!.props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
root.findAllByType('button').find((node) => node.props.children === 'Apply')!.props.onClick();
|
||||
});
|
||||
assert.ok(root.findAllByType('button').every((node) => node.props.disabled));
|
||||
assert.ok(root.findAllByType('textarea').every((node) => node.props.disabled));
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
await act(async () => { resolveConfig({ ok: true }); });
|
||||
assert.ok(root.findAllByType('button').every((node) => !node.props.disabled));
|
||||
assert.equal(JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!).enabled, true);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('shared privacy controls refresh when another window changes the shared preference', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const settings = { enabled: true, redactPrivateIps: true, cidrs: ['192.0.2.0/24'], patterns: ['INTERNAL_.*'] };
|
||||
env.values.set(STORAGE_KEY_AI_PRIVACY, JSON.stringify(settings));
|
||||
const event = new Event('storage');
|
||||
Object.defineProperty(event, 'key', { value: STORAGE_KEY_AI_PRIVACY });
|
||||
await act(async () => { window.dispatchEvent(event); });
|
||||
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === true).length, 2);
|
||||
assert.equal(renderer!.root.findAllByType('textarea')[0].props.value, '192.0.2.0/24');
|
||||
assert.equal(renderer!.root.findAllByType('textarea')[1].props.value, 'INTERNAL_.*');
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { useAIPrivacyState } from "../../../../application/state/useExternalMcpToggleState";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { SettingCard, SettingRow, SettingsSection, Toggle } from "../../settings-ui";
|
||||
|
||||
export const AIPrivacySection: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { settings, error, applying, apply } = useAIPrivacyState();
|
||||
const [enabled, setEnabled] = useState(settings.enabled);
|
||||
const [privateIps, setPrivateIps] = useState(settings.redactPrivateIps);
|
||||
const [cidrs, setCidrs] = useState(settings.cidrs.join("\n"));
|
||||
const [patterns, setPatterns] = useState(settings.patterns.join("\n"));
|
||||
const [applied, setApplied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(settings.enabled);
|
||||
setPrivateIps(settings.redactPrivateIps);
|
||||
setCidrs(settings.cidrs.join("\n"));
|
||||
setPatterns(settings.patterns.join("\n"));
|
||||
}, [settings]);
|
||||
|
||||
const onApply = async () => {
|
||||
setApplied(false);
|
||||
const saved = await apply({
|
||||
enabled,
|
||||
redactPrivateIps: privateIps,
|
||||
cidrs: cidrs.split(/\r?\n/).map((line) => line.trim()).filter(Boolean),
|
||||
patterns: patterns.split(/\r?\n/).filter((line) => line.trim().length > 0),
|
||||
});
|
||||
setApplied(saved);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection anchorId="ai-privacy" title={t("ai.privacy.title")}>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.description")}</p>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.boundary")}</p>
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingRow label={t("ai.privacy.enable")}>
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
onChange={(next) => { setEnabled(next); setApplied(false); }}
|
||||
ariaLabel={t("ai.privacy.enable")}
|
||||
disabled={applying}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("ai.privacy.privateIps")}
|
||||
description={t("ai.privacy.privateIps.description")}
|
||||
>
|
||||
<Toggle
|
||||
checked={privateIps}
|
||||
onChange={(next) => { setPrivateIps(next); setApplied(false); }}
|
||||
ariaLabel={t("ai.privacy.privateIps")}
|
||||
disabled={applying}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.cidrs")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.cidrs.description")}</span>
|
||||
<textarea
|
||||
value={cidrs}
|
||||
onChange={(event) => { setCidrs(event.currentTarget.value); setApplied(false); }}
|
||||
aria-label={t("ai.privacy.cidrs")}
|
||||
placeholder={"192.0.2.0/24\n2001:db8::/32"}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
disabled={applying}
|
||||
className="w-full resize-y rounded-md border border-border/60 bg-background px-2 py-1.5 font-mono text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.patterns")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.patterns.description")}</span>
|
||||
<textarea
|
||||
value={patterns}
|
||||
onChange={(event) => { setPatterns(event.currentTarget.value); setApplied(false); }}
|
||||
aria-label={t("ai.privacy.patterns")}
|
||||
placeholder="INTERNAL_[A-Z0-9]+"
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
disabled={applying}
|
||||
className="w-full resize-y rounded-md border border-border/60 bg-background px-2 py-1.5 font-mono text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.connectionScope")}</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-amber-500">{t("ai.privacy.reconnect")}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => { void onApply(); }} disabled={applying}>
|
||||
{t(applying ? "ai.privacy.applying" : "ai.privacy.apply")}
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("ai.privacy.failed")} {error}
|
||||
</p>
|
||||
) : applied ? (
|
||||
<p role="status" className="text-xs text-emerald-500">{t("ai.privacy.applied")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
@@ -252,7 +252,7 @@ export function buildCursorSnippet(launcherPath: string, discoveryPath?: string
|
||||
|
||||
export const ExternalMcpCard: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { enabled, setEnabled } = useExternalMcpToggleState();
|
||||
const { enabled, setEnabled, error: configError } = useExternalMcpToggleState();
|
||||
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
|
||||
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
|
||||
const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen());
|
||||
@@ -681,8 +681,7 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
checked={enabled}
|
||||
onChange={(nextEnabled) => {
|
||||
setActionMessage(null);
|
||||
setEnabled(nextEnabled);
|
||||
window.setTimeout(() => { void refreshStatus(); }, 0);
|
||||
void setEnabled(nextEnabled).then(() => refreshStatus());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -989,8 +988,8 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
{actionMessage.text}
|
||||
</div>
|
||||
) : null}
|
||||
{status?.error ? (
|
||||
<div className="text-xs text-destructive">{status.error}</div>
|
||||
{configError || status?.error ? (
|
||||
<div role="alert" className="text-xs text-destructive">{configError || status?.error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -177,6 +177,7 @@ export interface NetcattyAiBridge {
|
||||
mode?: 'temporary' | 'persistent';
|
||||
idleTimeoutMinutes?: number;
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
redaction?: import("../../../../application/state/useExternalMcpToggleState").ExternalMcpRedactionSettings;
|
||||
httpEnabled?: boolean;
|
||||
httpPort?: number;
|
||||
httpAllowCustomBind?: boolean;
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { TerminalSession } from '../../types';
|
||||
import type { DockerContainerAction, DockerContainerInfo, DockerStatInfo, TerminalPopupIcon } from '../../domain/systemManager/types';
|
||||
import { dockerContainerInfoEqual } from '../../domain/systemManager/pollEquals';
|
||||
import { getContainerFlags, getContainerTone } from '../../domain/systemManager/containerState';
|
||||
import { buildDockerExecShellCommand, buildDockerLogsCommand } from '../../domain/systemManager/dockerShell';
|
||||
import type { ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
|
||||
import { buildContainerExecShellCommand, buildContainerLogsCommand, describeRuntimeFailure } from '../../domain/systemManager/containerRuntime';
|
||||
import { DockerContainerDetail } from './DockerContainerDetail';
|
||||
import { DockerImageIcon } from './DockerImageIcon';
|
||||
import { useStableListOrder, mergePollListByKey } from './listStable';
|
||||
@@ -59,6 +60,7 @@ async function buildContainerPopupIcon(image: string): Promise<TerminalPopupIcon
|
||||
interface DockerContainersPanelProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
runtime?: ContainerRuntimeId;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
@@ -157,6 +159,7 @@ const DockerContainerRow = memo(function DockerContainerRow({
|
||||
export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
sessionId,
|
||||
parentSession,
|
||||
runtime = 'docker',
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
@@ -179,15 +182,15 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
setConfirmAction(null);
|
||||
setPendingAction(null);
|
||||
setSelectedId(null);
|
||||
}, [sessionId]);
|
||||
}, [runtime, sessionId]);
|
||||
|
||||
const containersFetcher = useCallback(async () => {
|
||||
const result = await backend.listDockerContainers(sessionId);
|
||||
const result = await backend.listDockerContainers(sessionId, runtime);
|
||||
if (!result.success || !result.containers) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadDocker'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDocker')));
|
||||
}
|
||||
return result.containers;
|
||||
}, [backend, sessionId, stableT]);
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
|
||||
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
|
||||
const { data: containers, error, loading, refresh } = usePolling<DockerContainerInfo[]>(
|
||||
@@ -195,7 +198,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
listIntervalMs,
|
||||
isVisible || warmupEnabled,
|
||||
(prev, next) => mergePollListByKey(prev, next, (c) => c.id, dockerContainerInfoEqual),
|
||||
{ poll: isVisible, resetKey: sessionId },
|
||||
{ poll: isVisible, resetKey: `${sessionId}:${runtime}` },
|
||||
);
|
||||
|
||||
const matched = useMemo<DockerContainerInfo[]>(() => {
|
||||
@@ -240,12 +243,12 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
);
|
||||
const statsFetcher = useCallback(async () => {
|
||||
if (statContainerIds.length === 0) return [];
|
||||
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds });
|
||||
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds, runtime });
|
||||
if (!result.success || !result.stats) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadDockerStats'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDockerStats')));
|
||||
}
|
||||
return result.stats;
|
||||
}, [backend, sessionId, stableT, statContainerIds]);
|
||||
}, [backend, runtime, sessionId, stableT, statContainerIds]);
|
||||
|
||||
const statsIntervalMs = Math.max(2, statsRefreshIntervalSec) * 1000;
|
||||
const { data: stats, loading: statsLoading } = usePolling<DockerStatInfo[]>(
|
||||
@@ -266,18 +269,19 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
}, [stats]);
|
||||
|
||||
const getContainerInspectKey = useCallback((container: DockerContainerInfo) => (
|
||||
`${sessionId}:${container.id}`
|
||||
), [sessionId]);
|
||||
`${sessionId}:${runtime}:${container.id}`
|
||||
), [runtime, sessionId]);
|
||||
const fetchContainerInspect = useCallback(async (container: DockerContainerInfo) => {
|
||||
const result = await backend.dockerInspect({
|
||||
sessionId,
|
||||
containerId: container.id.slice(0, 12),
|
||||
runtime,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.actionFailed')));
|
||||
}
|
||||
return result.inspect ?? null;
|
||||
}, [backend, sessionId, stableT]);
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
const {
|
||||
records: inspectByContainerId,
|
||||
loadRecord: loadContainerInspect,
|
||||
@@ -305,9 +309,12 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
}
|
||||
setPendingAction({ id: containerId, action });
|
||||
try {
|
||||
const result = await backend.dockerAction({ sessionId, containerId, action, newName });
|
||||
const result = await backend.dockerAction({ sessionId, containerId, action, newName, runtime });
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const affectedContainer = (containers ?? []).find((container) => (
|
||||
@@ -332,6 +339,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
invalidateContainerInspectMatching,
|
||||
refresh,
|
||||
refreshContainerInspect,
|
||||
runtime,
|
||||
sessionId,
|
||||
t,
|
||||
]);
|
||||
@@ -360,7 +368,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
backend,
|
||||
parentSession,
|
||||
`docker: ${container.name || id}`,
|
||||
buildDockerExecShellCommand(id),
|
||||
buildContainerExecShellCommand(runtime, id, parentSession.shellType),
|
||||
{ icon: await buildContainerPopupIcon(container.image) },
|
||||
);
|
||||
if (!result.success) {
|
||||
@@ -372,7 +380,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
});
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
}
|
||||
}, [backend, parentSession, sessionId, t]);
|
||||
}, [backend, parentSession, runtime, sessionId, t]);
|
||||
|
||||
const openLogs = useCallback(async (container: DockerContainerInfo) => {
|
||||
const id = container.id.slice(0, 12);
|
||||
@@ -387,7 +395,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
backend,
|
||||
parentSession,
|
||||
`logs: ${container.name || id}`,
|
||||
buildDockerLogsCommand(id),
|
||||
buildContainerLogsCommand(runtime, id, parentSession.shellType),
|
||||
{ icon: await buildContainerPopupIcon(container.image) },
|
||||
);
|
||||
if (!result.success) {
|
||||
@@ -399,7 +407,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
});
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
}
|
||||
}, [backend, parentSession, sessionId, t]);
|
||||
}, [backend, parentSession, runtime, sessionId, t]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="docker-containers">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Layers, Loader2, Tag, Trash2 } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { describeRuntimeFailure, type ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
|
||||
import { dockerImageRowKey, type DockerImageInfo } from '../../domain/systemManager/types';
|
||||
import { dockerImageInfoEqual } from '../../domain/systemManager/pollEquals';
|
||||
import { DockerImageIcon } from './DockerImageIcon';
|
||||
@@ -35,6 +36,7 @@ type PendingImageConfirm =
|
||||
|
||||
interface DockerImagesPanelProps {
|
||||
sessionId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
@@ -89,6 +91,7 @@ const DockerImageRow = memo(function DockerImageRow({
|
||||
|
||||
export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
sessionId,
|
||||
runtime = 'docker',
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
@@ -111,15 +114,15 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
// Clear busy so a hung/in-flight action from the previous session cannot
|
||||
// leave the new session's confirm dialog permanently disabled.
|
||||
setActionBusy(false);
|
||||
}, [sessionId]);
|
||||
}, [runtime, sessionId]);
|
||||
|
||||
const imagesFetcher = useCallback(async () => {
|
||||
const result = await backend.listDockerImages(sessionId);
|
||||
const result = await backend.listDockerImages(sessionId, runtime);
|
||||
if (!result.success || !result.images) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadDockerImages'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDockerImages')));
|
||||
}
|
||||
return result.images;
|
||||
}, [backend, sessionId, stableT]);
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
|
||||
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
|
||||
const { data: images, error, loading, refresh } = usePolling<DockerImageInfo[]>(
|
||||
@@ -127,7 +130,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
listIntervalMs,
|
||||
isVisible || warmupEnabled,
|
||||
(prev, next) => mergePollListByKey(prev, next, dockerImageRowKey, dockerImageInfoEqual),
|
||||
{ poll: isVisible, resetKey: sessionId },
|
||||
{ poll: isVisible, resetKey: `${sessionId}:${runtime}` },
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -154,18 +157,19 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const displayList = useStableListOrder(filtered, dockerImageRowKey, query, compareImages);
|
||||
|
||||
const getImageInspectKey = useCallback((image: DockerImageInfo) => (
|
||||
`${sessionId}:${dockerImageRowKey(image)}`
|
||||
), [sessionId]);
|
||||
`${sessionId}:${runtime}:${dockerImageRowKey(image)}`
|
||||
), [runtime, sessionId]);
|
||||
const fetchImageInspect = useCallback(async (image: DockerImageInfo) => {
|
||||
const result = await backend.dockerImageInspect({
|
||||
sessionId,
|
||||
imageId: image.id.slice(0, 12),
|
||||
runtime,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.actionFailed')));
|
||||
}
|
||||
return result.inspect ?? null;
|
||||
}, [backend, sessionId, stableT]);
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
const {
|
||||
records: inspectByImageKey,
|
||||
loadRecord: loadImageInspect,
|
||||
@@ -184,15 +188,26 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const actionGeneration = actionGenerationRef.current;
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const tagged = Boolean(
|
||||
image.repository
|
||||
&& image.tag
|
||||
&& image.repository !== '<none>'
|
||||
&& image.tag !== '<none>',
|
||||
);
|
||||
const result = await backend.dockerImageAction({
|
||||
sessionId,
|
||||
runtime,
|
||||
action: 'rm',
|
||||
imageId: image.id.slice(0, 12),
|
||||
force: image.tag === '<none>',
|
||||
force: !tagged,
|
||||
...(tagged ? { repository: image.repository, tag: image.tag } : {}),
|
||||
});
|
||||
if (actionGenerationRef.current !== actionGeneration) return;
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (selectedId === dockerImageRowKey(image)) {
|
||||
@@ -205,7 +220,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
setActionBusy(false);
|
||||
}
|
||||
}
|
||||
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, selectedId, sessionId, t]);
|
||||
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, runtime, selectedId, sessionId, t]);
|
||||
|
||||
const handleRemove = useCallback((image: DockerImageInfo) => {
|
||||
const label = image.name || image.id.slice(0, 12);
|
||||
@@ -216,10 +231,13 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const actionGeneration = actionGenerationRef.current;
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const result = await backend.dockerImageAction({ sessionId, action: 'prune', all });
|
||||
const result = await backend.dockerImageAction({ sessionId, runtime, action: 'prune', all });
|
||||
if (actionGenerationRef.current !== actionGeneration) return;
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
@@ -228,7 +246,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
setActionBusy(false);
|
||||
}
|
||||
}
|
||||
}, [backend, refresh, sessionId, t]);
|
||||
}, [backend, refresh, runtime, sessionId, t]);
|
||||
|
||||
const handlePrune = useCallback((all: boolean) => {
|
||||
setConfirmTarget({ kind: 'prune', all });
|
||||
@@ -237,13 +255,17 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const handleTagSubmit = async (image: DockerImageInfo, repository: string, tag: string) => {
|
||||
const result = await backend.dockerImageAction({
|
||||
sessionId,
|
||||
runtime,
|
||||
action: 'tag',
|
||||
imageId: image.id.slice(0, 12),
|
||||
repository,
|
||||
tag: tag || 'latest',
|
||||
});
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { Box, Layers } from 'lucide-react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useContainerRuntimeSelection } from '../../application/state/systemManager/useContainerRuntimeSelection';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import type { ContainerSourceId } from '../../domain/systemManager/containerRuntime';
|
||||
import type { SessionCapabilities } from '../../domain/systemManager/types';
|
||||
import type { TerminalSession } from '../../types';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { DockerContainersPanel } from './DockerContainersPanel';
|
||||
import { DockerImagesPanel } from './DockerImagesPanel';
|
||||
import { KubernetesPodsPanel } from './KubernetesPodsPanel';
|
||||
import { SystemPanelShell } from './SystemPanelUi';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
type DockerSubTab = 'containers' | 'images';
|
||||
|
||||
const RUNTIME_LABEL: Record<ContainerSourceId, string> = {
|
||||
docker: 'systemManager.runtime.docker',
|
||||
podman: 'systemManager.runtime.podman',
|
||||
nerdctl: 'systemManager.runtime.nerdctl',
|
||||
kubernetes: 'systemManager.runtime.kubernetes',
|
||||
};
|
||||
|
||||
interface DockerManagerTabProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
capabilities?: SessionCapabilities;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
@@ -21,9 +34,48 @@ interface DockerManagerTabProps {
|
||||
statsRefreshIntervalSec: number;
|
||||
}
|
||||
|
||||
function CompactSelect({
|
||||
value,
|
||||
options,
|
||||
ariaLabel,
|
||||
section,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
options: { id: string; label: string }[];
|
||||
ariaLabel: string;
|
||||
section: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
aria-label={ariaLabel}
|
||||
data-section={section}
|
||||
className={cn(
|
||||
"h-6 w-auto max-w-[9.5rem] gap-1 rounded border-border/40 bg-transparent px-2 py-0 text-[11px] shadow-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id} className="text-xs">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
sessionId,
|
||||
parentSession,
|
||||
capabilities,
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
@@ -32,6 +84,26 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
}: DockerManagerTabProps) {
|
||||
const { t } = useI18n();
|
||||
const [subTab, setSubTab] = useState<DockerSubTab>('containers');
|
||||
const {
|
||||
source,
|
||||
selectSource,
|
||||
contextName,
|
||||
selectContext,
|
||||
namespaceName,
|
||||
setNamespaceName,
|
||||
contexts,
|
||||
namespaces,
|
||||
scopeError,
|
||||
reloadScopes,
|
||||
runtime,
|
||||
detected,
|
||||
showRuntimeSwitcher,
|
||||
} = useContainerRuntimeSelection({
|
||||
sessionId,
|
||||
hostKey: parentSession.hostId || sessionId,
|
||||
capabilities,
|
||||
backend,
|
||||
});
|
||||
|
||||
const tabs: { id: DockerSubTab; icon: typeof Box; label: string }[] = [
|
||||
{ id: 'containers', icon: Box, label: t('systemManager.docker.subTabs.containers') },
|
||||
@@ -40,8 +112,8 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
|
||||
return (
|
||||
<SystemPanelShell section="system-manager-docker">
|
||||
<div className="shrink-0 flex items-center gap-0.5 px-2 py-1 border-b border-border/30">
|
||||
{tabs.map(({ id, icon: Icon, label }) => (
|
||||
<div className="shrink-0 flex items-center gap-1 px-2 py-1 border-b border-border/30">
|
||||
{source !== 'kubernetes' && tabs.map(({ id, icon: Icon, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
@@ -57,29 +129,79 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{showRuntimeSwitcher && source && detected.includes(source) && (
|
||||
<CompactSelect
|
||||
value={source}
|
||||
ariaLabel={t('systemManager.runtime.switch')}
|
||||
section="container-runtime-switcher"
|
||||
className="shrink-0"
|
||||
options={detected.map((id) => ({ id, label: t(RUNTIME_LABEL[id]) }))}
|
||||
onChange={selectSource}
|
||||
/>
|
||||
)}
|
||||
{source === 'kubernetes' && contextName && contexts.some((item) => item.name === contextName) && (
|
||||
<CompactSelect
|
||||
value={contextName}
|
||||
ariaLabel={t('systemManager.k8s.context')}
|
||||
section="k8s-context-select"
|
||||
className="w-full min-w-0 max-w-none flex-1"
|
||||
options={contexts.map((item) => ({ id: item.name, label: item.name }))}
|
||||
onChange={selectContext}
|
||||
/>
|
||||
)}
|
||||
{source === 'kubernetes' && namespaceName && namespaces.includes(namespaceName) && (
|
||||
<CompactSelect
|
||||
value={namespaceName}
|
||||
ariaLabel={t('systemManager.k8s.namespace')}
|
||||
section="k8s-namespace-select"
|
||||
className="shrink-0"
|
||||
options={namespaces.map((name) => ({ id: name, label: name }))}
|
||||
onChange={setNamespaceName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'containers' && 'hidden')}>
|
||||
<DockerContainersPanel
|
||||
{source && source !== 'kubernetes' && (
|
||||
<>
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'containers' && 'hidden')}>
|
||||
<DockerContainersPanel
|
||||
sessionId={sessionId}
|
||||
parentSession={parentSession}
|
||||
runtime={runtime}
|
||||
isVisible={isVisible && subTab === 'containers'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'containers')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
statsRefreshIntervalSec={statsRefreshIntervalSec}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'images' && 'hidden')}>
|
||||
<DockerImagesPanel
|
||||
sessionId={sessionId}
|
||||
runtime={runtime}
|
||||
isVisible={isVisible && subTab === 'images'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'images')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{source === 'kubernetes' && (
|
||||
<KubernetesPodsPanel
|
||||
sessionId={sessionId}
|
||||
parentSession={parentSession}
|
||||
isVisible={isVisible && subTab === 'containers'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'containers')}
|
||||
contextName={contextName}
|
||||
namespaceName={namespaceName}
|
||||
scopeError={scopeError}
|
||||
isVisible={isVisible}
|
||||
warmupEnabled={warmupEnabled}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
statsRefreshIntervalSec={statsRefreshIntervalSec}
|
||||
onReloadScopes={reloadScopes}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'images' && 'hidden')}>
|
||||
<DockerImagesPanel
|
||||
sessionId={sessionId}
|
||||
isVisible={isVisible && subTab === 'images'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'images')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SystemPanelShell>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import { Box, FileText, Info, Terminal, Trash2 } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
|
||||
import type { KubectlPodInfo } from '../../domain/systemManager/containerRuntime';
|
||||
import {
|
||||
buildKubectlDescribeCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
describeRuntimeFailure,
|
||||
podStatusMessageKey,
|
||||
} from '../../domain/systemManager/containerRuntime';
|
||||
import type { TerminalSession } from '../../types';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { useStableListOrder } from './listStable';
|
||||
import { openInteractiveTerminal } from './openInteractiveTerminal';
|
||||
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
|
||||
import {
|
||||
SystemPanelEmpty,
|
||||
SystemPanelError,
|
||||
SystemPanelList,
|
||||
SystemPanelLoading,
|
||||
SystemPanelMetaBar,
|
||||
SystemPanelRefreshButton,
|
||||
SystemPanelRoundButton,
|
||||
SystemPanelRow,
|
||||
SystemPanelSearch,
|
||||
SystemPanelStatusBadge,
|
||||
SystemPanelToolbar,
|
||||
} from './SystemPanelUi';
|
||||
import { showSystemManagerError } from './systemManagerToast';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
interface KubernetesPodsPanelProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
contextName: string | null;
|
||||
namespaceName: string | null;
|
||||
scopeError?: string | null;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
listRefreshIntervalSec: number;
|
||||
onReloadScopes: () => void;
|
||||
}
|
||||
|
||||
function podTone(status: string): 'success' | 'warning' | 'muted' {
|
||||
if (status === 'Running') return 'success';
|
||||
if (status === 'Pending' || status === 'Completed' || status === 'Succeeded') return 'muted';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
const KubernetesPodRow = memo(function KubernetesPodRow({
|
||||
pod,
|
||||
selected,
|
||||
pendingDelete,
|
||||
onSelect,
|
||||
onShell,
|
||||
onLogs,
|
||||
onDescribe,
|
||||
onDelete,
|
||||
}: {
|
||||
pod: KubectlPodInfo;
|
||||
selected: boolean;
|
||||
pendingDelete: boolean;
|
||||
onSelect: (pod: KubectlPodInfo) => void;
|
||||
onShell: (pod: KubectlPodInfo) => void;
|
||||
onLogs: (pod: KubectlPodInfo) => void;
|
||||
onDescribe: (pod: KubectlPodInfo) => void;
|
||||
onDelete: (pod: KubectlPodInfo) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const statusKey = podStatusMessageKey(pod.status);
|
||||
const statusLabel = statusKey ? t(statusKey) : pod.status;
|
||||
return (
|
||||
<SystemPanelRow
|
||||
selected={selected}
|
||||
onClick={() => onSelect(pod)}
|
||||
leading={<Box size={14} className="text-muted-foreground" />}
|
||||
title={pod.name}
|
||||
subtitle={`${pod.containers.join(', ') || '—'} · ${t('systemManager.k8s.restarts', { count: String(pod.restarts) })}${pod.age ? ` · ${pod.age}` : ''}`}
|
||||
trailing={(
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<SystemPanelStatusBadge tone={podTone(pod.status)}>{statusLabel}</SystemPanelStatusBadge>
|
||||
{pod.shellContainers.length > 0 && (
|
||||
<SystemPanelRoundButton title={t('systemManager.docker.shell')} onClick={() => onShell(pod)}>
|
||||
<Terminal size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
)}
|
||||
<SystemPanelRoundButton title={t('systemManager.docker.logs')} onClick={() => onLogs(pod)}>
|
||||
<FileText size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton title={t('systemManager.k8s.describe')} onClick={() => onDescribe(pod)}>
|
||||
<Info size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.k8s.delete')}
|
||||
destructive
|
||||
loading={pendingDelete}
|
||||
onClick={() => onDelete(pod)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const KubernetesPodsPanel = memo(function KubernetesPodsPanel({
|
||||
sessionId,
|
||||
parentSession,
|
||||
contextName,
|
||||
namespaceName,
|
||||
scopeError = null,
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
listRefreshIntervalSec,
|
||||
onReloadScopes,
|
||||
}: KubernetesPodsPanelProps) {
|
||||
const { t } = useI18n();
|
||||
const stableT = useStableTranslate();
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedName, setSelectedName] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
|
||||
const [confirmPod, setConfirmPod] = useState<string | null>(null);
|
||||
const [containerPrompt, setContainerPrompt] = useState<{
|
||||
pod: KubectlPodInfo;
|
||||
action: 'shell' | 'logs';
|
||||
} | null>(null);
|
||||
|
||||
const scopeKey = `${sessionId}:${contextName ?? ''}:${namespaceName ?? ''}`;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedName(null);
|
||||
setPendingDelete(null);
|
||||
setConfirmPod(null);
|
||||
setContainerPrompt(null);
|
||||
}, [scopeKey]);
|
||||
|
||||
const podsFetcher = useCallback(async () => {
|
||||
if (!contextName || !namespaceName) return [];
|
||||
const result = await backend.kubectl({
|
||||
sessionId,
|
||||
op: 'pods',
|
||||
context: contextName,
|
||||
namespace: namespaceName,
|
||||
});
|
||||
if (!result.success || !result.pods) {
|
||||
throw new Error(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadPods'),
|
||||
contextName,
|
||||
));
|
||||
}
|
||||
return result.pods;
|
||||
}, [backend, contextName, namespaceName, sessionId, stableT]);
|
||||
|
||||
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
|
||||
const { data: pods, error, loading, refresh } = usePolling<KubectlPodInfo[]>(
|
||||
podsFetcher,
|
||||
listIntervalMs,
|
||||
(isVisible || warmupEnabled) && !!contextName && !!namespaceName,
|
||||
undefined,
|
||||
{ poll: isVisible, resetKey: scopeKey },
|
||||
);
|
||||
|
||||
const matched = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return (pods ?? []).filter((pod) => {
|
||||
if (!q) return true;
|
||||
const statusKey = podStatusMessageKey(pod.status);
|
||||
const statusLabel = (statusKey ? t(statusKey) : pod.status).toLowerCase();
|
||||
return pod.name.toLowerCase().includes(q)
|
||||
|| pod.status.toLowerCase().includes(q)
|
||||
|| statusLabel.includes(q)
|
||||
|| pod.containers.some((name) => name.toLowerCase().includes(q));
|
||||
});
|
||||
}, [pods, query, t]);
|
||||
|
||||
const displayList = useStableListOrder<KubectlPodInfo, string>(
|
||||
matched,
|
||||
(pod) => pod.name,
|
||||
`${scopeKey}|${query}`,
|
||||
(a, b) => a.name.localeCompare(b.name),
|
||||
);
|
||||
|
||||
const openCommand = useCallback(async (title: string, command: string) => {
|
||||
const result = await openInteractiveTerminal(backend, parentSession, title, command);
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
}
|
||||
}, [backend, parentSession, t]);
|
||||
|
||||
const runPodCommand = useCallback((pod: KubectlPodInfo, action: 'shell' | 'logs' | 'describe', container?: string) => {
|
||||
if (!contextName || !namespaceName) return;
|
||||
if (action === 'describe') {
|
||||
void openCommand(
|
||||
`describe: ${pod.name}`,
|
||||
buildKubectlDescribeCommand(contextName, namespaceName, pod.name, parentSession.shellType),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const containers = action === 'shell' ? pod.shellContainers : pod.containers;
|
||||
if (action === 'shell' && containers.length === 0) return;
|
||||
if (!container && containers.length > 1) {
|
||||
setContainerPrompt({ pod, action });
|
||||
return;
|
||||
}
|
||||
const chosen = container || containers[0];
|
||||
if (action === 'shell') {
|
||||
void openCommand(
|
||||
`pod: ${pod.name}`,
|
||||
buildKubectlExecShellCommand(contextName, namespaceName, pod.name, chosen, parentSession.shellType),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void openCommand(
|
||||
`logs: ${pod.name}`,
|
||||
buildKubectlLogsCommand(contextName, namespaceName, pod.name, chosen, parentSession.shellType),
|
||||
);
|
||||
}, [contextName, namespaceName, openCommand, parentSession.shellType]);
|
||||
|
||||
const deletePod = useCallback(async (name: string) => {
|
||||
if (!contextName || !namespaceName) return;
|
||||
setPendingDelete(name);
|
||||
try {
|
||||
const result = await backend.kubectl({
|
||||
sessionId,
|
||||
op: 'delete',
|
||||
context: contextName,
|
||||
namespace: namespaceName,
|
||||
pod: name,
|
||||
});
|
||||
if (!result.success) {
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed'), contextName),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (selectedName === name) setSelectedName(null);
|
||||
await refresh();
|
||||
} finally {
|
||||
setPendingDelete(null);
|
||||
}
|
||||
}, [backend, contextName, namespaceName, refresh, selectedName, sessionId, t]);
|
||||
|
||||
const visibleError = scopeError || error;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="kubernetes-pods">
|
||||
<SystemPanelToolbar
|
||||
trailing={(
|
||||
<SystemPanelRefreshButton
|
||||
title={t('history.action.refresh')}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
onReloadScopes();
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<SystemPanelSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t('systemManager.k8s.search')}
|
||||
/>
|
||||
</SystemPanelToolbar>
|
||||
|
||||
<SystemPanelMetaBar>
|
||||
{t('systemManager.k8s.meta', { count: String(displayList.length) })}
|
||||
</SystemPanelMetaBar>
|
||||
|
||||
<SystemPanelList>
|
||||
{!contextName && !visibleError && (
|
||||
<SystemPanelEmpty icon={Box} message={t('systemManager.k8s.noContext')} />
|
||||
)}
|
||||
{visibleError && (
|
||||
<SystemPanelError
|
||||
message={visibleError}
|
||||
onRetry={() => {
|
||||
onReloadScopes();
|
||||
void refresh();
|
||||
}}
|
||||
retryLabel={t('history.action.retry')}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
{!visibleError && contextName && displayList.length === 0 && loading && (
|
||||
<SystemPanelLoading message={t('systemManager.common.loading')} />
|
||||
)}
|
||||
{!visibleError && contextName && namespaceName && displayList.length === 0 && !loading && (
|
||||
<SystemPanelEmpty icon={Box} message={t('systemManager.k8s.empty')} />
|
||||
)}
|
||||
{displayList.map((pod) => (
|
||||
<KubernetesPodRow
|
||||
key={pod.name}
|
||||
pod={pod}
|
||||
selected={selectedName === pod.name}
|
||||
pendingDelete={pendingDelete === pod.name}
|
||||
onSelect={(item) => setSelectedName((current) => current === item.name ? null : item.name)}
|
||||
onShell={(item) => runPodCommand(item, 'shell')}
|
||||
onLogs={(item) => runPodCommand(item, 'logs')}
|
||||
onDescribe={(item) => runPodCommand(item, 'describe')}
|
||||
onDelete={(item) => setConfirmPod(item.name)}
|
||||
/>
|
||||
))}
|
||||
</SystemPanelList>
|
||||
|
||||
<SystemPanelConfirmDialog
|
||||
open={confirmPod !== null}
|
||||
title={t('systemManager.k8s.delete')}
|
||||
message={t('systemManager.k8s.confirmDelete', { name: confirmPod ?? '' })}
|
||||
confirmLabel={t('systemManager.k8s.delete')}
|
||||
destructive
|
||||
busy={pendingDelete !== null}
|
||||
onOpenChange={(open) => { if (!open) setConfirmPod(null); }}
|
||||
onConfirm={() => {
|
||||
const name = confirmPod;
|
||||
setConfirmPod(null);
|
||||
if (!name) return;
|
||||
void deletePod(name);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={containerPrompt !== null} onOpenChange={(open) => { if (!open) setContainerPrompt(null); }}>
|
||||
<DialogContent className="sm:max-w-[380px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('systemManager.k8s.chooseContainer')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-1">
|
||||
{(containerPrompt?.action === 'shell'
|
||||
? containerPrompt.pod.shellContainers
|
||||
: containerPrompt?.pod.containers
|
||||
)?.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className="rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
onClick={() => {
|
||||
const prompt = containerPrompt;
|
||||
setContainerPrompt(null);
|
||||
if (!prompt) return;
|
||||
runPodCommand(prompt.pod, prompt.action, name);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { resolveCapabilityPanelState } from '../../domain/systemManagerPanelStat
|
||||
import {
|
||||
allowSystemManagerMutations,
|
||||
buildSystemManagerTabs,
|
||||
hasContainerSource,
|
||||
shouldCollectServerStats,
|
||||
} from '../../domain/systemManager/systemTarget';
|
||||
|
||||
@@ -284,13 +285,14 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
}, [isConnectedSession, isVisible, availableTabsKey, tabBarEl]);
|
||||
|
||||
// Must be defined before early returns to comply with React rules of hooks.
|
||||
const containerSourceReady = hasContainerSource(capabilities);
|
||||
const prevTabRef = React.useRef(resolvedTab);
|
||||
const probingRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
const prev = prevTabRef.current;
|
||||
prevTabRef.current = resolvedTab;
|
||||
if (prev === resolvedTab) return;
|
||||
if (resolvedTab === 'docker' && capabilities?.hasDocker !== true) {
|
||||
if (resolvedTab === 'docker' && !containerSourceReady) {
|
||||
if (!probingRef.current) {
|
||||
probingRef.current = true;
|
||||
refreshCapabilities().finally(() => { probingRef.current = false; });
|
||||
@@ -298,7 +300,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
} else if (resolvedTab === 'tmux' && capabilities?.hasTmux !== true) {
|
||||
void refreshCapabilities();
|
||||
}
|
||||
}, [resolvedTab, capabilities, refreshCapabilities]);
|
||||
}, [resolvedTab, capabilities, containerSourceReady, refreshCapabilities]);
|
||||
|
||||
// Auto-poll for Docker capabilities while Docker tab is active and Docker not yet detected.
|
||||
// Use setTimeout recursion so the next probe only starts after the previous one finishes,
|
||||
@@ -315,7 +317,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
// preventing stale probes from surviving cleanup (unlike cancelledRef which is shared).
|
||||
// First poll is delayed by one interval to avoid overlapping with the tab-switch probe.
|
||||
React.useEffect(() => {
|
||||
if (!isVisible || resolvedTab !== 'docker' || capabilities?.hasDocker === true) return;
|
||||
if (!isVisible || resolvedTab !== 'docker' || containerSourceReady) return;
|
||||
|
||||
let cancelled = false;
|
||||
let timerId: ReturnType<typeof setTimeout>;
|
||||
@@ -344,7 +346,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
cancelled = true;
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [isVisible, resolvedTab, capabilities?.hasDocker, capabilitiesTtlMs]);
|
||||
}, [isVisible, resolvedTab, containerSourceReady, capabilitiesTtlMs]);
|
||||
|
||||
const selectTab = useCallback((id: SystemManagerSubTab) => {
|
||||
setActiveTab(id);
|
||||
@@ -398,7 +400,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
}
|
||||
|
||||
const tmuxReady = capabilities?.hasTmux === true;
|
||||
const dockerReady = capabilities?.hasDocker === true;
|
||||
const dockerReady = containerSourceReady;
|
||||
const gpuReady = capabilities?.hasNvidiaSmi === true || capabilities?.hasNpuSmi === true;
|
||||
const portsReady = (
|
||||
capabilities?.hasSs === true
|
||||
@@ -621,8 +623,10 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
) : dockerPanelState === 'ready' ? (
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'docker' && 'hidden')}>
|
||||
<DockerManagerTab
|
||||
key={session.hostId || sessionId}
|
||||
sessionId={sessionId}
|
||||
parentSession={session}
|
||||
capabilities={capabilities}
|
||||
isVisible={isVisible && resolvedTab === 'docker'}
|
||||
warmupEnabled={isVisible && resolvedTab !== 'docker'}
|
||||
backend={backend}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
test('kubernetes keeps the shared runtime select and drops the Containers back button', () => {
|
||||
const tab = readFileSync(new URL('./DockerManagerTab.tsx', import.meta.url), 'utf8');
|
||||
const hook = readFileSync(
|
||||
new URL('../../application/state/systemManager/useContainerRuntimeSelection.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.equal(tab.includes('container-runtime-back'), false);
|
||||
assert.match(tab, /data-section=\{section\}/);
|
||||
assert.match(tab, /section="container-runtime-switcher"/);
|
||||
assert.match(tab, /section="k8s-context-select"/);
|
||||
assert.match(tab, /section="k8s-namespace-select"/);
|
||||
assert.doesNotMatch(tab, /<div className="flex-1" \/>/);
|
||||
assert.match(tab, /section="k8s-context-select"[\s\S]*className="w-full min-w-0 max-w-none flex-1"/);
|
||||
assert.match(tab, /section="container-runtime-switcher"[\s\S]*className="shrink-0"/);
|
||||
assert.match(tab, /section="k8s-namespace-select"[\s\S]*className="shrink-0"/);
|
||||
assert.match(hook, /const showRuntimeSwitcher = detected\.length > 1;/);
|
||||
assert.doesNotMatch(hook, /source !== 'kubernetes' && detected\.length/);
|
||||
assert.match(hook, /reconcileContainerSource\(/);
|
||||
assert.match(hook, /isAutomaticRuntimeFallback\(remembered, source, available\)/);
|
||||
});
|
||||
@@ -93,3 +93,71 @@ test('openInteractiveTerminal keeps SSH connection reuse for ordinary connected
|
||||
assert.equal(payload.sourceSession.protocol, 'ssh');
|
||||
assert.equal(payload.sourceSession.reuseConnectionFromSessionId, 'session-1');
|
||||
});
|
||||
|
||||
async function withSystemPanels(
|
||||
panels: Array<{ getBoundingClientRect: () => { x: number; y: number; width: number; height: number } }>,
|
||||
run: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = {
|
||||
querySelectorAll(selector: string) {
|
||||
return selector === '[data-section="system-manager-panel"]' ? panels : [];
|
||||
},
|
||||
} as unknown as Document;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete (globalThis as { document?: Document }).document;
|
||||
} else {
|
||||
globalThis.document = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('openInteractiveTerminal passes the system panel rectangle so the shell can avoid it', async () => {
|
||||
const panel = {
|
||||
getBoundingClientRect: () => ({ x: 1000, y: 40, width: 440, height: 860 }),
|
||||
};
|
||||
const payloads: Array<{ avoidClientRect?: { x: number; width: number } }> = [];
|
||||
await withSystemPanels([panel], async () => {
|
||||
await openInteractiveTerminal(
|
||||
{
|
||||
openTerminalPopup: async (payload: { avoidClientRect?: { x: number; width: number } }) => {
|
||||
payloads.push(payload);
|
||||
return { success: true };
|
||||
},
|
||||
} as never,
|
||||
parentSession(),
|
||||
'pod: multi-box',
|
||||
'kubectl exec -it multi-box -- sh',
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(payloads[0]?.avoidClientRect, { x: 1000, y: 40, width: 440, height: 860 });
|
||||
});
|
||||
|
||||
test('openInteractiveTerminal skips a parked zero-size system panel and avoids the visible one', async () => {
|
||||
const parked = {
|
||||
getBoundingClientRect: () => ({ x: 0, y: 0, width: 0, height: 0 }),
|
||||
};
|
||||
const visible = {
|
||||
getBoundingClientRect: () => ({ x: 1000, y: 40, width: 440, height: 860 }),
|
||||
};
|
||||
const payloads: Array<{ avoidClientRect?: { x: number; y: number; width: number; height: number } }> = [];
|
||||
await withSystemPanels([parked, visible], async () => {
|
||||
await openInteractiveTerminal(
|
||||
{
|
||||
openTerminalPopup: async (payload: { avoidClientRect?: { x: number; y: number; width: number; height: number } }) => {
|
||||
payloads.push(payload);
|
||||
return { success: true };
|
||||
},
|
||||
} as never,
|
||||
parentSession(),
|
||||
'pod: multi-box',
|
||||
'kubectl exec -it multi-box -- sh',
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(payloads[0]?.avoidClientRect, { x: 1000, y: 40, width: 440, height: 860 });
|
||||
});
|
||||
@@ -6,6 +6,18 @@ import type { useSystemManagerBackend } from '../../application/state/useSystemM
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
function readSystemPanelClientRect(): { x: number; y: number; width: number; height: number } | undefined {
|
||||
if (typeof document === "undefined" || typeof document.querySelectorAll !== "function") return undefined;
|
||||
const panels = document.querySelectorAll('[data-section="system-manager-panel"]');
|
||||
for (const panel of panels) {
|
||||
if (typeof panel.getBoundingClientRect !== "function") continue;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
if (rect.width < 1 || rect.height < 1) continue;
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildPopupTitle(parentSession: TerminalSession, title: string): string {
|
||||
const hostLabel = parentSession.hostLabel.trim();
|
||||
const cleanTitle = title.trim();
|
||||
@@ -62,11 +74,13 @@ export async function openInteractiveTerminal(
|
||||
canReuseConnection,
|
||||
hasIcon: !!options?.icon,
|
||||
});
|
||||
const avoidClientRect = readSystemPanelClientRect();
|
||||
const result = await backend.openTerminalPopup({
|
||||
title: popupTitle,
|
||||
icon: options?.icon,
|
||||
parentSessionId: parentSession.id,
|
||||
startupCommand,
|
||||
...(avoidClientRect ? { avoidClientRect } : {}),
|
||||
sourceSession: buildCommandPopupSourceSession(parentSession, startupCommand, canReuseConnection),
|
||||
});
|
||||
await writeSystemManagerDiagnostic('openInteractiveTerminal result', {
|
||||
|
||||
@@ -49,11 +49,11 @@ test("process and docker confirm dialogs reset when sessionId changes", () => {
|
||||
assert.match(processSource, /}, \[sessionId\]\);/);
|
||||
|
||||
assert.match(containersSource, /setConfirmAction\(null\)/);
|
||||
assert.match(containersSource, /}, \[sessionId\]\);/);
|
||||
assert.match(containersSource, /}, \[runtime, sessionId\]\);/);
|
||||
|
||||
assert.match(imagesSource, /setConfirmTarget\(null\)/);
|
||||
assert.match(imagesSource, /setActionBusy\(false\)/);
|
||||
assert.match(imagesSource, /}, \[sessionId\]\);/);
|
||||
assert.match(imagesSource, /}, \[runtime, sessionId\]\);/);
|
||||
});
|
||||
|
||||
test("ports and services confirm dialogs reset when sessionId changes", () => {
|
||||
|
||||
@@ -360,6 +360,36 @@ test("VaultHostListSection exposes normal group cards to keyboard", () => {
|
||||
assert.match(markup, /data-group-path="production"[^>]*tabindex="0"/);
|
||||
});
|
||||
|
||||
test("VaultHostListSection keeps selected host cards draggable and leaves unselected cards in place", () => {
|
||||
const otherHost = makeHost("other-host", "Other Router");
|
||||
const markup = renderHostList({
|
||||
viewMode: "list",
|
||||
displayedGroups: [],
|
||||
displayedHosts: [mainHost, otherHost],
|
||||
visibleDisplayedHosts: [mainHost, otherHost],
|
||||
isMultiSelectMode: true,
|
||||
selectedHostIds: new Set([mainHost.id]),
|
||||
});
|
||||
|
||||
assert.match(markup, /data-host-id="main-host"[^>]*draggable="true"/);
|
||||
assert.match(markup, /data-host-id="other-host"[^>]*draggable="false"/);
|
||||
const source = readFileSync(new URL("./VaultHostListSection.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /useVaultHostMarquee/);
|
||||
assert.match(source, /data-vault-marquee/);
|
||||
assert.match(source, /moveHostsToGroup/);
|
||||
});
|
||||
|
||||
test("VaultHostListSection keeps a normal host card draggable outside multi-select", () => {
|
||||
const markup = renderHostList({
|
||||
viewMode: "grid",
|
||||
displayedGroups: [],
|
||||
displayedHosts: [mainHost],
|
||||
visibleDisplayedHosts: [mainHost],
|
||||
});
|
||||
|
||||
assert.match(markup, /data-host-id="main-host"[^>]*draggable="true"/);
|
||||
});
|
||||
|
||||
test("VaultHostListSection exposes ungrouped hosts to keyboard and assistive technology", () => {
|
||||
const markup = renderHostList({
|
||||
viewMode: "list",
|
||||
|
||||
@@ -27,7 +27,10 @@ import {
|
||||
import type { GroupNode, Host } from "../../domain/models";
|
||||
import { isPluginHostProtocol } from "../../domain/pluginConnection";
|
||||
import { OpenDualPaneSftpMenuItem, OpenHostBrowserMenuItem } from "../host/HostTreeContextMenus";
|
||||
import { VAULT_HOST_DRAG_IDS, isVaultHostDraggable } from "../../domain/vaultHostDrag";
|
||||
import { getVaultHostRowSubtitle } from "../../domain/localShellHost";
|
||||
import { beginVaultHostCardDrag } from "./vaultHostDragImage";
|
||||
import { useVaultHostMarquee } from "./useVaultHostMarquee";
|
||||
|
||||
type VaultHostListSectionContext = Record<string, any>;
|
||||
|
||||
@@ -54,12 +57,15 @@ const isRelatedTargetInside = (
|
||||
};
|
||||
|
||||
const EMPTY_GROUP_PATH_SET = new Set<string>();
|
||||
const EMPTY_HOST_ID_SET: ReadonlySet<string> = new Set();
|
||||
const noopSetMultiSelectMode = (_enabled: boolean) => {};
|
||||
const noopSetSelectedHostIds = (_ids: Set<string>) => {};
|
||||
|
||||
export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext }) {
|
||||
const { Badge, Boolean, Button, cancelInlineGroupEdit, CheckSquare, ClipboardCopy, Clock, cn, commitInlineGroupRename, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, displayedGroups, displayedHosts, DistroAvatar, Edit2, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, groupConfigs, groupedDisplayHosts, handleCopyCredentials, handleCopyHostname, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleHostConnect, handleOpenHostBrowser, hostClickBehavior: hostClickBehaviorProp, handleUnmanageGroup, hasHostsSidePanel, hostListScrollRef, HostTreeView, isHostsSectionActive, isMultiSelectMode, lastPinnedId, LayoutGrid, managedGroupPaths, moveGroup, moveHostToGroup, onDeleteHost, Pin, pinnedHosts, Plug, recentHosts, reorderGroup, reorderHost, sanitizeHost, search, selectedGroupPath, selectedGroupPaths, selectedHostIds, selectedTags, sessionCount, setDeleteTargetPath, setDragOverDropTarget, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsNewFolderOpen, setLastPinnedId, setNewFolderName, setSelectedGroupPath, setTargetParentPath, shouldHideEmptyRootHostsSection, showRecentHosts, sortMode, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, t, toggleGroupSelection, toggleHostPinned, toggleHostSelection, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, viewMode, visibleDisplayedHosts } = ctx;
|
||||
const { Badge, Boolean, Button, cancelInlineGroupEdit, CheckSquare, ClipboardCopy, Clock, cn, commitInlineGroupRename, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, displayedGroups, displayedHosts, DistroAvatar, Edit2, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, groupConfigs, groupedDisplayHosts, handleCopyCredentials, handleCopyHostname, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleHostConnect, handleOpenHostBrowser, hostClickBehavior: hostClickBehaviorProp, handleUnmanageGroup, hasHostsSidePanel, hostListScrollRef, HostTreeView, isHostsSectionActive, isMultiSelectMode, lastPinnedId, LayoutGrid, managedGroupPaths, moveGroup, moveHostToGroup, moveHostsToGroup, onDeleteHost, Pin, pinnedHosts, Plug, recentHosts, reorderGroup, reorderHost, sanitizeHost, search, selectedGroupPath, selectedGroupPaths, selectedHostIds, selectedTags, sessionCount, setDeleteTargetPath, setDragOverDropTarget, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsMultiSelectMode = noopSetMultiSelectMode, setIsNewFolderOpen, setLastPinnedId, setNewFolderName, setSelectedGroupPath, setSelectedHostIds = noopSetSelectedHostIds, setTargetParentPath, shouldHideEmptyRootHostsSection, showRecentHosts, sortMode, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, t, toggleGroupSelection, toggleHostPinned, toggleHostSelection, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, viewMode, visibleDisplayedHosts } = ctx;
|
||||
const hostClickBehavior: HostClickBehavior = hostClickBehaviorProp === 'select' ? 'select' : 'connect';
|
||||
const multiSelectedGroupPaths: Set<string> = selectedGroupPaths ?? EMPTY_GROUP_PATH_SET;
|
||||
const [draggingHostId, setDraggingHostId] = React.useState<string | null>(null);
|
||||
const [draggingHostIds, setDraggingHostIds] = React.useState<ReadonlySet<string> | null>(null);
|
||||
const draggingHostIdRef = React.useRef<string | null>(null);
|
||||
const lastPreviewReorderRef = React.useRef<string | null>(null);
|
||||
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(hostListScrollRef);
|
||||
@@ -120,6 +126,19 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
setFocusedGroupPath(null);
|
||||
}, [selectedGroupPath, viewMode, hostClickBehavior]);
|
||||
|
||||
const {
|
||||
marqueeRect,
|
||||
onPointerDown: onMarqueePointerDown,
|
||||
onClickCapture: onMarqueeClickCapture,
|
||||
} = useVaultHostMarquee({
|
||||
containerRef: hostListScrollRef,
|
||||
viewMode,
|
||||
isMultiSelectMode: Boolean(isMultiSelectMode),
|
||||
selectedHostIds: selectedHostIds ?? EMPTY_HOST_ID_SET,
|
||||
setIsMultiSelectMode,
|
||||
setSelectedHostIds,
|
||||
});
|
||||
|
||||
const activateHost = React.useCallback((host: Host) => {
|
||||
const action = resolveHostActivateAction({
|
||||
behavior: hostClickBehavior,
|
||||
@@ -284,17 +303,25 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
|
||||
const resetHostDragState = React.useCallback(() => {
|
||||
draggingHostIdRef.current = null;
|
||||
setDraggingHostId(null);
|
||||
setDraggingHostIds(null);
|
||||
lastPreviewReorderRef.current = null;
|
||||
setDragOverDropTarget(null);
|
||||
}, [setDragOverDropTarget]);
|
||||
|
||||
const handleHostDragStart = React.useCallback((e: React.DragEvent, hostId: string) => {
|
||||
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData("host-id", hostId);
|
||||
const ids = beginVaultHostCardDrag({
|
||||
dataTransfer: e.dataTransfer,
|
||||
hostId,
|
||||
isMultiSelectMode: !!isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
countLabel: (count) => t("vault.hosts.dragSelectionCount", { count }),
|
||||
});
|
||||
if (!ids) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
draggingHostIdRef.current = hostId;
|
||||
setDraggingHostId(hostId);
|
||||
setDraggingHostIds(new Set(ids));
|
||||
lastPreviewReorderRef.current = null;
|
||||
|
||||
// Grid preview reorder can move the card into another virtual row, and rows
|
||||
@@ -308,7 +335,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
resetHostDragState();
|
||||
};
|
||||
sourceNode.addEventListener("dragend", handleNativeDragEnd);
|
||||
}, [resetHostDragState]);
|
||||
}, [isMultiSelectMode, resetHostDragState, selectedHostIds, t]);
|
||||
|
||||
const renderHostEditButton = (host: any, compact = false) => (
|
||||
<Button
|
||||
@@ -351,14 +378,21 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
return <div
|
||||
ref={hostListScrollRef}
|
||||
className={cn(
|
||||
"flex-1 overflow-auto px-4 pb-4 space-y-3",
|
||||
"relative flex-1 overflow-auto px-4 pb-4 [&>:not([data-vault-marquee])~:not([data-vault-marquee])]:mt-3",
|
||||
viewMode === "tree" ? "pt-1.5" : "pt-0",
|
||||
!isHostsSectionActive && "hidden",
|
||||
marqueeRect && "select-none",
|
||||
)}
|
||||
data-section="vault-host-list"
|
||||
onClick={handleHostListClick}
|
||||
onClickCapture={onMarqueeClickCapture}
|
||||
onPointerDown={onMarqueePointerDown}
|
||||
onDragOverCapture={(e) => {
|
||||
const target = (e.target as Element | null)?.closest("[data-host-id], [data-group-path]");
|
||||
if (hasVaultDragType(e.dataTransfer, VAULT_HOST_DRAG_IDS)) {
|
||||
const groupTarget = target instanceof HTMLElement && target.hasAttribute("data-group-path");
|
||||
if (!groupTarget) return;
|
||||
}
|
||||
if (target) e.preventDefault();
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const draggedGroupPath = e.dataTransfer.getData("group-path");
|
||||
@@ -392,6 +426,10 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
const target = (e.target as Element | null)?.closest("[data-host-id], [data-group-path]");
|
||||
if (hasVaultDragType(e.dataTransfer, VAULT_HOST_DRAG_IDS)) {
|
||||
const groupTarget = target instanceof HTMLElement && target.hasAttribute("data-group-path");
|
||||
if (!groupTarget) return;
|
||||
}
|
||||
if (!(target instanceof HTMLElement) || viewMode === "grid") return;
|
||||
const draggedGroupPath = e.dataTransfer.getData("group-path");
|
||||
const isDraggingGroup = hasVaultDragType(e.dataTransfer, "group-path");
|
||||
@@ -419,7 +457,12 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const targetHostId = target.getAttribute("data-host-id");
|
||||
const targetGroupPath = target.getAttribute("data-group-path");
|
||||
if (draggedHostId && targetHostId && draggedHostId !== targetHostId) {
|
||||
if (
|
||||
draggedHostId
|
||||
&& targetHostId
|
||||
&& draggedHostId !== targetHostId
|
||||
&& !hasVaultDragType(e.dataTransfer, VAULT_HOST_DRAG_IDS)
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const position = getVaultDropPosition(target, e.clientX, e.clientY, viewMode === "grid");
|
||||
@@ -447,6 +490,19 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
resetHostDragState();
|
||||
}}
|
||||
>
|
||||
{marqueeRect && (
|
||||
<div
|
||||
data-vault-marquee="true"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute z-20 border border-primary/80 bg-primary/15"
|
||||
style={{
|
||||
left: marqueeRect.left,
|
||||
top: marqueeRect.top,
|
||||
width: marqueeRect.right - marqueeRect.left,
|
||||
height: marqueeRect.bottom - marqueeRect.top,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{viewMode !== "tree" && (
|
||||
<section className="space-y-2 pt-2">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
@@ -476,6 +532,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
setDragOverDropTarget,
|
||||
moveGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
});
|
||||
}}
|
||||
@@ -546,7 +603,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -565,7 +622,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
style={lastPinnedId === host.id ? { animation: "pop-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both" } : undefined}
|
||||
onAnimationEnd={() => { if (lastPinnedId === host.id) setLastPinnedId(null); }}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
@@ -672,7 +729,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -689,7 +746,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
? selectedHostIds.has(host.id)
|
||||
: undefined}
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
@@ -794,6 +851,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
dataTransfer: e.dataTransfer,
|
||||
groupPath: selectedGroupPath,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
})) return;
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
@@ -874,6 +932,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
dataTransfer: e.dataTransfer,
|
||||
groupPath: node.path,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
})) return;
|
||||
const groupPath =
|
||||
@@ -983,6 +1042,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
cancelInlineGroupEdit={cancelInlineGroupEdit}
|
||||
onDeleteGroup={startInlineDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveHostsToGroup={moveHostsToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={handleUnmanageGroup}
|
||||
@@ -1041,7 +1101,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -1058,7 +1118,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
? selectedHostIds.has(host.id)
|
||||
: undefined}
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
@@ -1192,7 +1252,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -1209,7 +1269,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
? selectedHostIds.has(host.id)
|
||||
: undefined}
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
|
||||
@@ -1237,6 +1237,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
managedGroupPaths,
|
||||
moveGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
onDeleteHost,
|
||||
Pin,
|
||||
pinnedHosts,
|
||||
@@ -1256,10 +1257,12 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
setDragOverDropTarget,
|
||||
setGroupDragOverDropTarget,
|
||||
setIsDeleteGroupOpen,
|
||||
setIsMultiSelectMode,
|
||||
setIsNewFolderOpen,
|
||||
setLastPinnedId,
|
||||
setNewFolderName,
|
||||
setSelectedGroupPath,
|
||||
setSelectedHostIds,
|
||||
setTargetParentPath,
|
||||
shouldHideEmptyRootHostsSection,
|
||||
showRecentHosts,
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import type { Host, ManagedSource } from '../../types';
|
||||
import { handleVaultHostDropToGroup } from './vaultReorderDrag';
|
||||
import { useVaultGroupDragHandlers } from './useVaultGroupDragHandlers';
|
||||
|
||||
const host = (id: string, extra: Partial<Host> = {}): Host => ({
|
||||
@@ -44,6 +45,37 @@ test('mixed protocols entering and leaving managed groups retain single-host mov
|
||||
}, writes);
|
||||
});
|
||||
|
||||
test('a multi-id group drop publishes the whole selection through the batched move', async () => {
|
||||
const original = [host('ssh'), host('telnet', { protocol: 'telnet' }), host('local', { protocol: 'local' })];
|
||||
const sources = [{ id: 'parent', groupName: 'Managed' }, { id: 'child', groupName: 'Managed/Nested' }] as ManagedSource[];
|
||||
const writes: Host[][] = [];
|
||||
await withActions(original, sources, actions => {
|
||||
let handled = false;
|
||||
act(() => {
|
||||
handled = handleVaultHostDropToGroup({
|
||||
dataTransfer: {
|
||||
getData: (type: string) => type === 'host-ids' ? JSON.stringify(['ssh', 'telnet', 'local']) : '',
|
||||
},
|
||||
groupPath: 'Managed/Nested',
|
||||
moveHostToGroup: () => {
|
||||
throw new Error('multi-id drop must not move hosts one at a time');
|
||||
},
|
||||
moveHostsToGroup: actions.moveHostsToGroup,
|
||||
resetHostDragState: () => {},
|
||||
});
|
||||
});
|
||||
assert.equal(handled, true);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.deepEqual(writes[0].map(item => item.group), ['Managed/Nested', 'Managed/Nested', 'Managed/Nested']);
|
||||
assert.equal(writes[0][0].managedSourceId, 'child');
|
||||
assert.equal(writes[0][0].label, 'Hostssh');
|
||||
assert.equal(writes[0][1].managedSourceId, undefined);
|
||||
assert.equal(writes[0][1].label, 'Host telnet');
|
||||
assert.equal(writes[0][2].managedSourceId, undefined);
|
||||
assert.equal(writes[0][2].label, 'Host local');
|
||||
}, writes);
|
||||
});
|
||||
|
||||
test('same-group, missing and empty selections do not publish changes', async () => {
|
||||
const writes: Host[][] = [];
|
||||
await withActions([host('a')], [], actions => {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
clientPointToContent,
|
||||
contentRectFromClientBox,
|
||||
exceedsMarqueeThreshold,
|
||||
hostIdsIntersectingMarquee,
|
||||
isPointerOnNativeScrollbar,
|
||||
marqueeAutoScrollDelta,
|
||||
marqueeCancelSelection,
|
||||
marqueeCommitSelection,
|
||||
marqueeRectFromPoints,
|
||||
marqueeSelectionMode,
|
||||
mergeMarqueeSelection,
|
||||
rememberHostPlacements,
|
||||
shouldStartVaultMarquee,
|
||||
type HostPlacement,
|
||||
type MarqueeContentFrame,
|
||||
type MarqueeRect,
|
||||
} from "../../domain/vaultMarqueeSelection";
|
||||
|
||||
const MARQUEE_BLOCKED_SELECTOR = [
|
||||
"[data-host-id]",
|
||||
"[data-group-path]",
|
||||
"button",
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
"a",
|
||||
"label",
|
||||
"[role='scrollbar']",
|
||||
].join(",");
|
||||
|
||||
const selectionSignature = (ids: ReadonlySet<string>) => [...ids].sort().join("\0");
|
||||
|
||||
const elementFromEventTarget = (target: EventTarget | null): Element | null => {
|
||||
if (target instanceof Element) return target;
|
||||
if (typeof Node !== "undefined" && target instanceof Node) return target.parentElement;
|
||||
return null;
|
||||
};
|
||||
|
||||
const isBlockedMarqueeTarget = (target: EventTarget | null): boolean => {
|
||||
const element = elementFromEventTarget(target);
|
||||
if (!element) return true;
|
||||
return Boolean(element.closest(MARQUEE_BLOCKED_SELECTOR));
|
||||
};
|
||||
|
||||
const readFrame = (container: HTMLElement): MarqueeContentFrame => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
scrollLeft: container.scrollLeft,
|
||||
scrollTop: container.scrollTop,
|
||||
clientLeft: container.clientLeft,
|
||||
clientTop: container.clientTop,
|
||||
};
|
||||
};
|
||||
|
||||
const readVisibleHostPlacements = (container: HTMLElement): HostPlacement[] => {
|
||||
const frame = readFrame(container);
|
||||
const placements: HostPlacement[] = [];
|
||||
container.querySelectorAll<HTMLElement>("[data-host-id]").forEach((node, index) => {
|
||||
const id = node.dataset.hostId;
|
||||
if (!id) return;
|
||||
const box = node.getBoundingClientRect();
|
||||
placements.push({
|
||||
key: node.dataset.vaultGridItem || `${id}:${index}`,
|
||||
id,
|
||||
rect: contentRectFromClientBox(box, frame),
|
||||
});
|
||||
});
|
||||
return placements;
|
||||
};
|
||||
|
||||
interface UseVaultHostMarqueeOptions {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
viewMode: string;
|
||||
isMultiSelectMode: boolean;
|
||||
selectedHostIds: ReadonlySet<string>;
|
||||
setIsMultiSelectMode?: (value: boolean) => void;
|
||||
setSelectedHostIds?: (ids: Set<string>) => void;
|
||||
}
|
||||
|
||||
export function useVaultHostMarquee({
|
||||
containerRef,
|
||||
viewMode,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
setIsMultiSelectMode,
|
||||
setSelectedHostIds,
|
||||
}: UseVaultHostMarqueeOptions) {
|
||||
const [marqueeRect, setMarqueeRect] = useState<MarqueeRect | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const sessionRef = useRef(0);
|
||||
const stopGestureRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => () => stopGestureRef.current?.(), []);
|
||||
|
||||
const onPointerDown = useCallback((event: React.PointerEvent<HTMLElement>) => {
|
||||
if (suppressClickRef.current) suppressClickRef.current = false;
|
||||
const container = containerRef.current;
|
||||
if (!container || !setSelectedHostIds || !setIsMultiSelectMode) return;
|
||||
const bounds = container.getBoundingClientRect();
|
||||
if (!shouldStartVaultMarquee({
|
||||
viewMode,
|
||||
button: event.button,
|
||||
pointerType: event.pointerType,
|
||||
onScrollbar: isPointerOnNativeScrollbar({
|
||||
rectLeft: bounds.left,
|
||||
rectTop: bounds.top,
|
||||
clientLeft: container.clientLeft,
|
||||
clientTop: container.clientTop,
|
||||
clientWidth: container.clientWidth,
|
||||
clientHeight: container.clientHeight,
|
||||
}, event.clientX, event.clientY),
|
||||
blockedTarget: isBlockedMarqueeTarget(event.target),
|
||||
})) return;
|
||||
|
||||
stopGestureRef.current?.();
|
||||
const startContent = clientPointToContent(
|
||||
{ x: event.clientX, y: event.clientY },
|
||||
readFrame(container),
|
||||
);
|
||||
const baseline = new Set(selectedHostIds);
|
||||
const wasMultiSelectMode = Boolean(isMultiSelectMode);
|
||||
const mode = marqueeSelectionMode(event);
|
||||
const cache = new Map<string, HostPlacement>();
|
||||
const session = sessionRef.current + 1;
|
||||
sessionRef.current = session;
|
||||
let active = false;
|
||||
let scrollDelta = 0;
|
||||
let frameId = 0;
|
||||
let lastClientX = event.clientX;
|
||||
let lastClientY = event.clientY;
|
||||
let lastSignature = selectionSignature(baseline);
|
||||
let lastMode = wasMultiSelectMode;
|
||||
|
||||
const publish = (nextIds: Set<string>, modeEnabled: boolean) => {
|
||||
const signature = selectionSignature(nextIds);
|
||||
if (signature !== lastSignature) {
|
||||
lastSignature = signature;
|
||||
setSelectedHostIds(nextIds);
|
||||
}
|
||||
if (modeEnabled !== lastMode) {
|
||||
lastMode = modeEnabled;
|
||||
setIsMultiSelectMode(modeEnabled);
|
||||
}
|
||||
};
|
||||
|
||||
const applyAt = (clientX: number, clientY: number) => {
|
||||
if (sessionRef.current !== session) return;
|
||||
const current = clientPointToContent({ x: clientX, y: clientY }, readFrame(container));
|
||||
if (!active && !exceedsMarqueeThreshold(startContent, current)) return;
|
||||
active = true;
|
||||
const rect = marqueeRectFromPoints(startContent, current);
|
||||
rememberHostPlacements(cache, readVisibleHostPlacements(container));
|
||||
const hits = hostIdsIntersectingMarquee(rect, cache.values());
|
||||
const next = marqueeCommitSelection({
|
||||
wasMultiSelectMode,
|
||||
nextIds: mergeMarqueeSelection(baseline, hits, mode),
|
||||
});
|
||||
setMarqueeRect(rect);
|
||||
publish(next.selectedHostIds, next.isMultiSelectMode);
|
||||
};
|
||||
|
||||
const listeners: {
|
||||
move?: (event: PointerEvent) => void;
|
||||
up?: () => void;
|
||||
cancel?: () => void;
|
||||
key?: (event: KeyboardEvent) => void;
|
||||
scroll?: () => void;
|
||||
} = {};
|
||||
let stopGesture = () => {};
|
||||
|
||||
const finishListeners = () => {
|
||||
if (listeners.move) window.removeEventListener("pointermove", listeners.move);
|
||||
if (listeners.up) window.removeEventListener("pointerup", listeners.up);
|
||||
if (listeners.cancel) window.removeEventListener("pointercancel", listeners.cancel);
|
||||
if (listeners.key) window.removeEventListener("keydown", listeners.key);
|
||||
if (listeners.scroll) container.removeEventListener("scroll", listeners.scroll);
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
frameId = 0;
|
||||
scrollDelta = 0;
|
||||
if (stopGestureRef.current === stopGesture) stopGestureRef.current = null;
|
||||
};
|
||||
|
||||
stopGesture = () => {
|
||||
if (sessionRef.current !== session) return;
|
||||
sessionRef.current += 1;
|
||||
finishListeners();
|
||||
setMarqueeRect(null);
|
||||
};
|
||||
|
||||
const end = (cancel: boolean) => {
|
||||
if (sessionRef.current !== session) return;
|
||||
const wasActive = active;
|
||||
stopGesture();
|
||||
if (!wasActive) return;
|
||||
suppressClickRef.current = true;
|
||||
if (!cancel) return;
|
||||
const restored = marqueeCancelSelection({
|
||||
wasMultiSelectMode,
|
||||
baselineIds: baseline,
|
||||
});
|
||||
lastSignature = "";
|
||||
lastMode = !restored.isMultiSelectMode;
|
||||
publish(restored.selectedHostIds, restored.isMultiSelectMode);
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
frameId = 0;
|
||||
if (!scrollDelta || sessionRef.current !== session) return;
|
||||
container.scrollTop += scrollDelta;
|
||||
frameId = window.requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
listeners.move = (pointerEvent: PointerEvent) => {
|
||||
lastClientX = pointerEvent.clientX;
|
||||
lastClientY = pointerEvent.clientY;
|
||||
applyAt(pointerEvent.clientX, pointerEvent.clientY);
|
||||
if (active) {
|
||||
pointerEvent.preventDefault();
|
||||
window.getSelection?.()?.removeAllRanges();
|
||||
}
|
||||
const nextBounds = container.getBoundingClientRect();
|
||||
scrollDelta = active
|
||||
? marqueeAutoScrollDelta(pointerEvent.clientY, nextBounds.top, nextBounds.bottom)
|
||||
: 0;
|
||||
if (scrollDelta && !frameId) frameId = window.requestAnimationFrame(tick);
|
||||
if (!scrollDelta && frameId) {
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = 0;
|
||||
}
|
||||
};
|
||||
|
||||
listeners.scroll = () => {
|
||||
if (!active) return;
|
||||
applyAt(lastClientX, lastClientY);
|
||||
};
|
||||
listeners.up = () => end(false);
|
||||
listeners.cancel = () => end(true);
|
||||
listeners.key = (keyEvent: KeyboardEvent) => {
|
||||
if (keyEvent.key !== "Escape") return;
|
||||
if (!active) {
|
||||
end(false);
|
||||
return;
|
||||
}
|
||||
keyEvent.preventDefault();
|
||||
keyEvent.stopPropagation();
|
||||
end(true);
|
||||
};
|
||||
|
||||
stopGestureRef.current = stopGesture;
|
||||
window.addEventListener("pointermove", listeners.move);
|
||||
window.addEventListener("pointerup", listeners.up);
|
||||
window.addEventListener("pointercancel", listeners.cancel);
|
||||
window.addEventListener("keydown", listeners.key);
|
||||
container.addEventListener("scroll", listeners.scroll, { passive: true });
|
||||
}, [
|
||||
containerRef,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
setIsMultiSelectMode,
|
||||
setSelectedHostIds,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const onClickCapture = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
if (!suppressClickRef.current) return;
|
||||
suppressClickRef.current = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return { marqueeRect, onPointerDown, onClickCapture };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
applyVaultHostDragData,
|
||||
resolveVaultHostDragIds,
|
||||
} from "../../domain/vaultHostDrag";
|
||||
|
||||
export function attachVaultHostDragBadge(dataTransfer: DataTransfer, label: string): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const badge = document.createElement("div");
|
||||
badge.textContent = label;
|
||||
badge.setAttribute("data-vault-host-drag-badge", "true");
|
||||
badge.style.position = "fixed";
|
||||
badge.style.top = "-1000px";
|
||||
badge.style.left = "0";
|
||||
badge.style.zIndex = "2147483647";
|
||||
badge.style.padding = "2px 8px";
|
||||
badge.style.borderRadius = "999px";
|
||||
badge.style.background = "#111827";
|
||||
badge.style.color = "#ffffff";
|
||||
badge.style.font = "12px/16px sans-serif";
|
||||
badge.style.pointerEvents = "none";
|
||||
document.body.appendChild(badge);
|
||||
dataTransfer.setDragImage(badge, badge.offsetWidth / 2, badge.offsetHeight / 2);
|
||||
window.setTimeout(() => badge.remove(), 0);
|
||||
}
|
||||
|
||||
export function beginVaultHostCardDrag(input: {
|
||||
dataTransfer: DataTransfer;
|
||||
hostId: string;
|
||||
isMultiSelectMode: boolean;
|
||||
selectedHostIds: ReadonlySet<string> | undefined;
|
||||
countLabel: (count: number) => string;
|
||||
}): string[] | null {
|
||||
const ids = resolveVaultHostDragIds({
|
||||
hostId: input.hostId,
|
||||
isMultiSelectMode: input.isMultiSelectMode,
|
||||
selectedHostIds: input.selectedHostIds ?? new Set<string>(),
|
||||
});
|
||||
if (!ids) return null;
|
||||
applyVaultHostDragData(input.dataTransfer, ids);
|
||||
if (ids.length > 1) attachVaultHostDragBadge(input.dataTransfer, input.countLabel(ids.length));
|
||||
return ids;
|
||||
}
|
||||
@@ -115,6 +115,58 @@ test("group host drop resets host drag state after moving the host to the group"
|
||||
]);
|
||||
});
|
||||
|
||||
test("group drop of a multi-id payload batches every selected host once", () => {
|
||||
const singleCalls: string[] = [];
|
||||
const batchCalls: Array<{ ids: string[]; group: string | null }> = [];
|
||||
|
||||
const handled = handleVaultHostDropToGroup({
|
||||
dataTransfer: {
|
||||
getData: (type: string) => (
|
||||
type === "host-ids" ? JSON.stringify(["a", "b", "c"]) : type === "host-id" ? "a" : ""
|
||||
),
|
||||
},
|
||||
groupPath: "team/nested",
|
||||
moveHostToGroup: (hostId, targetPath) => {
|
||||
singleCalls.push(`${hostId}:${String(targetPath)}`);
|
||||
},
|
||||
moveHostsToGroup: (hostIds, targetPath) => {
|
||||
batchCalls.push({ ids: [...hostIds], group: targetPath });
|
||||
},
|
||||
resetHostDragState: () => {
|
||||
singleCalls.push("reset");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(singleCalls, ["reset"]);
|
||||
assert.deepEqual(batchCalls, [{ ids: ["a", "b", "c"], group: "team/nested" }]);
|
||||
});
|
||||
|
||||
test("root drop of a multi-id payload batches every selected host into all hosts", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
handleVaultRootDrop({
|
||||
dataTransfer: {
|
||||
getData: (type: string) => (type === "host-ids" ? JSON.stringify(["a", "b"]) : ""),
|
||||
},
|
||||
preventDefault: () => calls.push("preventDefault"),
|
||||
setDragOverDropTarget: () => calls.push("drop:null"),
|
||||
moveGroup: () => calls.push("group"),
|
||||
moveHostToGroup: () => calls.push("single"),
|
||||
moveHostsToGroup: (hostIds, targetPath) => {
|
||||
calls.push(`batch:${[...hostIds].join(",")}:${String(targetPath)}`);
|
||||
},
|
||||
resetHostDragState: () => calls.push("resetHostDragState"),
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"preventDefault",
|
||||
"drop:null",
|
||||
"batch:a,b:null",
|
||||
"resetHostDragState",
|
||||
]);
|
||||
});
|
||||
|
||||
test("group drop without a host leaves host drag state alone", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
dispatchVaultHostGroupMove,
|
||||
readVaultDraggedHostIds,
|
||||
} from "../../domain/vaultHostDrag";
|
||||
|
||||
export type VaultDropPosition = "before" | "after";
|
||||
export type VaultDropIntent = VaultDropPosition | "inside";
|
||||
export type VaultDropAxis = "x" | "y";
|
||||
@@ -47,6 +52,7 @@ export const handleVaultRootDrop = ({
|
||||
setDragOverDropTarget,
|
||||
moveGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
}: {
|
||||
dataTransfer: Pick<DataTransfer, "getData">;
|
||||
@@ -54,15 +60,20 @@ export const handleVaultRootDrop = ({
|
||||
setDragOverDropTarget: (target: null) => void;
|
||||
moveGroup: (groupPath: string, targetParentPath: string | null) => void;
|
||||
moveHostToGroup: (hostId: string, targetPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, targetPath: string | null) => void;
|
||||
resetHostDragState: () => void;
|
||||
}) => {
|
||||
preventDefault();
|
||||
setDragOverDropTarget(null);
|
||||
const groupPath = dataTransfer.getData("group-path");
|
||||
const hostId = dataTransfer.getData("host-id");
|
||||
const hostIds = readVaultDraggedHostIds((type) => dataTransfer.getData(type));
|
||||
if (groupPath) moveGroup(groupPath, null);
|
||||
if (hostId) {
|
||||
moveHostToGroup(hostId, null);
|
||||
if (dispatchVaultHostGroupMove({
|
||||
hostIds,
|
||||
groupPath: null,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
})) {
|
||||
resetHostDragState();
|
||||
}
|
||||
};
|
||||
@@ -71,17 +82,23 @@ export const handleVaultHostDropToGroup = ({
|
||||
dataTransfer,
|
||||
groupPath,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
}: {
|
||||
dataTransfer: Pick<DataTransfer, "getData">;
|
||||
groupPath: string | null;
|
||||
moveHostToGroup: (hostId: string, targetPath: string | null) => void;
|
||||
moveHostToGroup?: (hostId: string, targetPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, targetPath: string | null) => void;
|
||||
resetHostDragState: () => void;
|
||||
}) => {
|
||||
const hostId = dataTransfer.getData("host-id");
|
||||
if (!hostId) return false;
|
||||
const moved = dispatchVaultHostGroupMove({
|
||||
hostIds: readVaultDraggedHostIds((type) => dataTransfer.getData(type)),
|
||||
groupPath,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
});
|
||||
if (!moved) return false;
|
||||
|
||||
moveHostToGroup(hostId, groupPath);
|
||||
resetHostDragState();
|
||||
return true;
|
||||
};
|
||||
|
||||
|
After Width: | Height: | Size: 985 KiB |
|
After Width: | Height: | Size: 959 KiB |
|
After Width: | Height: | Size: 931 KiB |
|
After Width: | Height: | Size: 794 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 757 KiB |
|
After Width: | Height: | Size: 892 KiB |
@@ -0,0 +1,66 @@
|
||||
# Shared AI privacy and reversible masking
|
||||
|
||||
AI privacy masking is **off by default**. Enable it in Settings > AI > Tool Access >
|
||||
AI privacy. This shared policy applies to Netcatty-managed context and tools used by
|
||||
Catty, integrated agents and external MCP clients. It is independent of the External
|
||||
MCP switch. External stdio and Streamable HTTP clients use the same policy, including
|
||||
for the environment resource.
|
||||
|
||||
This is not a system-wide AI traffic filter. Third-party agents' native tools,
|
||||
direct file access and other MCP servers are outside the Netcatty-managed boundary.
|
||||
Local terminal display, saved terminal output and actual host data are not changed.
|
||||
|
||||
## Working loop
|
||||
|
||||
A recognized address or credential in managed context or a tool response becomes an
|
||||
opaque token, for example `[NC_<session>_IP_1]`. The same exact value has the same token
|
||||
within its managed conversation or external MCP connection. A later managed command
|
||||
or file-write argument containing that token is restored locally before permission
|
||||
checks and the approval dialog. The host receives the original value; the agent
|
||||
receives masked managed results.
|
||||
|
||||
Mappings stay in memory and are isolated by managed conversation or external MCP
|
||||
connection. No mapping is saved to disk or shared through the app-wide external chat
|
||||
scope. New conversations and reconnects start a new mapping. Unknown, stale, or
|
||||
other-session tokens are rejected rather than sent to the host.
|
||||
|
||||
After changing privacy settings, start a new managed conversation and reconnect
|
||||
external MCP clients. Existing scopes fail closed instead of silently changing
|
||||
their protection. Settings apply only after main-process validation succeeds. A
|
||||
saved legacy External MCP privacy preference is used only if no shared AI privacy
|
||||
preference exists; applying valid settings saves the shared preference. Corrupt
|
||||
saved settings must be repaired rather than silently reverting to unprotected
|
||||
operation.
|
||||
|
||||
## What is covered
|
||||
|
||||
- Recognized credential assignments, authorization bearer values, URL passwords,
|
||||
familiar API token forms, structured secret fields and complete private-key blocks.
|
||||
- IPv4 and IPv6 addresses, with private/local ranges retained by default. Enable
|
||||
**Redact private IP addresses** to hide them too, or select additional CIDRs.
|
||||
Documentation/test addresses are also masked.
|
||||
- Structured host names and user-supplied regular-expression matches.
|
||||
- Netcatty-managed text context, tool results, nested structured results and errors.
|
||||
|
||||
Custom expressions match whole values and use the existing RE2 engine. Unsupported
|
||||
expressions, malformed CIDRs, or empty matches are rejected. Rules and mapping
|
||||
sizes are bounded; exhaustion blocks the response instead of evicting active tokens.
|
||||
|
||||
Binary/base64 tool attachments are blocked while masking is enabled. This feature
|
||||
does not scan images, archives, encoded/encrypted content, files accessed directly
|
||||
by third-party agents, or other MCP servers' results. It cannot guarantee detection
|
||||
of arbitrary credentials, all hostnames, or intentionally obfuscated data. It is an
|
||||
accidental-disclosure safeguard, not a DLP system or a substitute for the normal
|
||||
tool permission policy.
|
||||
|
||||
## Verification
|
||||
|
||||
Keep External MCP disabled and enable AI privacy. Use synthetic values in a new
|
||||
Catty conversation and a new integrated-agent conversation, then repeat through
|
||||
external stdio and HTTP clients after enabling External MCP: read `192.0.2.10`, read
|
||||
`198.51.100.7`, then read the first again. Tokens should be different for different
|
||||
values and stable for repeated reads within the same scope. Write a returned token
|
||||
back through a managed tool or use it in a managed terminal command; the actual
|
||||
file/terminal should contain the original value, never the token. A token from
|
||||
another conversation/client or an earlier scope must fail without dispatching a
|
||||
command. No protection should be implied for third-party native tool/file access.
|
||||
@@ -11,6 +11,13 @@ import {
|
||||
isNetcattyManagedStartupHistoryCommand,
|
||||
} from './remoteHistory.ts';
|
||||
import { buildDockerExecShellCommand, buildDockerLogsCommand } from './systemManager/dockerShell.ts';
|
||||
import {
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
buildKubectlDescribeCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
} from './systemManager/containerRuntime.ts';
|
||||
import { buildTmuxAttachCommand } from './systemManager/tmuxShell.ts';
|
||||
|
||||
test('parseBashHistory: plain lines', () => {
|
||||
@@ -199,8 +206,44 @@ test('isNetcattyAiHistoryCommand: detects AI PTY marker lines', () => {
|
||||
test('isNetcattyManagedStartupHistoryCommand: detects Docker and tmux terminal launch commands', () => {
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildDockerExecShellCommand('587abcdef123')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildDockerLogsCommand('587abcdef123')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildContainerLogsCommand('podman', '587abcdef123')), true);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlLogsCommand('kind-dev', 'default', 'api-0')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(
|
||||
"printf '\\033[H\\033[2J\\033[3J'; _nc_docker_err=$(docker inspect 587abcdef123 2>&1 >/dev/null);",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildTmuxAttachCommand('my-session')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildTmuxAttachCommand('my-session', 2)), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildContainerLogsCommand('podman', 'abc123', 'powershell')), true);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand(buildContainerExecShellCommand('nerdctl', 'abc123', 'cmd')), true);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlLogsCommand('kind-kind', 'default', 'api', undefined, 'powershell')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlDescribeCommand('kind-kind', 'default', 'api', 'cmd')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand(buildKubectlExecShellCommand('kind-kind', 'default', 'api', 'app', 'powershell')),
|
||||
true,
|
||||
);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('podman logs -f abc123'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('kubectl logs api'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('kubectl describe pod api'), false);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand('kubectl --context prod -n default logs -f --tail 200 api'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isNetcattyManagedStartupHistoryCommand('kubectl --context prod describe pod api'),
|
||||
false,
|
||||
);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('docker ps -a'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('docker logs -f 587abcdef123'), false);
|
||||
assert.equal(isNetcattyManagedStartupHistoryCommand('docker exec -it 587abcdef123 bash'), false);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RemoteHistoryEntry, RemoteHistorySource } from './models';
|
||||
import { NETCATTY_POPUP_HISTORY_MARKER } from './systemManager/containerRuntime.ts';
|
||||
|
||||
/** Marker prefix Netcatty AI uses when executing commands via the PTY bridge. */
|
||||
export const NETCATTY_AI_HISTORY_MARKER = '__NCMCP_';
|
||||
@@ -9,11 +10,17 @@ export function isNetcattyAiHistoryCommand(command: string): boolean {
|
||||
}
|
||||
|
||||
const NETCATTY_MANAGED_STARTUP_COMMAND =
|
||||
/^(?:sh\s+-c\s+.*printf .*\\033\[H\\033\[2J\\033\[3J.*_nc_docker_err=.*\bdocker\s+inspect\b|printf '\\033\[H\\033\[2J\\033\[3J';\s*(?:_nc_docker_err=.*\bdocker\s+inspect\b|exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)))/;
|
||||
/^(?:sh\s+-c\s+.*printf .*\\033\[H\\033\[2J\\033\[3J.*(?:_nc_(?:docker|rt)_err=.*\b(?:docker|podman|nerdctl)\s+inspect\b|exec\s+kubectl\b)|printf '\\033\[H\\033\[2J\\033\[3J';\s*(?:_nc_(?:docker|rt)_err=.*\b(?:docker|podman|nerdctl)\s+inspect\b|exec\s+(?:(?:docker|podman|nerdctl)\s+(?:exec|logs)\b|tmux\s+attach\b)))/;
|
||||
|
||||
const NETCATTY_WINDOWS_CONTAINER_POPUP_COMMAND =
|
||||
/^(?:\$__nc_popup='__NC_POPUP__'; |set "__NC_POPUP__=1"& )?(?:docker|podman|nerdctl) inspect [a-zA-Z0-9]+ (?:>\$null; if \(\$LASTEXITCODE -eq 0\) \{|>nul && )/;
|
||||
|
||||
/** True when a shell history line came from a Netcatty-managed terminal launch. */
|
||||
export function isNetcattyManagedStartupHistoryCommand(command: string): boolean {
|
||||
return NETCATTY_MANAGED_STARTUP_COMMAND.test(command.trim());
|
||||
const text = command.trim();
|
||||
return text.includes(NETCATTY_POPUP_HISTORY_MARKER)
|
||||
|| NETCATTY_MANAGED_STARTUP_COMMAND.test(text)
|
||||
|| NETCATTY_WINDOWS_CONTAINER_POPUP_COMMAND.test(text);
|
||||
}
|
||||
|
||||
const ZSH_EXTENDED_RECORD = /^: (\d+):\d+;([\s\S]*)$/;
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
"use strict";
|
||||
|
||||
const CONTAINER_RUNTIMES = ["docker", "podman", "nerdctl"];
|
||||
const SOURCE_ORDER = ["docker", "podman", "nerdctl", "kubernetes"];
|
||||
|
||||
const CLEAR_STARTUP_OUTPUT = "printf '\\033[H\\033[2J\\033[3J';";
|
||||
const NETCATTY_POPUP_HISTORY_MARKER = "__NC_POPUP__";
|
||||
|
||||
function popupHistoryPrefix(shell) {
|
||||
if (shell === "powershell") return `$__nc_popup='${NETCATTY_POPUP_HISTORY_MARKER}'; `;
|
||||
if (shell === "cmd") return `set "${NETCATTY_POPUP_HISTORY_MARKER}=1"& `;
|
||||
return "";
|
||||
}
|
||||
|
||||
function shQuote(str) {
|
||||
return `'${String(str).replace(/'/g, "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
function normalizeContainerRuntime(runtime) {
|
||||
return CONTAINER_RUNTIMES.includes(runtime) ? runtime : "docker";
|
||||
}
|
||||
|
||||
function isContainerSource(value) {
|
||||
return SOURCE_ORDER.includes(value);
|
||||
}
|
||||
|
||||
function runtimeBinary(runtime) {
|
||||
return normalizeContainerRuntime(runtime);
|
||||
}
|
||||
|
||||
function buildRuntimeCommand(runtime, args) {
|
||||
return `${runtimeBinary(runtime)} ${String(args || "")}`.trim();
|
||||
}
|
||||
|
||||
function parseRuntimeProbe(stdout) {
|
||||
const text = stdout || "";
|
||||
const hasFlag = (name) => new RegExp(`(?:^|\\r?\\n)${name}=1(?:\\r?\\n|$)`).test(text);
|
||||
return {
|
||||
docker: hasFlag("__NC_DOCKER__"),
|
||||
podman: hasFlag("__NC_PODMAN__"),
|
||||
nerdctl: hasFlag("__NC_NERDCTL__"),
|
||||
kubernetes: hasFlag("__NC_KUBECTL__"),
|
||||
};
|
||||
}
|
||||
|
||||
function sourcesFromCapabilities(capabilities) {
|
||||
if (!capabilities) return [];
|
||||
const present = {
|
||||
docker: capabilities.hasDocker === true || capabilities.docker === true,
|
||||
podman: capabilities.hasPodman === true || capabilities.podman === true,
|
||||
nerdctl: capabilities.hasNerdctl === true || capabilities.nerdctl === true,
|
||||
kubernetes: capabilities.hasKubectl === true || capabilities.kubernetes === true,
|
||||
};
|
||||
return SOURCE_ORDER.filter((id) => present[id]);
|
||||
}
|
||||
|
||||
function pickContainerSource(detected, current, remembered) {
|
||||
const available = SOURCE_ORDER.filter((id) => Array.isArray(detected) && detected.includes(id));
|
||||
if (current && available.includes(current)) return current;
|
||||
if (remembered && available.includes(remembered)) return remembered;
|
||||
return available[0] || null;
|
||||
}
|
||||
|
||||
function rememberedContainerSource(preference, detected) {
|
||||
const source = preference?.source;
|
||||
if (!isContainerSource(source)) return null;
|
||||
const available = Array.isArray(detected) ? detected : [];
|
||||
if (source === "kubernetes" && !available.includes("kubernetes") && CONTAINER_RUNTIMES.includes(preference?.engine)) {
|
||||
return preference.engine;
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
function reconcileContainerSource(detected, current, preference) {
|
||||
const available = SOURCE_ORDER.filter((id) => Array.isArray(detected) && detected.includes(id));
|
||||
const preferred = rememberedContainerSource(preference, available);
|
||||
if (preferred && available.includes(preferred)) return preferred;
|
||||
return pickContainerSource(available, current, preferred);
|
||||
}
|
||||
|
||||
function isAutomaticRuntimeFallback(preference, source, detected) {
|
||||
if (!preference?.source || !isContainerSource(source) || preference.source === source) return false;
|
||||
const available = Array.isArray(detected) ? detected : [];
|
||||
if (available.includes(preference.source)) return false;
|
||||
const automatic = pickContainerSource(
|
||||
available,
|
||||
null,
|
||||
rememberedContainerSource(preference, available),
|
||||
);
|
||||
return source === automatic;
|
||||
}
|
||||
|
||||
function firstContainerRuntime(detected) {
|
||||
return CONTAINER_RUNTIMES.find((id) => Array.isArray(detected) && detected.includes(id)) || null;
|
||||
}
|
||||
|
||||
function socketNeedles(runtime) {
|
||||
if (runtime === "podman") {
|
||||
return ["podman.sock", "/run/podman", "podman daemon"];
|
||||
}
|
||||
if (runtime === "nerdctl") {
|
||||
return ["containerd.sock", "/run/containerd"];
|
||||
}
|
||||
return ["docker daemon", "docker.sock", "/var/run/docker.sock", "connect to the docker daemon"];
|
||||
}
|
||||
|
||||
function isRuntimeSocketPermissionError(runtime, result) {
|
||||
const text = `${result?.stderr || ""}\n${result?.stdout || ""}\n${result?.error || ""}`.toLowerCase();
|
||||
if (!text.includes("permission denied")) return false;
|
||||
return socketNeedles(normalizeContainerRuntime(runtime)).some((needle) => text.includes(needle));
|
||||
}
|
||||
|
||||
function socketCasePatterns(runtime) {
|
||||
if (runtime === "podman") {
|
||||
return [
|
||||
"*permission\\ denied*podman.sock*",
|
||||
"*podman.sock*permission\\ denied*",
|
||||
"*permission\\ denied*/run/podman*",
|
||||
"*/run/podman*permission\\ denied*",
|
||||
"*permission\\ denied*podman\\ daemon*",
|
||||
"*podman\\ daemon*permission\\ denied*",
|
||||
];
|
||||
}
|
||||
if (runtime === "nerdctl") {
|
||||
return [
|
||||
"*permission\\ denied*containerd.sock*",
|
||||
"*containerd.sock*permission\\ denied*",
|
||||
"*permission\\ denied*/run/containerd*",
|
||||
"*/run/containerd*permission\\ denied*",
|
||||
];
|
||||
}
|
||||
return [
|
||||
"*permission\\ denied*docker\\ daemon*",
|
||||
"*docker\\ daemon*permission\\ denied*",
|
||||
"*permission\\ denied*docker.sock*",
|
||||
"*docker.sock*permission\\ denied*",
|
||||
"*permission\\ denied*/var/run/docker.sock*",
|
||||
"*/var/run/docker.sock*permission\\ denied*",
|
||||
"*permission\\ denied*connect\\ to\\ the\\ docker\\ daemon*",
|
||||
"*connect\\ to\\ the\\ docker\\ daemon*permission\\ denied*",
|
||||
];
|
||||
}
|
||||
|
||||
function sanitizeContainerId(id) {
|
||||
return String(id || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 64);
|
||||
}
|
||||
|
||||
function containerInteractiveArgs(containerId, shell) {
|
||||
if (shell === "cmd") {
|
||||
return `exec -it ${containerId} sh -c "command -v bash >/dev/null 2>&1 && exec bash || exec sh"`;
|
||||
}
|
||||
return `exec -it ${containerId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`;
|
||||
}
|
||||
|
||||
function buildContainerCommandWithSudoFallback(runtime, containerId, cliArgs, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const bin = runtimeBinary(runtime);
|
||||
const plainCommand = `${bin} ${cliArgs}`;
|
||||
if (popupShell === "powershell") {
|
||||
return `${popupHistoryPrefix(popupShell)}${bin} inspect ${containerId} >$null; if ($LASTEXITCODE -eq 0) { ${plainCommand} } else { exit $LASTEXITCODE }`;
|
||||
}
|
||||
if (popupShell === "cmd") {
|
||||
return `${popupHistoryPrefix(popupShell)}${bin} inspect ${containerId} >nul && ${plainCommand}`;
|
||||
}
|
||||
const sudoCommand = `sudo ${plainCommand}`;
|
||||
const script = [
|
||||
CLEAR_STARTUP_OUTPUT,
|
||||
`_nc_rt_err=$(${bin} inspect ${containerId} 2>&1 >/dev/null);`,
|
||||
"_nc_rt_status=$?;",
|
||||
`if [ "$_nc_rt_status" -eq 0 ]; then exec ${plainCommand}; fi;`,
|
||||
"_nc_rt_lc=$(printf '%s' \"$_nc_rt_err\" | tr '[:upper:]' '[:lower:]');",
|
||||
"case \"$_nc_rt_lc\" in",
|
||||
`${socketCasePatterns(normalizeContainerRuntime(runtime)).join("|")}) exec ${sudoCommand} ;;`,
|
||||
"*) printf '%s\\n' \"$_nc_rt_err\" >&2; exit \"$_nc_rt_status\" ;;",
|
||||
"esac",
|
||||
].join(" ");
|
||||
return `sh -c ${shQuote(script)}`;
|
||||
}
|
||||
|
||||
function buildContainerExecShellCommand(runtime, containerId, shell) {
|
||||
const safeId = sanitizeContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return buildContainerCommandWithSudoFallback(
|
||||
runtime,
|
||||
safeId,
|
||||
containerInteractiveArgs(safeId, normalizePopupShell(shell)),
|
||||
shell,
|
||||
);
|
||||
}
|
||||
|
||||
function buildContainerLogsCommand(runtime, containerId, shell) {
|
||||
const safeId = sanitizeContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return buildContainerCommandWithSudoFallback(runtime, safeId, `logs -f --tail 200 ${safeId}`, shell);
|
||||
}
|
||||
|
||||
function hasControlChar(text) {
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const code = text.charCodeAt(i);
|
||||
if (code <= 31 || code === 127) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSafeShellArg(value, shell) {
|
||||
const text = String(value ?? "");
|
||||
if (!text || text.length > 512) return false;
|
||||
if (hasControlChar(text)) return false;
|
||||
// Interactive cmd.exe expands %VAR% inside double quotes, and %% is not a
|
||||
// reliable escape there. With /V:ON it also expands !VAR! inside quotes.
|
||||
// Reject those arguments instead of targeting a substituted context.
|
||||
if (shell === "cmd" && (text.includes("%") || text.includes("!"))) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizePopupShell(shell) {
|
||||
return shell === "powershell" || shell === "cmd" ? shell : "posix";
|
||||
}
|
||||
|
||||
function quoteShellArg(shell, value) {
|
||||
const text = String(value);
|
||||
if (shell === "cmd") return `"${text.replace(/"/g, '""')}"`;
|
||||
if (shell === "powershell") return `'${text.replace(/'/g, "''")}'`;
|
||||
return shQuote(text);
|
||||
}
|
||||
|
||||
function kubectlPrefix(context, namespace, shell = "posix") {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
if (!isSafeShellArg(context, popupShell)) return null;
|
||||
const parts = ["kubectl", "--context", quoteShellArg(popupShell, context)];
|
||||
if (namespace != null && namespace !== "") {
|
||||
if (!isSafeShellArg(namespace, popupShell)) return null;
|
||||
parts.push("-n", quoteShellArg(popupShell, namespace));
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function optionalContainerArg(container, shell = "posix") {
|
||||
if (container == null || container === "") return "";
|
||||
if (!isSafeShellArg(container, normalizePopupShell(shell))) return null;
|
||||
return ` -c ${quoteShellArg(normalizePopupShell(shell), container)}`;
|
||||
}
|
||||
|
||||
function isKubectlForbiddenError(message) {
|
||||
return /\bforbidden\b/i.test(String(message || ""));
|
||||
}
|
||||
|
||||
function isKubectlUnreachableError(message) {
|
||||
const text = String(message || "");
|
||||
if (!text.trim() || isKubectlForbiddenError(text)) return false;
|
||||
return /couldn'?t get current server api group list|could not get current server api group list|unable to connect to the server|connection refused|dial tcp|i\/o timeout|no such host|network is unreachable|context deadline exceeded|tls handshake timeout/i.test(text);
|
||||
}
|
||||
|
||||
function isNerdctlUnreachableError(message) {
|
||||
const text = String(message || "").toLowerCase();
|
||||
if (!text.trim()) return false;
|
||||
if (text.includes("rootless containerd") || text.includes("xdg_runtime_dir")) return true;
|
||||
if (text.includes("cannot connect to the docker daemon")) return true;
|
||||
if (!text.includes("containerd")) return false;
|
||||
return /not running|connection refused|no such file|cannot connect|failed to connect|unavailable/.test(text);
|
||||
}
|
||||
|
||||
const POD_STATUS_MESSAGE_KEYS = {
|
||||
Running: "systemManager.k8s.status.Running",
|
||||
Pending: "systemManager.k8s.status.Pending",
|
||||
Completed: "systemManager.k8s.status.Completed",
|
||||
Succeeded: "systemManager.k8s.status.Succeeded",
|
||||
Failed: "systemManager.k8s.status.Failed",
|
||||
Unknown: "systemManager.k8s.status.Unknown",
|
||||
CrashLoopBackOff: "systemManager.k8s.status.CrashLoopBackOff",
|
||||
ContainerCreating: "systemManager.k8s.status.ContainerCreating",
|
||||
Evicted: "systemManager.k8s.status.Evicted",
|
||||
Terminating: "systemManager.k8s.status.Terminating",
|
||||
ImagePullBackOff: "systemManager.k8s.status.ImagePullBackOff",
|
||||
ErrImagePull: "systemManager.k8s.status.ErrImagePull",
|
||||
Error: "systemManager.k8s.status.Error",
|
||||
OOMKilled: "systemManager.k8s.status.OOMKilled",
|
||||
};
|
||||
|
||||
function podStatusMessageKey(status) {
|
||||
return POD_STATUS_MESSAGE_KEYS[String(status || "")] || null;
|
||||
}
|
||||
|
||||
function runtimeFailureMessage(errorCode, context) {
|
||||
if (errorCode === "nerdctlUnreachable") {
|
||||
return { key: "systemManager.errors.nerdctlUnreachable" };
|
||||
}
|
||||
if (errorCode === "clusterUnreachable") {
|
||||
const name = typeof context === "string" ? context.trim() : "";
|
||||
if (name) {
|
||||
return {
|
||||
key: "systemManager.errors.clusterUnreachable",
|
||||
values: { context: name },
|
||||
};
|
||||
}
|
||||
return { key: "systemManager.errors.clusterUnreachableGeneric" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function wrapClearAndExec(command) {
|
||||
return `sh -c ${shQuote(`${CLEAR_STARTUP_OUTPUT} exec ${command}`)}`;
|
||||
}
|
||||
|
||||
function buildKubectlContextsCommand() {
|
||||
return "kubectl config view -o json";
|
||||
}
|
||||
|
||||
function buildKubectlNamespacesCommand(context, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
if (!isSafeShellArg(context, popupShell)) return null;
|
||||
return `kubectl --context ${quoteShellArg(popupShell, context)} get namespaces -o json`;
|
||||
}
|
||||
|
||||
function buildKubectlPodsCommand(context, namespace, shell) {
|
||||
const prefix = kubectlPrefix(context, namespace, shell);
|
||||
if (!prefix) return null;
|
||||
return `${prefix} get pods -o json`;
|
||||
}
|
||||
|
||||
function buildKubectlDeletePodCommand(context, namespace, pod, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const prefix = kubectlPrefix(context, namespace, popupShell);
|
||||
if (!prefix || !isSafeShellArg(pod, popupShell)) return null;
|
||||
return `${prefix} delete pod ${quoteShellArg(popupShell, pod)} --wait=false`;
|
||||
}
|
||||
|
||||
function containerShellArg(shell) {
|
||||
if (shell === "cmd") {
|
||||
return 'sh -c "command -v bash >/dev/null 2>&1 && exec bash || exec sh"';
|
||||
}
|
||||
return "sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'";
|
||||
}
|
||||
|
||||
function finishKubectlPopup(shell, command) {
|
||||
return shell === "posix" ? wrapClearAndExec(command) : `${popupHistoryPrefix(shell)}${command}`;
|
||||
}
|
||||
|
||||
function buildKubectlExecShellCommand(context, namespace, pod, container, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const prefix = kubectlPrefix(context, namespace, popupShell);
|
||||
if (!prefix) return 'echo "Invalid context"';
|
||||
if (!isSafeShellArg(pod, popupShell)) return 'echo "Invalid pod name"';
|
||||
const containerArg = optionalContainerArg(container, popupShell);
|
||||
if (containerArg == null) return 'echo "Invalid container name"';
|
||||
const command = `${prefix} exec -it ${quoteShellArg(popupShell, pod)}${containerArg} -- ${containerShellArg(popupShell)}`;
|
||||
return finishKubectlPopup(popupShell, command);
|
||||
}
|
||||
|
||||
function buildKubectlLogsCommand(context, namespace, pod, container, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const prefix = kubectlPrefix(context, namespace, popupShell);
|
||||
if (!prefix) return 'echo "Invalid context"';
|
||||
if (!isSafeShellArg(pod, popupShell)) return 'echo "Invalid pod name"';
|
||||
const containerArg = optionalContainerArg(container, popupShell);
|
||||
if (containerArg == null) return 'echo "Invalid container name"';
|
||||
const command = `${prefix} logs -f --tail 200 ${quoteShellArg(popupShell, pod)}${containerArg}`;
|
||||
return finishKubectlPopup(popupShell, command);
|
||||
}
|
||||
|
||||
function buildKubectlDescribeCommand(context, namespace, pod, shell) {
|
||||
const popupShell = normalizePopupShell(shell);
|
||||
const prefix = kubectlPrefix(context, namespace, popupShell);
|
||||
if (!prefix) return 'echo "Invalid context"';
|
||||
if (!isSafeShellArg(pod, popupShell)) return 'echo "Invalid pod name"';
|
||||
const command = `${prefix} describe pod ${quoteShellArg(popupShell, pod)}`;
|
||||
return finishKubectlPopup(popupShell, command);
|
||||
}
|
||||
|
||||
function parseJsonValue(text) {
|
||||
const raw = String(text || "");
|
||||
for (let i = 0; i < raw.length; i += 1) {
|
||||
const ch = raw[i];
|
||||
if (ch !== "{" && ch !== "[") continue;
|
||||
try {
|
||||
return JSON.parse(raw.slice(i));
|
||||
} catch {
|
||||
// A bracketed banner such as [WARN] is not the JSON document.
|
||||
}
|
||||
}
|
||||
throw new Error("Invalid JSON");
|
||||
}
|
||||
|
||||
function parseKubeconfigContexts(jsonText) {
|
||||
const parsed = parseJsonValue(jsonText);
|
||||
const current = typeof parsed["current-context"] === "string" ? parsed["current-context"] : "";
|
||||
const contexts = [];
|
||||
const list = Array.isArray(parsed.contexts) ? parsed.contexts : [];
|
||||
for (const entry of list) {
|
||||
const name = typeof entry?.name === "string" ? entry.name : "";
|
||||
if (!name) continue;
|
||||
const rawNamespace = entry?.context?.namespace;
|
||||
const namespace = typeof rawNamespace === "string" && rawNamespace ? rawNamespace : "default";
|
||||
contexts.push({
|
||||
name,
|
||||
namespace,
|
||||
current: name === current,
|
||||
});
|
||||
}
|
||||
return { contexts, current };
|
||||
}
|
||||
|
||||
function parseNamespaceList(jsonText) {
|
||||
const parsed = parseJsonValue(jsonText);
|
||||
const items = Array.isArray(parsed.items) ? parsed.items : [];
|
||||
const names = [];
|
||||
for (const item of items) {
|
||||
const name = item?.metadata?.name;
|
||||
if (typeof name === "string" && name) names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function formatPodAge(iso, now = Date.now()) {
|
||||
const timestamp = Date.parse(String(iso || ""));
|
||||
if (!Number.isFinite(timestamp)) return "";
|
||||
const seconds = Math.max(0, Math.floor((now - timestamp) / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
}
|
||||
|
||||
function podDisplayStatus(pod) {
|
||||
if (typeof pod?.metadata?.deletionTimestamp === "string" && pod.metadata.deletionTimestamp) {
|
||||
return "Terminating";
|
||||
}
|
||||
const statuses = [
|
||||
...(Array.isArray(pod?.status?.initContainerStatuses) ? pod.status.initContainerStatuses : []),
|
||||
...(Array.isArray(pod?.status?.containerStatuses) ? pod.status.containerStatuses : []),
|
||||
];
|
||||
for (const status of statuses) {
|
||||
const reason = status?.state?.waiting?.reason;
|
||||
if (reason) return String(reason);
|
||||
}
|
||||
const podReason = typeof pod?.status?.reason === "string" ? pod.status.reason.trim() : "";
|
||||
if (podReason) return podReason;
|
||||
for (const status of statuses) {
|
||||
const reason = status?.state?.terminated?.reason;
|
||||
if (reason && reason !== "Completed") return String(reason);
|
||||
}
|
||||
const phase = String(pod?.status?.phase || "Unknown");
|
||||
if (phase === "Succeeded") return "Completed";
|
||||
return phase;
|
||||
}
|
||||
|
||||
function podContainerNames(pod) {
|
||||
const names = [];
|
||||
const specs = [
|
||||
...(Array.isArray(pod?.spec?.containers) ? pod.spec.containers : []),
|
||||
...(Array.isArray(pod?.spec?.initContainers) ? pod.spec.initContainers : []),
|
||||
...(Array.isArray(pod?.spec?.ephemeralContainers) ? pod.spec.ephemeralContainers : []),
|
||||
];
|
||||
for (const container of specs) {
|
||||
const name = container?.name;
|
||||
if (typeof name === "string" && name && !names.includes(name)) names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function podShellContainerNames(pod) {
|
||||
const running = new Set();
|
||||
const statuses = [
|
||||
...(Array.isArray(pod?.status?.containerStatuses) ? pod.status.containerStatuses : []),
|
||||
...(Array.isArray(pod?.status?.initContainerStatuses) ? pod.status.initContainerStatuses : []),
|
||||
...(Array.isArray(pod?.status?.ephemeralContainerStatuses) ? pod.status.ephemeralContainerStatuses : []),
|
||||
];
|
||||
for (const status of statuses) {
|
||||
if (status?.state?.running && typeof status.name === "string" && status.name) {
|
||||
running.add(status.name);
|
||||
}
|
||||
}
|
||||
const ordered = podContainerNames(pod).filter((name) => running.has(name));
|
||||
for (const name of running) {
|
||||
if (!ordered.includes(name)) ordered.push(name);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function podRestartCount(pod) {
|
||||
const statuses = [
|
||||
...(Array.isArray(pod?.status?.containerStatuses) ? pod.status.containerStatuses : []),
|
||||
...(Array.isArray(pod?.status?.initContainerStatuses) ? pod.status.initContainerStatuses : []),
|
||||
];
|
||||
let restarts = 0;
|
||||
for (const status of statuses) {
|
||||
const count = Number(status?.restartCount || 0);
|
||||
if (Number.isFinite(count)) restarts += count;
|
||||
}
|
||||
return restarts;
|
||||
}
|
||||
|
||||
function parseKubectlPodList(jsonText, now = Date.now()) {
|
||||
const parsed = parseJsonValue(jsonText);
|
||||
const items = Array.isArray(parsed.items) ? parsed.items : [];
|
||||
const pods = [];
|
||||
for (const pod of items) {
|
||||
const name = typeof pod?.metadata?.name === "string" ? pod.metadata.name : "";
|
||||
if (!name) continue;
|
||||
pods.push({
|
||||
name,
|
||||
namespace: typeof pod?.metadata?.namespace === "string" ? pod.metadata.namespace : "",
|
||||
status: podDisplayStatus(pod),
|
||||
restarts: podRestartCount(pod),
|
||||
age: formatPodAge(pod?.metadata?.creationTimestamp, now),
|
||||
containers: podContainerNames(pod),
|
||||
shellContainers: podShellContainerNames(pod),
|
||||
});
|
||||
}
|
||||
return pods;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
NETCATTY_POPUP_HISTORY_MARKER,
|
||||
CONTAINER_RUNTIMES,
|
||||
SOURCE_ORDER,
|
||||
normalizeContainerRuntime,
|
||||
isContainerSource,
|
||||
runtimeBinary,
|
||||
buildRuntimeCommand,
|
||||
parseRuntimeProbe,
|
||||
sourcesFromCapabilities,
|
||||
pickContainerSource,
|
||||
rememberedContainerSource,
|
||||
reconcileContainerSource,
|
||||
isAutomaticRuntimeFallback,
|
||||
firstContainerRuntime,
|
||||
isRuntimeSocketPermissionError,
|
||||
sanitizeContainerId,
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
buildKubectlContextsCommand,
|
||||
buildKubectlNamespacesCommand,
|
||||
buildKubectlPodsCommand,
|
||||
buildKubectlDeletePodCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
buildKubectlDescribeCommand,
|
||||
isKubectlForbiddenError,
|
||||
isKubectlUnreachableError,
|
||||
isNerdctlUnreachableError,
|
||||
podStatusMessageKey,
|
||||
runtimeFailureMessage,
|
||||
parseKubeconfigContexts,
|
||||
parseNamespaceList,
|
||||
parseKubectlPodList,
|
||||
formatPodAge,
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
export type ContainerRuntimeId = "docker" | "podman" | "nerdctl";
|
||||
export type ContainerSourceId = ContainerRuntimeId | "kubernetes";
|
||||
|
||||
export interface RuntimeProbeFlags {
|
||||
docker: boolean;
|
||||
podman: boolean;
|
||||
nerdctl: boolean;
|
||||
kubernetes: boolean;
|
||||
}
|
||||
|
||||
export interface KubeContextInfo {
|
||||
name: string;
|
||||
namespace: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
export interface KubectlPodInfo {
|
||||
name: string;
|
||||
namespace: string;
|
||||
status: string;
|
||||
restarts: number;
|
||||
age: string;
|
||||
containers: string[];
|
||||
shellContainers: string[];
|
||||
}
|
||||
|
||||
export const NETCATTY_POPUP_HISTORY_MARKER: string;
|
||||
|
||||
export function normalizeContainerRuntime(runtime: unknown): ContainerRuntimeId;
|
||||
export function isContainerSource(value: unknown): value is ContainerSourceId;
|
||||
export function runtimeBinary(runtime: unknown): string;
|
||||
export function buildRuntimeCommand(runtime: unknown, args: string): string;
|
||||
export function parseRuntimeProbe(stdout: string): RuntimeProbeFlags;
|
||||
export function sourcesFromCapabilities(capabilities: {
|
||||
hasDocker?: boolean;
|
||||
hasPodman?: boolean;
|
||||
hasNerdctl?: boolean;
|
||||
hasKubectl?: boolean;
|
||||
docker?: boolean;
|
||||
podman?: boolean;
|
||||
nerdctl?: boolean;
|
||||
kubernetes?: boolean;
|
||||
} | null | undefined): ContainerSourceId[];
|
||||
export function pickContainerSource(
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
current: ContainerSourceId | null | undefined,
|
||||
remembered: ContainerSourceId | null | undefined,
|
||||
): ContainerSourceId | null;
|
||||
export function rememberedContainerSource(
|
||||
preference: { source?: string; engine?: string } | null | undefined,
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
): ContainerSourceId | null;
|
||||
export function reconcileContainerSource(
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
current: ContainerSourceId | null | undefined,
|
||||
preference: { source?: string; engine?: string } | null | undefined,
|
||||
): ContainerSourceId | null;
|
||||
export function isAutomaticRuntimeFallback(
|
||||
preference: { source?: string; engine?: string } | null | undefined,
|
||||
source: ContainerSourceId | null | undefined,
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
): boolean;
|
||||
export function firstContainerRuntime(
|
||||
detected: readonly ContainerSourceId[] | null | undefined,
|
||||
): ContainerRuntimeId | null;
|
||||
export function isRuntimeSocketPermissionError(
|
||||
runtime: unknown,
|
||||
result: { stderr?: string; stdout?: string; error?: string } | null | undefined,
|
||||
): boolean;
|
||||
export function sanitizeContainerId(id: string): string;
|
||||
export function buildContainerExecShellCommand(runtime: unknown, containerId: string, shell?: string): string;
|
||||
export function buildContainerLogsCommand(runtime: unknown, containerId: string, shell?: string): string;
|
||||
export function buildKubectlContextsCommand(): string;
|
||||
export function buildKubectlNamespacesCommand(context: string, shell?: string): string | null;
|
||||
export function buildKubectlPodsCommand(context: string, namespace: string, shell?: string): string | null;
|
||||
export function buildKubectlDeletePodCommand(context: string, namespace: string, pod: string, shell?: string): string | null;
|
||||
export type PopupShell = "posix" | "powershell" | "cmd";
|
||||
export function isKubectlForbiddenError(message: string | null | undefined): boolean;
|
||||
export function isKubectlUnreachableError(message: string | null | undefined): boolean;
|
||||
export function isNerdctlUnreachableError(message: string | null | undefined): boolean;
|
||||
export function podStatusMessageKey(status: string | null | undefined): string | null;
|
||||
export function runtimeFailureMessage(
|
||||
errorCode: string | null | undefined,
|
||||
context?: string | null,
|
||||
): { key: string; values?: Record<string, string> } | null;
|
||||
export function buildKubectlExecShellCommand(
|
||||
context: string,
|
||||
namespace: string,
|
||||
pod: string,
|
||||
container?: string,
|
||||
shell?: string,
|
||||
): string;
|
||||
export function buildKubectlLogsCommand(
|
||||
context: string,
|
||||
namespace: string,
|
||||
pod: string,
|
||||
container?: string,
|
||||
shell?: string,
|
||||
): string;
|
||||
export function buildKubectlDescribeCommand(
|
||||
context: string,
|
||||
namespace: string,
|
||||
pod: string,
|
||||
shell?: string,
|
||||
): string;
|
||||
export function parseKubeconfigContexts(jsonText: string): { contexts: KubeContextInfo[]; current: string };
|
||||
export function parseNamespaceList(jsonText: string): string[];
|
||||
export function parseKubectlPodList(jsonText: string, now?: number): KubectlPodInfo[];
|
||||
export function formatPodAge(iso: string, now?: number): string;
|
||||
@@ -0,0 +1,486 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
buildKubectlDeletePodCommand,
|
||||
buildKubectlDescribeCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
buildKubectlNamespacesCommand,
|
||||
buildKubectlPodsCommand,
|
||||
buildRuntimeCommand,
|
||||
firstContainerRuntime,
|
||||
formatPodAge,
|
||||
isRuntimeSocketPermissionError,
|
||||
parseKubeconfigContexts,
|
||||
parseKubectlPodList,
|
||||
parseNamespaceList,
|
||||
parseRuntimeProbe,
|
||||
isAutomaticRuntimeFallback,
|
||||
pickContainerSource,
|
||||
reconcileContainerSource,
|
||||
rememberedContainerSource,
|
||||
sourcesFromCapabilities,
|
||||
isKubectlForbiddenError,
|
||||
isKubectlUnreachableError,
|
||||
isNerdctlUnreachableError,
|
||||
podStatusMessageKey,
|
||||
runtimeFailureMessage,
|
||||
} = require("./containerRuntime.shared.cjs");
|
||||
|
||||
test("parseRuntimeProbe reads line-anchored command -v markers", () => {
|
||||
const probe = parseRuntimeProbe([
|
||||
"command -v docker >/dev/null && printf __NC_DOCKER__=1",
|
||||
"__NC_OS__=Linux",
|
||||
"__NC_DOCKER__=1",
|
||||
"__NC_PODMAN__=1",
|
||||
"echo __NC_NERDCTL__=1 trailing",
|
||||
"__NC_KUBECTL__=1",
|
||||
].join("\n"));
|
||||
|
||||
assert.deepEqual(probe, {
|
||||
docker: true,
|
||||
podman: true,
|
||||
nerdctl: false,
|
||||
kubernetes: true,
|
||||
});
|
||||
assert.deepEqual(sourcesFromCapabilities(probe), ["docker", "podman", "kubernetes"]);
|
||||
});
|
||||
|
||||
test("sourcesFromCapabilities keeps a stable runtime order and ignores a docker-only host's missing peers", () => {
|
||||
assert.deepEqual(sourcesFromCapabilities({ hasDocker: true }), ["docker"]);
|
||||
assert.deepEqual(
|
||||
sourcesFromCapabilities({ hasNerdctl: true, hasKubectl: true, hasPodman: true }),
|
||||
["podman", "nerdctl", "kubernetes"],
|
||||
);
|
||||
assert.deepEqual(sourcesFromCapabilities(undefined), []);
|
||||
});
|
||||
|
||||
test("pickContainerSource remembers the last choice per host and falls back when it disappears", () => {
|
||||
assert.equal(
|
||||
pickContainerSource(["docker", "podman", "kubernetes"], null, "podman"),
|
||||
"podman",
|
||||
);
|
||||
assert.equal(
|
||||
pickContainerSource(["docker", "podman"], "kubernetes", "podman"),
|
||||
"podman",
|
||||
);
|
||||
assert.equal(
|
||||
pickContainerSource(["docker", "podman", "kubernetes"], "kubernetes", "docker"),
|
||||
"kubernetes",
|
||||
);
|
||||
assert.equal(pickContainerSource(["nerdctl"], null, "docker"), "nerdctl");
|
||||
assert.equal(
|
||||
rememberedContainerSource({ source: "kubernetes", engine: "podman" }, ["docker", "podman"]),
|
||||
"podman",
|
||||
);
|
||||
assert.equal(
|
||||
rememberedContainerSource({ source: "kubernetes", engine: "podman" }, ["docker", "podman", "kubernetes"]),
|
||||
"kubernetes",
|
||||
);
|
||||
assert.equal(
|
||||
pickContainerSource(
|
||||
["docker", "podman"],
|
||||
"kubernetes",
|
||||
rememberedContainerSource({ source: "kubernetes", engine: "podman" }, ["docker", "podman"]),
|
||||
),
|
||||
"podman",
|
||||
);
|
||||
assert.equal(firstContainerRuntime(["kubernetes", "podman"]), "podman");
|
||||
assert.equal(firstContainerRuntime(["kubernetes"]), null);
|
||||
});
|
||||
|
||||
test("reconcileContainerSource restores a runtime after a probe gap and does not treat the fallback as the choice", () => {
|
||||
const nerdctl = { source: "nerdctl", engine: "nerdctl" };
|
||||
assert.equal(reconcileContainerSource(["docker", "podman"], "nerdctl", nerdctl), "docker");
|
||||
assert.equal(isAutomaticRuntimeFallback(nerdctl, "docker", ["docker", "podman"]), true);
|
||||
assert.equal(
|
||||
reconcileContainerSource(["docker", "podman", "nerdctl"], "docker", nerdctl),
|
||||
"nerdctl",
|
||||
);
|
||||
assert.equal(isAutomaticRuntimeFallback(nerdctl, "podman", ["docker", "podman"]), false);
|
||||
|
||||
const kubernetes = { source: "kubernetes", engine: "podman" };
|
||||
assert.equal(reconcileContainerSource(["docker", "podman"], "kubernetes", kubernetes), "podman");
|
||||
assert.equal(isAutomaticRuntimeFallback(kubernetes, "podman", ["docker", "podman"]), true);
|
||||
assert.equal(
|
||||
reconcileContainerSource(["docker", "podman", "kubernetes"], "podman", kubernetes),
|
||||
"kubernetes",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildRuntimeCommand uses the detected CLI and defaults unknown runtimes to docker", () => {
|
||||
assert.equal(buildRuntimeCommand("docker", "ps -a --format '{{json .}}'"), "docker ps -a --format '{{json .}}'");
|
||||
assert.equal(buildRuntimeCommand("podman", "start abc123"), "podman start abc123");
|
||||
assert.equal(buildRuntimeCommand("nerdctl", "logs -f --tail 200 abc123"), "nerdctl logs -f --tail 200 abc123");
|
||||
assert.equal(buildRuntimeCommand("kubernetes", "ps"), "docker ps");
|
||||
assert.equal(buildRuntimeCommand(undefined, "inspect abc"), "docker inspect abc");
|
||||
});
|
||||
|
||||
test("interactive container commands keep the docker sudo probe and swap the binary", () => {
|
||||
const dockerShell = buildContainerExecShellCommand("docker", "587abcdef123");
|
||||
assert.match(dockerShell, /^sh -c /);
|
||||
assert.match(dockerShell, /docker inspect 587abcdef123/);
|
||||
assert.match(dockerShell, /exec docker exec -it 587abcdef123/);
|
||||
assert.match(dockerShell, /exec sudo docker exec -it 587abcdef123/);
|
||||
assert.match(dockerShell, /permission\\ denied.*docker\.sock.*docker\.sock.*permission\\ denied/);
|
||||
assert.equal(dockerShell.includes("\n"), false);
|
||||
assert.doesNotMatch(dockerShell, /sudo -S/);
|
||||
|
||||
const podmanLogs = buildContainerLogsCommand("podman", "587abcdef123");
|
||||
assert.match(podmanLogs, /podman inspect 587abcdef123/);
|
||||
assert.match(podmanLogs, /exec podman logs -f --tail 200 587abcdef123/);
|
||||
assert.match(podmanLogs, /exec sudo podman logs -f --tail 200 587abcdef123/);
|
||||
assert.match(podmanLogs, /podman\.sock/);
|
||||
assert.doesNotMatch(podmanLogs, /docker/);
|
||||
|
||||
const nerdctlShell = buildContainerExecShellCommand("nerdctl", "abc");
|
||||
assert.match(nerdctlShell, /exec nerdctl exec -it abc/);
|
||||
assert.match(nerdctlShell, /exec sudo nerdctl exec -it abc/);
|
||||
assert.match(nerdctlShell, /containerd\.sock/);
|
||||
});
|
||||
|
||||
test("socket permission fallback is specific to each runtime", () => {
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("docker", { stderr: "dial unix /var/run/docker.sock: connect: permission denied" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("docker", { stderr: "permission denied" }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("podman", { stderr: "dial unix /run/podman/podman.sock: connect: permission denied" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("nerdctl", { stderr: "dial unix /run/containerd/containerd.sock: connect: permission denied" }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isRuntimeSocketPermissionError("podman", { stderr: "dial unix /var/run/docker.sock: connect: permission denied" }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseKubeconfigContexts returns names and namespaces without user material", () => {
|
||||
const parsed = parseKubeconfigContexts(JSON.stringify({
|
||||
"current-context": "kind-kind",
|
||||
contexts: [
|
||||
{
|
||||
name: "kind-kind",
|
||||
context: { cluster: "kind-kind", user: "kind-kind", namespace: "app" },
|
||||
},
|
||||
{
|
||||
name: "arn:aws:eks:us-west-2:123:cluster/prod",
|
||||
context: { cluster: "prod", user: "admin" },
|
||||
},
|
||||
],
|
||||
users: [{ name: "admin", user: { token: "secret-token" } }],
|
||||
}));
|
||||
|
||||
assert.equal(parsed.current, "kind-kind");
|
||||
assert.deepEqual(parsed.contexts, [
|
||||
{ name: "kind-kind", namespace: "app", current: true },
|
||||
{ name: "arn:aws:eks:us-west-2:123:cluster/prod", namespace: "default", current: false },
|
||||
]);
|
||||
assert.equal(JSON.stringify(parsed).includes("secret-token"), false);
|
||||
});
|
||||
|
||||
test("parseNamespaceList skips a bracketed banner before the JSON object", () => {
|
||||
const names = parseNamespaceList([
|
||||
"[WARN] connection is using an insecure certificate",
|
||||
'{"items":[{"metadata":{"name":"default"}}]}',
|
||||
].join("\n"));
|
||||
assert.deepEqual(names, ["default"]);
|
||||
});
|
||||
|
||||
test("parseNamespaceList reads metadata names and ignores kubectl warnings before the JSON", () => {
|
||||
const stdout = "Warning: old client\n" + JSON.stringify({
|
||||
items: [
|
||||
{ metadata: { name: "default" } },
|
||||
{ metadata: { name: "kube-system" } },
|
||||
{ metadata: {} },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(parseNamespaceList(stdout), ["default", "kube-system"]);
|
||||
});
|
||||
|
||||
test("parseKubectlPodList reports status, restarts, age, and containers", () => {
|
||||
const now = Date.parse("2026-10-20T00:10:00Z");
|
||||
const pods = parseKubectlPodList(JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
metadata: {
|
||||
name: "api",
|
||||
namespace: "default",
|
||||
creationTimestamp: "2026-10-20T00:08:30Z",
|
||||
},
|
||||
spec: {
|
||||
containers: [{ name: "app" }, { name: "sidecar" }],
|
||||
initContainers: [{ name: "init" }],
|
||||
},
|
||||
status: {
|
||||
phase: "Running",
|
||||
initContainerStatuses: [
|
||||
{ name: "init", restartCount: 0, state: { terminated: { reason: "Completed" } } },
|
||||
],
|
||||
containerStatuses: [
|
||||
{ name: "app", restartCount: 4, state: { waiting: { reason: "CrashLoopBackOff" } } },
|
||||
{ name: "sidecar", restartCount: 1, state: { running: {} } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
metadata: {
|
||||
name: "batch",
|
||||
namespace: "jobs",
|
||||
creationTimestamp: "2026-10-18T00:10:00Z",
|
||||
},
|
||||
spec: { containers: [{ name: "worker" }] },
|
||||
status: {
|
||||
phase: "Succeeded",
|
||||
containerStatuses: [
|
||||
{ name: "worker", restartCount: 0, state: { terminated: { reason: "Completed" } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}), now);
|
||||
|
||||
assert.deepEqual(pods, [
|
||||
{
|
||||
name: "api",
|
||||
namespace: "default",
|
||||
status: "CrashLoopBackOff",
|
||||
restarts: 5,
|
||||
age: "1m",
|
||||
containers: ["app", "sidecar", "init"],
|
||||
shellContainers: ["sidecar"],
|
||||
},
|
||||
{
|
||||
name: "batch",
|
||||
namespace: "jobs",
|
||||
status: "Completed",
|
||||
restarts: 0,
|
||||
age: "2d",
|
||||
containers: ["worker"],
|
||||
shellContainers: [],
|
||||
},
|
||||
]);
|
||||
const terminating = parseKubectlPodList(JSON.stringify({
|
||||
items: [{
|
||||
metadata: {
|
||||
name: "api",
|
||||
namespace: "default",
|
||||
creationTimestamp: "2026-10-20T00:08:30Z",
|
||||
deletionTimestamp: "2026-10-20T00:10:00Z",
|
||||
},
|
||||
spec: {
|
||||
containers: [{ name: "app" }],
|
||||
ephemeralContainers: [{ name: "debugger" }, { name: "old-debug" }],
|
||||
},
|
||||
status: {
|
||||
phase: "Running",
|
||||
containerStatuses: [
|
||||
{ name: "app", restartCount: 0, state: { running: {} } },
|
||||
],
|
||||
ephemeralContainerStatuses: [
|
||||
{ name: "debugger", restartCount: 0, state: { running: {} } },
|
||||
{ name: "old-debug", restartCount: 0, state: { terminated: { reason: "Completed" } } },
|
||||
],
|
||||
},
|
||||
}],
|
||||
}), now);
|
||||
const evicted = parseKubectlPodList(JSON.stringify({
|
||||
items: [{
|
||||
metadata: { name: "api", namespace: "default", creationTimestamp: "2026-10-20T00:08:30Z" },
|
||||
spec: { containers: [{ name: "app" }] },
|
||||
status: {
|
||||
phase: "Failed",
|
||||
reason: "Evicted",
|
||||
message: "The node was low on resource: memory.",
|
||||
containerStatuses: [
|
||||
{ name: "app", restartCount: 0, state: { terminated: { reason: "Error", exitCode: 137 } } },
|
||||
],
|
||||
},
|
||||
}],
|
||||
}), now);
|
||||
assert.equal(evicted[0].status, "Evicted");
|
||||
|
||||
assert.equal(terminating[0].status, "Terminating");
|
||||
assert.deepEqual(terminating[0].containers, ["app", "debugger", "old-debug"]);
|
||||
assert.deepEqual(terminating[0].shellContainers, ["app", "debugger"]);
|
||||
|
||||
assert.equal(formatPodAge("2026-10-20T00:09:10Z", now), "50s");
|
||||
assert.equal(formatPodAge("2026-10-20T00:07:00Z", now), "3m");
|
||||
assert.equal(formatPodAge("2026-10-19T22:10:00Z", now), "2h");
|
||||
});
|
||||
|
||||
test("kubectl commands quote remote arguments and reject control characters", () => {
|
||||
assert.equal(
|
||||
buildKubectlPodsCommand("kind-kind", "default"),
|
||||
"kubectl --context 'kind-kind' -n 'default' get pods -o json",
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlNamespacesCommand("arn:aws:eks:us-west-2:123:cluster/prod"),
|
||||
"kubectl --context 'arn:aws:eks:us-west-2:123:cluster/prod' get namespaces -o json",
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlDeletePodCommand("ctx", "kube-system", "coredns-a"),
|
||||
"kubectl --context 'ctx' -n 'kube-system' delete pod 'coredns-a' --wait=false",
|
||||
);
|
||||
assert.equal(buildKubectlDeletePodCommand("ctx", "default", "bad\nname"), null);
|
||||
assert.equal(buildKubectlPodsCommand("ok", "bad\nns"), null);
|
||||
assert.equal(
|
||||
buildKubectlNamespacesCommand("o'brien", "powershell"),
|
||||
"kubectl --context 'o''brien' get namespaces -o json",
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlPodsCommand("o'brien", "default", "powershell"),
|
||||
"kubectl --context 'o''brien' -n 'default' get pods -o json",
|
||||
);
|
||||
assert.match(
|
||||
buildKubectlDeletePodCommand("o'brien", "default", "web", "cmd"),
|
||||
/--context "o'brien"/,
|
||||
);
|
||||
|
||||
const quoted = buildKubectlDeletePodCommand("o'brien", "default", "web");
|
||||
assert.match(quoted, /--context 'o'"'"'brien'/);
|
||||
assert.doesNotMatch(quoted, /--context 'o'brien'/);
|
||||
|
||||
const shell = buildKubectlExecShellCommand("kind-kind", "default", "api", "sidecar");
|
||||
assert.match(shell, /^sh -c /);
|
||||
assert.match(shell, /kubectl/);
|
||||
assert.match(shell, /--context/);
|
||||
assert.match(shell, /kind-kind/);
|
||||
assert.match(shell, /-n/);
|
||||
assert.match(shell, /default/);
|
||||
assert.match(shell, /exec -it/);
|
||||
assert.match(shell, /api/);
|
||||
assert.match(shell, /-c/);
|
||||
assert.match(shell, /sidecar/);
|
||||
assert.match(shell, /command -v bash/);
|
||||
assert.equal(shell.includes("\n"), false);
|
||||
|
||||
const logs = buildKubectlLogsCommand("kind-kind", "default", "api", "app");
|
||||
assert.match(logs, /kubectl/);
|
||||
assert.match(logs, /logs -f --tail 200/);
|
||||
assert.match(logs, /kind-kind/);
|
||||
assert.match(logs, /app/);
|
||||
|
||||
const described = buildKubectlDescribeCommand("kind-kind", "default", "api");
|
||||
assert.match(described, /kubectl/);
|
||||
assert.match(described, /describe pod/);
|
||||
assert.match(described, /kind-kind/);
|
||||
assert.match(described, /api/);
|
||||
assert.equal(buildKubectlExecShellCommand("kind-kind", "default", "bad\npod"), 'echo "Invalid pod name"');
|
||||
});
|
||||
|
||||
test("kubectl popup commands skip the POSIX wrapper on Windows shells", () => {
|
||||
for (const shell of ["powershell", "cmd"]) {
|
||||
const exec = buildKubectlExecShellCommand("kind-kind", "default", "api", "sidecar", shell);
|
||||
const logs = buildKubectlLogsCommand("kind-kind", "default", "api", "app", shell);
|
||||
const described = buildKubectlDescribeCommand("kind-kind", "default", "api", shell);
|
||||
assert.match(exec, /kubectl --context/, shell);
|
||||
assert.match(logs, /kubectl --context/, shell);
|
||||
assert.match(described, /kubectl --context/, shell);
|
||||
assert.match(exec, /__NC_POPUP__/, shell);
|
||||
assert.equal(exec.startsWith("sh -c "), false, shell);
|
||||
assert.match(exec, /exec -it/);
|
||||
assert.match(exec, /sidecar/);
|
||||
assert.match(logs, /logs -f --tail 200/);
|
||||
assert.match(described, /describe pod/);
|
||||
}
|
||||
assert.match(
|
||||
buildKubectlExecShellCommand("kind-kind", "default", "api", "sidecar", "powershell"),
|
||||
/--context 'kind-kind'/,
|
||||
);
|
||||
assert.match(
|
||||
buildKubectlDescribeCommand("kind-kind", "default", "api", "cmd"),
|
||||
/--context "kind-kind"/,
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlDescribeCommand("prod-%USERNAME%", "default", "api", "cmd"),
|
||||
'echo "Invalid context"',
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlDescribeCommand("prod!USER!", "default", "api", "cmd"),
|
||||
'echo "Invalid context"',
|
||||
);
|
||||
assert.match(
|
||||
buildKubectlDescribeCommand("prod-%USERNAME%", "default", "api", "powershell"),
|
||||
/--context 'prod-%USERNAME%'/,
|
||||
);
|
||||
assert.equal(
|
||||
buildKubectlNamespacesCommand("prod-%USERNAME%", "powershell"),
|
||||
"kubectl --context 'prod-%USERNAME%' get namespaces -o json",
|
||||
);
|
||||
});
|
||||
|
||||
test("container popup commands inspect in the host shell on Windows", () => {
|
||||
const powershellLogs = buildContainerLogsCommand("podman", "abc123", "powershell");
|
||||
assert.equal(powershellLogs.startsWith("sh -c "), false);
|
||||
assert.match(powershellLogs, /\$__nc_popup='__NC_POPUP__'; podman inspect abc123 >\$null;/);
|
||||
assert.match(powershellLogs, /podman logs -f --tail 200 abc123/);
|
||||
|
||||
const cmdShell = buildContainerExecShellCommand("nerdctl", "abc123", "cmd");
|
||||
assert.equal(cmdShell.startsWith("sh -c "), false);
|
||||
assert.match(cmdShell, /^set "__NC_POPUP__=1"& nerdctl inspect abc123 >nul && nerdctl exec -it abc123/);
|
||||
assert.match(cmdShell, /sh -c "command -v bash/);
|
||||
});
|
||||
|
||||
test("isKubectlForbiddenError matches namespace list denial", () => {
|
||||
assert.equal(
|
||||
isKubectlForbiddenError('Error from server (Forbidden): namespaces is forbidden: User "view" cannot list resource "namespaces"'),
|
||||
true,
|
||||
);
|
||||
assert.equal(isKubectlForbiddenError("Unable to connect to the server"), false);
|
||||
});
|
||||
|
||||
test("unreachable kubectl API text is classified without treating Forbidden as unreachable", () => {
|
||||
const spam = [
|
||||
'memcache.go:381] "Couldn\'t get current server API group list"',
|
||||
'dial tcp 127.0.0.1:6443: connect: connection refused',
|
||||
].join("\n");
|
||||
assert.equal(isKubectlUnreachableError(spam), true);
|
||||
assert.equal(isKubectlUnreachableError("Unable to connect to the server: dial tcp 10.0.0.1:6443: i/o timeout"), true);
|
||||
assert.equal(
|
||||
isKubectlUnreachableError('Error from server (Forbidden): pods is forbidden'),
|
||||
false,
|
||||
);
|
||||
assert.equal(isKubectlUnreachableError(""), false);
|
||||
});
|
||||
|
||||
test("nerdctl containerd failures are classified separately from socket permission errors", () => {
|
||||
assert.equal(
|
||||
isNerdctlUnreachableError('time="2026-10-20T18:31:27+08:00" level=fatal msg="rootless containerd not running? (hint: use containerd-rootless-setuptool.sh install to start rootless containerd): environment variable XDG_RUNTIME_DIR is not set"'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isNerdctlUnreachableError("permission denied while trying to connect to the containerd socket"),
|
||||
false,
|
||||
);
|
||||
assert.equal(isNerdctlUnreachableError("Error: no such container: abc"), false);
|
||||
});
|
||||
|
||||
test("runtime failures map to localized message keys and pod badges use known status keys", () => {
|
||||
assert.deepEqual(runtimeFailureMessage("nerdctlUnreachable"), {
|
||||
key: "systemManager.errors.nerdctlUnreachable",
|
||||
});
|
||||
assert.deepEqual(runtimeFailureMessage("clusterUnreachable", "broken-cluster"), {
|
||||
key: "systemManager.errors.clusterUnreachable",
|
||||
values: { context: "broken-cluster" },
|
||||
});
|
||||
assert.deepEqual(runtimeFailureMessage("clusterUnreachable", " "), {
|
||||
key: "systemManager.errors.clusterUnreachableGeneric",
|
||||
});
|
||||
assert.equal(runtimeFailureMessage("other"), null);
|
||||
assert.equal(podStatusMessageKey("Running"), "systemManager.k8s.status.Running");
|
||||
assert.equal(podStatusMessageKey("CrashLoopBackOff"), "systemManager.k8s.status.CrashLoopBackOff");
|
||||
assert.equal(podStatusMessageKey("Evicted"), "systemManager.k8s.status.Evicted");
|
||||
assert.equal(podStatusMessageKey("Init:0/1"), null);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { runtimeFailureMessage } from "@/domain/systemManager/containerRuntime.shared.cjs";
|
||||
|
||||
export type {
|
||||
ContainerRuntimeId,
|
||||
ContainerSourceId,
|
||||
KubeContextInfo,
|
||||
KubectlPodInfo,
|
||||
RuntimeProbeFlags,
|
||||
} from "@/domain/systemManager/containerRuntime.shared.cjs";
|
||||
|
||||
export {
|
||||
NETCATTY_POPUP_HISTORY_MARKER,
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
buildKubectlDescribeCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
firstContainerRuntime,
|
||||
isContainerSource,
|
||||
isKubectlForbiddenError,
|
||||
isAutomaticRuntimeFallback,
|
||||
pickContainerSource,
|
||||
podStatusMessageKey,
|
||||
reconcileContainerSource,
|
||||
rememberedContainerSource,
|
||||
sourcesFromCapabilities,
|
||||
} from "@/domain/systemManager/containerRuntime.shared.cjs";
|
||||
|
||||
export function describeRuntimeFailure(
|
||||
result: { error?: string; errorCode?: string } | null | undefined,
|
||||
translate: (key: string, values?: Record<string, string>) => string,
|
||||
fallback: string,
|
||||
context?: string | null,
|
||||
): string {
|
||||
const mapped = runtimeFailureMessage(result?.errorCode, context);
|
||||
if (mapped) return translate(mapped.key, mapped.values);
|
||||
return result?.error || fallback;
|
||||
}
|
||||
@@ -1,52 +1,18 @@
|
||||
import {
|
||||
buildContainerExecShellCommand,
|
||||
buildContainerLogsCommand,
|
||||
} from "./containerRuntime.ts";
|
||||
|
||||
/** Sanitize Docker container/image IDs — must match electron/bridges/systemManager/dockerOps.cjs */
|
||||
export function sanitizeDockerContainerId(id: string): string {
|
||||
return String(id || '').replace(/[^a-zA-Z0-9]/g, '').slice(0, 64);
|
||||
}
|
||||
|
||||
const CLEAR_STARTUP_OUTPUT = "printf '\\033[H\\033[2J\\033[3J';";
|
||||
|
||||
function shQuote(value: string): string {
|
||||
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function buildDockerCommandWithSudoFallback(containerId: string, dockerArgs: string): string {
|
||||
const plainCommand = `docker ${dockerArgs}`;
|
||||
const sudoCommand = `sudo ${plainCommand}`;
|
||||
const script = [
|
||||
CLEAR_STARTUP_OUTPUT,
|
||||
`_nc_docker_err=$(docker inspect ${containerId} 2>&1 >/dev/null);`,
|
||||
'_nc_docker_status=$?;',
|
||||
`if [ "$_nc_docker_status" -eq 0 ]; then exec ${plainCommand}; fi;`,
|
||||
'_nc_docker_lc=$(printf \'%s\' "$_nc_docker_err" | tr \'[:upper:]\' \'[:lower:]\');',
|
||||
'case "$_nc_docker_lc" in',
|
||||
[
|
||||
'*permission\\ denied*docker\\ daemon*',
|
||||
'*docker\\ daemon*permission\\ denied*',
|
||||
'*permission\\ denied*docker.sock*',
|
||||
'*docker.sock*permission\\ denied*',
|
||||
'*permission\\ denied*/var/run/docker.sock*',
|
||||
'*/var/run/docker.sock*permission\\ denied*',
|
||||
'*permission\\ denied*connect\\ to\\ the\\ docker\\ daemon*',
|
||||
'*connect\\ to\\ the\\ docker\\ daemon*permission\\ denied*',
|
||||
].join('|') + `) exec ${sudoCommand} ;;`,
|
||||
'*) printf \'%s\\n\' "$_nc_docker_err" >&2; exit "$_nc_docker_status" ;;',
|
||||
'esac',
|
||||
].join(' ');
|
||||
return `sh -c ${shQuote(script)}`;
|
||||
return String(id || "").replace(/[^a-zA-Z0-9]/g, "").slice(0, 64);
|
||||
}
|
||||
|
||||
/** Interactive shell into a container — prefer bash, fall back to sh. */
|
||||
export function buildDockerExecShellCommand(containerId: string): string {
|
||||
const safeId = sanitizeDockerContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return buildDockerCommandWithSudoFallback(
|
||||
safeId,
|
||||
`exec -it ${safeId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`,
|
||||
);
|
||||
return buildContainerExecShellCommand("docker", containerId);
|
||||
}
|
||||
|
||||
export function buildDockerLogsCommand(containerId: string): string {
|
||||
const safeId = sanitizeDockerContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return buildDockerCommandWithSudoFallback(safeId, `logs -f --tail 200 ${safeId}`);
|
||||
return buildContainerLogsCommand("docker", containerId);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
import {
|
||||
allowSystemManagerMutations,
|
||||
buildSystemManagerTabs,
|
||||
hasContainerSource,
|
||||
shouldCollectServerStats,
|
||||
shouldShowGpuTab,
|
||||
shouldShowPortsTab,
|
||||
@@ -57,6 +58,23 @@ test("gpu tab appears only after nvidia-smi or npu-smi is detected", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("the single container tab appears for podman, nerdctl, or kubectl without a docker binary", () => {
|
||||
const networkHost = {
|
||||
id: "host-podman",
|
||||
label: "Podman",
|
||||
hostname: "podman.local",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux" as const,
|
||||
deviceType: "network" as const,
|
||||
};
|
||||
assert.equal(hasContainerSource(caps()), false);
|
||||
assert.equal(hasContainerSource(caps({ hasPodman: true })), true);
|
||||
assert.equal(buildSystemManagerTabs(networkHost, caps({ hasPodman: true }), null).includes("docker"), true);
|
||||
assert.equal(buildSystemManagerTabs(networkHost, caps({ hasKubectl: true }), null).includes("docker"), true);
|
||||
assert.equal(buildSystemManagerTabs(networkHost, caps(), null).includes("docker"), false);
|
||||
});
|
||||
|
||||
test("ports and services tabs are detect-first like GPU", () => {
|
||||
assert.equal(shouldShowPortsTab(undefined), false);
|
||||
assert.equal(shouldShowPortsTab(caps({ hasSs: true })), true);
|
||||
|
||||
@@ -44,12 +44,19 @@ export function shouldShowTmuxTab(
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasContainerSource(capabilities: SessionCapabilities | undefined): boolean {
|
||||
return capabilities?.hasDocker === true
|
||||
|| capabilities?.hasPodman === true
|
||||
|| capabilities?.hasNerdctl === true
|
||||
|| capabilities?.hasKubectl === true;
|
||||
}
|
||||
|
||||
export function shouldShowDockerTab(
|
||||
host: Host | null | undefined,
|
||||
capabilities: SessionCapabilities | undefined,
|
||||
session: TerminalSession | null | undefined,
|
||||
): boolean {
|
||||
if (capabilities?.hasDocker === true) return true;
|
||||
if (hasContainerSource(capabilities)) return true;
|
||||
// Network appliances: never show Docker from an OS guess alone.
|
||||
if (isNetworkDeviceTarget(host)) return false;
|
||||
return isDefiniteLinuxTarget(host, capabilities, session);
|
||||
|
||||
@@ -4,6 +4,12 @@ export interface SessionCapabilities {
|
||||
targetOs: TargetOs;
|
||||
hasTmux: boolean;
|
||||
hasDocker: boolean;
|
||||
/** `podman` binary present. A podman-docker shim may also set hasDocker. */
|
||||
hasPodman?: boolean;
|
||||
/** `nerdctl` binary present. */
|
||||
hasNerdctl?: boolean;
|
||||
/** `kubectl` binary present on the remote host. */
|
||||
hasKubectl?: boolean;
|
||||
hasNvidiaSmi: boolean;
|
||||
hasNpuSmi: boolean;
|
||||
/** `ss` binary present (preferred listening-port collector). */
|
||||
@@ -194,7 +200,7 @@ export type DockerContainerAction =
|
||||
|
||||
export type DockerImageManageAction =
|
||||
| { action: 'pull'; imageRef: string }
|
||||
| { action: 'rm'; imageId: string; force?: boolean }
|
||||
| { action: 'rm'; imageId: string; force?: boolean; repository?: string; tag?: string }
|
||||
| { action: 'prune'; all?: boolean }
|
||||
| { action: 'tag'; imageId: string; repository: string; tag?: string };
|
||||
|
||||
@@ -221,6 +227,11 @@ export interface TerminalPopupPayload {
|
||||
parentSessionId: string;
|
||||
sourceSession: import('../../types').TerminalSession;
|
||||
startupCommand: string;
|
||||
/**
|
||||
* System-manager panel rectangle in renderer viewport pixels. The popup is
|
||||
* placed in the remaining source-window area so it does not cover this panel.
|
||||
*/
|
||||
avoidClientRect?: { x: number; y: number; width: number; height: number };
|
||||
localShellType?: import('../../types').TerminalSession['shellType'];
|
||||
/**
|
||||
* When set, the popup attaches to this already-running backend session
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
VAULT_HOST_DRAG_ID,
|
||||
VAULT_HOST_DRAG_IDS,
|
||||
applyVaultHostDragData,
|
||||
dispatchVaultHostGroupMove,
|
||||
isVaultHostDraggable,
|
||||
readVaultDraggedHostIds,
|
||||
resolveVaultHostDragIds,
|
||||
} from './vaultHostDrag.ts';
|
||||
|
||||
test('outside multi-select a host drag carries only that host', () => {
|
||||
const ids = resolveVaultHostDragIds({
|
||||
hostId: 'a',
|
||||
isMultiSelectMode: false,
|
||||
selectedHostIds: new Set(['a', 'b']),
|
||||
});
|
||||
assert.deepEqual(ids, ['a']);
|
||||
assert.equal(isVaultHostDraggable(false, new Set(), 'a'), true);
|
||||
|
||||
const written: Record<string, string> = {};
|
||||
applyVaultHostDragData({
|
||||
effectAllowed: '',
|
||||
setData: (type, value) => {
|
||||
written[type] = value;
|
||||
},
|
||||
}, ids!);
|
||||
assert.deepEqual(written, { [VAULT_HOST_DRAG_ID]: 'a' });
|
||||
});
|
||||
|
||||
test('a multi-select drag starts only on an already selected host and carries every selected id', () => {
|
||||
assert.equal(resolveVaultHostDragIds({
|
||||
hostId: 'c',
|
||||
isMultiSelectMode: true,
|
||||
selectedHostIds: new Set(['a', 'b']),
|
||||
}), null);
|
||||
assert.equal(isVaultHostDraggable(true, new Set(['a', 'b']), 'c'), false);
|
||||
|
||||
const ids = resolveVaultHostDragIds({
|
||||
hostId: 'b',
|
||||
isMultiSelectMode: true,
|
||||
selectedHostIds: new Set(['a', 'b', 'c']),
|
||||
});
|
||||
assert.deepEqual(ids, ['b', 'a', 'c']);
|
||||
|
||||
const written: Record<string, string> = {};
|
||||
applyVaultHostDragData({
|
||||
effectAllowed: 'none',
|
||||
setData: (type, value) => {
|
||||
written[type] = value;
|
||||
},
|
||||
}, ids!);
|
||||
assert.equal(written[VAULT_HOST_DRAG_ID], 'b');
|
||||
assert.deepEqual(JSON.parse(written[VAULT_HOST_DRAG_IDS]), ['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
test('reading a drag payload prefers the multi-id list and falls back to one host id', () => {
|
||||
assert.deepEqual(readVaultDraggedHostIds((type) => (
|
||||
type === VAULT_HOST_DRAG_IDS ? JSON.stringify(['a', 'b', 'a']) : 'a'
|
||||
)), ['a', 'b', 'a']);
|
||||
assert.deepEqual(readVaultDraggedHostIds((type) => (
|
||||
type === VAULT_HOST_DRAG_ID ? 'solo' : ''
|
||||
)), ['solo']);
|
||||
assert.deepEqual(readVaultDraggedHostIds((type) => (
|
||||
type === VAULT_HOST_DRAG_IDS ? '{nope' : 'solo'
|
||||
)), ['solo']);
|
||||
assert.deepEqual(readVaultDraggedHostIds(() => ''), []);
|
||||
});
|
||||
|
||||
test('multi-id drag payload plans one batched group move', () => {
|
||||
const singleCalls: string[] = [];
|
||||
const batchCalls: Array<{ ids: string[]; group: string | null }> = [];
|
||||
const moved = dispatchVaultHostGroupMove({
|
||||
hostIds: readVaultDraggedHostIds((type) => (
|
||||
type === VAULT_HOST_DRAG_IDS ? JSON.stringify(['a', 'b', 'c']) : 'a'
|
||||
)),
|
||||
groupPath: 'Ops/Nested',
|
||||
moveHostToGroup: (hostId, groupPath) => {
|
||||
singleCalls.push(`${hostId}:${String(groupPath)}`);
|
||||
},
|
||||
moveHostsToGroup: (hostIds, groupPath) => {
|
||||
batchCalls.push({ ids: [...hostIds], group: groupPath });
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(moved, true);
|
||||
assert.deepEqual(singleCalls, []);
|
||||
assert.deepEqual(batchCalls, [{ ids: ['a', 'b', 'c'], group: 'Ops/Nested' }]);
|
||||
});
|
||||
|
||||
test('a single host id still moves through the single-host path when no batch handler is present', () => {
|
||||
const calls: string[] = [];
|
||||
const moved = dispatchVaultHostGroupMove({
|
||||
hostIds: ['host-1'],
|
||||
groupPath: null,
|
||||
moveHostToGroup: (hostId, groupPath) => {
|
||||
calls.push(`${hostId}:${String(groupPath)}`);
|
||||
},
|
||||
});
|
||||
assert.equal(moved, true);
|
||||
assert.deepEqual(calls, ['host-1:null']);
|
||||
assert.equal(dispatchVaultHostGroupMove({
|
||||
hostIds: [],
|
||||
groupPath: 'Ops',
|
||||
moveHostsToGroup: () => {
|
||||
throw new Error('empty selection must not move');
|
||||
},
|
||||
}), false);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
export const VAULT_HOST_DRAG_ID = 'host-id';
|
||||
export const VAULT_HOST_DRAG_IDS = 'host-ids';
|
||||
|
||||
export interface VaultHostDragWriter {
|
||||
effectAllowed: string;
|
||||
setData: (type: string, value: string) => void;
|
||||
}
|
||||
|
||||
export function resolveVaultHostDragIds(input: {
|
||||
hostId: string;
|
||||
isMultiSelectMode: boolean;
|
||||
selectedHostIds: ReadonlySet<string>;
|
||||
}): string[] | null {
|
||||
if (!input.isMultiSelectMode) return [input.hostId];
|
||||
if (!input.selectedHostIds.has(input.hostId)) return null;
|
||||
return [
|
||||
input.hostId,
|
||||
...input.selectedHostIds,
|
||||
].filter((id, index, ids) => id.length > 0 && ids.indexOf(id) === index);
|
||||
}
|
||||
|
||||
export function isVaultHostDraggable(
|
||||
isMultiSelectMode: boolean,
|
||||
selectedHostIds: ReadonlySet<string> | undefined,
|
||||
hostId: string,
|
||||
): boolean {
|
||||
return resolveVaultHostDragIds({
|
||||
hostId,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds: selectedHostIds ?? new Set(),
|
||||
}) !== null;
|
||||
}
|
||||
|
||||
export function encodeVaultHostDragIds(ids: readonly string[]): string {
|
||||
return JSON.stringify(ids);
|
||||
}
|
||||
|
||||
export function decodeVaultHostDragIds(raw: string | null | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function applyVaultHostDragData(dataTransfer: VaultHostDragWriter, ids: readonly string[]): void {
|
||||
if (ids.length === 0) return;
|
||||
dataTransfer.effectAllowed = 'copyMove';
|
||||
dataTransfer.setData(VAULT_HOST_DRAG_ID, ids[0]);
|
||||
if (ids.length > 1) dataTransfer.setData(VAULT_HOST_DRAG_IDS, encodeVaultHostDragIds(ids));
|
||||
}
|
||||
|
||||
export function readVaultDraggedHostIds(getData: (type: string) => string): string[] {
|
||||
const many = decodeVaultHostDragIds(getData(VAULT_HOST_DRAG_IDS));
|
||||
if (many.length > 0) return many;
|
||||
const single = getData(VAULT_HOST_DRAG_ID)?.trim() ?? '';
|
||||
return single ? [single] : [];
|
||||
}
|
||||
|
||||
export function dispatchVaultHostGroupMove(input: {
|
||||
hostIds: readonly string[];
|
||||
groupPath: string | null;
|
||||
moveHostToGroup?: (hostId: string, groupPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
|
||||
}): boolean {
|
||||
const ids: string[] = [];
|
||||
for (const id of input.hostIds) {
|
||||
if (id && !ids.includes(id)) ids.push(id);
|
||||
}
|
||||
if (ids.length === 0) return false;
|
||||
if (input.moveHostsToGroup) {
|
||||
input.moveHostsToGroup(new Set(ids), input.groupPath);
|
||||
return true;
|
||||
}
|
||||
if (ids.length === 1 && input.moveHostToGroup) {
|
||||
input.moveHostToGroup(ids[0], input.groupPath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -14,8 +14,9 @@ export {
|
||||
buildVaultHostMergeKey,
|
||||
} from "./vaultHostCreate";
|
||||
import { parseQuickConnectInput } from "./quickConnect";
|
||||
import { canServeAsSshJumpHost } from "./terminalProtocol";
|
||||
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
|
||||
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
|
||||
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase, decodeCsvProxyJump } from "./vaultImport/csvCredentialFields";
|
||||
import { isCsvDirectProxyValue, parseCsvProxy } from "./vaultImport/csvProxy";
|
||||
import { attachMobaXtermPasswords } from "./vaultImport/mobaXtermPasswords";
|
||||
import { decodeFinalShellPassword } from "./finalShellPassword";
|
||||
@@ -100,6 +101,29 @@ const parseProxyJump = (value: string): ParsedJumpHost[] => {
|
||||
.filter((h): h is ParsedJumpHost => h !== null);
|
||||
};
|
||||
|
||||
/** Vault host fields needed to bind a CSV ProxyJump hop without copying secrets. */
|
||||
export interface VaultImportExistingHostRef {
|
||||
id: string;
|
||||
label: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
protocol?: Host["protocol"];
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export const toVaultImportExistingHostRef = (
|
||||
host: Pick<Host, "id" | "label" | "hostname" | "port" | "username" | "protocol" | "group">,
|
||||
): VaultImportExistingHostRef => ({
|
||||
id: host.id,
|
||||
label: host.label,
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
protocol: host.protocol,
|
||||
group: host.group,
|
||||
});
|
||||
|
||||
export type VaultImportFormat =
|
||||
| "putty"
|
||||
| "mobaxterm"
|
||||
@@ -254,9 +278,32 @@ export function applyVaultImportDestination(
|
||||
return remapped;
|
||||
};
|
||||
|
||||
// Collapsing two group copies must not leave a chain pointed at the dropped id.
|
||||
// Ids that are not part of this import (an existing vault hop) stay as they are.
|
||||
const remappedHosts = hosts.map((host) => {
|
||||
const currentIds = host.hostChain?.hostIds;
|
||||
if (!currentIds?.length) return host;
|
||||
const hostIds: string[] = [];
|
||||
for (const hostId of currentIds) {
|
||||
const mapped = remapHostId(hostId);
|
||||
const nextId = mapped ?? (originalById.has(hostId) ? undefined : hostId);
|
||||
if (!nextId || nextId === host.id || hostIds.includes(nextId)) continue;
|
||||
hostIds.push(nextId);
|
||||
}
|
||||
if (hostIds.length === currentIds.length && hostIds.every((id, index) => id === currentIds[index])) {
|
||||
return host;
|
||||
}
|
||||
if (hostIds.length === 0) {
|
||||
const next = { ...host };
|
||||
delete next.hostChain;
|
||||
return next;
|
||||
}
|
||||
return { ...host, hostChain: { hostIds } };
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
hosts,
|
||||
hosts: remappedHosts,
|
||||
groups: [group],
|
||||
keyPassphrases: remapKeyPassphrases(result.keyPassphrases, { matchSelectedKey: true }),
|
||||
// Keep remapped candidates for alias conflict checks (same as CSV same-group
|
||||
@@ -421,8 +468,176 @@ const parseTarget = (
|
||||
return null;
|
||||
};
|
||||
|
||||
const CSV_JUMP_PORT = 22;
|
||||
|
||||
const importFromCsv = (text: string): VaultImportResult => {
|
||||
const csvJumpPort = (port: number | undefined): number => (
|
||||
port !== undefined && Number.isInteger(port) && port >= 1 && port <= 65535 ? port : CSV_JUMP_PORT
|
||||
);
|
||||
|
||||
type CsvJumpCandidate = {
|
||||
id: string;
|
||||
label: string;
|
||||
hostname: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
protocol?: Host["protocol"];
|
||||
group?: string;
|
||||
};
|
||||
|
||||
const csvJumpEndpointMatches = (hop: ParsedJumpHost, candidate: CsvJumpCandidate): boolean => {
|
||||
if (!canServeAsSshJumpHost(candidate)) return false;
|
||||
if (candidate.hostname.trim().toLowerCase() !== hop.hostname.toLowerCase()) return false;
|
||||
if (csvJumpPort(candidate.port) !== csvJumpPort(hop.port)) return false;
|
||||
if (hop.username && (candidate.username ?? "").trim().toLowerCase() !== hop.username.toLowerCase()) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const csvJumpLabelMatches = (hop: ParsedJumpHost, candidate: CsvJumpCandidate): boolean => {
|
||||
if (!canServeAsSshJumpHost(candidate)) return false;
|
||||
const label = candidate.label.trim().toLowerCase();
|
||||
if (!label || label !== hop.hostname.toLowerCase()) return false;
|
||||
if (csvJumpPort(candidate.port) !== csvJumpPort(hop.port)) return false;
|
||||
if (hop.username && (candidate.username ?? "").trim().toLowerCase() !== hop.username.toLowerCase()) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const pickCsvJumpCandidate = <T extends { group?: string }>(matches: readonly T[], group: string | undefined): T => (
|
||||
matches.find((candidate) => (candidate.group ?? "") === (group ?? "")) ?? matches[0]
|
||||
);
|
||||
|
||||
const attachCsvProxyJumpChains = (
|
||||
hosts: Host[],
|
||||
proxyJumpByHost: ReadonlyMap<Host, { value: string; row: number }>,
|
||||
issues: VaultImportIssue[],
|
||||
existingHosts: readonly VaultImportExistingHostRef[],
|
||||
): Host[] => {
|
||||
const inlineHosts: Host[] = [];
|
||||
const matchable: CsvJumpCandidate[] = [...hosts];
|
||||
|
||||
const resolveHop = (hop: ParsedJumpHost, group: string | undefined): string | null => {
|
||||
const endpointMatches = [
|
||||
...matchable.filter((candidate) => csvJumpEndpointMatches(hop, candidate)),
|
||||
...existingHosts.filter((candidate) => csvJumpEndpointMatches(hop, candidate)),
|
||||
];
|
||||
if (endpointMatches.length > 0) return pickCsvJumpCandidate(endpointMatches, group).id;
|
||||
const labelMatches = [
|
||||
...matchable.filter((candidate) => csvJumpLabelMatches(hop, candidate)),
|
||||
...existingHosts.filter((candidate) => csvJumpLabelMatches(hop, candidate)),
|
||||
];
|
||||
if (labelMatches.length > 0) return pickCsvJumpCandidate(labelMatches, group).id;
|
||||
|
||||
const inlineKey = `${hop.hostname.toLowerCase()}|${csvJumpPort(hop.port)}|${(hop.username ?? "").toLowerCase()}`;
|
||||
const existingInline = inlineHosts.find((host) => (
|
||||
`${host.hostname.toLowerCase()}|${csvJumpPort(host.port)}|${(host.username ?? "").toLowerCase()}` === inlineKey
|
||||
));
|
||||
if (existingInline) return existingInline.id;
|
||||
if (!looksLikeHostnameToken(hop.hostname)) return null;
|
||||
try {
|
||||
const inlineHost = createHost({
|
||||
label: hop.hostname,
|
||||
hostname: hop.hostname,
|
||||
username: hop.username,
|
||||
port: hop.port,
|
||||
protocol: "ssh",
|
||||
});
|
||||
inlineHosts.push(inlineHost);
|
||||
matchable.push(inlineHost);
|
||||
return inlineHost.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
for (const host of hosts) {
|
||||
const entry = proxyJumpByHost.get(host);
|
||||
if (!entry) continue;
|
||||
const specs = entry.value
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part && part.toLowerCase() !== "none");
|
||||
if (specs.length === 0) continue;
|
||||
|
||||
const resolvedIds: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
const inlineCountBefore = inlineHosts.length;
|
||||
let chainRejected = false;
|
||||
for (const spec of specs) {
|
||||
const jump = parseJumpHostSpec(spec);
|
||||
if (!jump) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV row ${entry.row}: ProxyJump hop "${spec}" was ignored because it is not a valid jump host.`,
|
||||
});
|
||||
chainRejected = true;
|
||||
continue;
|
||||
}
|
||||
const inlineCount = inlineHosts.length;
|
||||
const resolvedId = resolveHop(jump, host.group);
|
||||
if (!resolvedId) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV row ${entry.row}: ProxyJump hop "${spec}" was ignored because it is not a valid jump host.`,
|
||||
});
|
||||
chainRejected = true;
|
||||
continue;
|
||||
}
|
||||
if (inlineHosts.length > inlineCount) unresolved.push(jump.hostname);
|
||||
if (!resolvedIds.includes(resolvedId)) resolvedIds.push(resolvedId);
|
||||
}
|
||||
|
||||
if (chainRejected || resolvedIds.includes(host.id)) {
|
||||
if (inlineHosts.length > inlineCountBefore) {
|
||||
const droppedIds = new Set(inlineHosts.splice(inlineCountBefore).map((item) => item.id));
|
||||
for (let index = matchable.length - 1; index >= 0; index -= 1) {
|
||||
if (droppedIds.has(matchable[index].id)) matchable.splice(index, 1);
|
||||
}
|
||||
}
|
||||
if (resolvedIds.includes(host.id)) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV: detected circular reference in ProxyJump for "${host.label}", skipping chain.`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (resolvedIds.length > 0) host.hostChain = { hostIds: resolvedIds };
|
||||
if (unresolved.length > 0) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV: created inline jump host(s) for "${host.label}": ${unresolved.join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const allHosts = [...hosts, ...inlineHosts];
|
||||
const participatesInCycle = (hostId: string, visited: Set<string>): boolean => {
|
||||
if (visited.has(hostId)) return true;
|
||||
visited.add(hostId);
|
||||
const current = allHosts.find((item) => item.id === hostId);
|
||||
for (const chainId of current?.hostChain?.hostIds ?? []) {
|
||||
if (!allHosts.some((item) => item.id === chainId)) continue;
|
||||
if (participatesInCycle(chainId, visited)) return true;
|
||||
}
|
||||
visited.delete(hostId);
|
||||
return false;
|
||||
};
|
||||
const cyclicHosts = allHosts.filter((host) => (
|
||||
Boolean(host.hostChain?.hostIds?.length) && participatesInCycle(host.id, new Set())
|
||||
));
|
||||
for (const host of cyclicHosts) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV: detected circular dependency in jump chain for "${host.label}", removing chain.`,
|
||||
});
|
||||
delete host.hostChain;
|
||||
}
|
||||
return allHosts;
|
||||
};
|
||||
|
||||
const importFromCsv = (
|
||||
text: string,
|
||||
existingHosts: readonly VaultImportExistingHostRef[] = [],
|
||||
): VaultImportResult => {
|
||||
const issues: VaultImportIssue[] = [];
|
||||
const rows = parseCsv(text).filter((r) => r.some((c) => c.trim() !== ""));
|
||||
if (rows.length === 0) {
|
||||
@@ -448,6 +663,7 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
const keyPathIdx = findExactHeaderIndex(header, ["keypath", "key path", "identityfile", "identity file"]);
|
||||
const explicitPassphraseIdx = findExactHeaderIndex(header, ["passphrase", "keypassphrase", "key passphrase"]);
|
||||
const proxyIdx = findExactHeaderIndex(header, ["proxy", "proxyserver", "proxy server"]);
|
||||
const proxyJumpIdx = findExactHeaderIndex(header, ["proxyjump"]);
|
||||
const localShellIdx = findExactHeaderIndex(header, ["localshell"]);
|
||||
const passphraseIdx = keyPathIdx >= 0 ? explicitPassphraseIdx : -1;
|
||||
const exactPasswordIdx = findExactHeaderIndex(header, ["password", "pass", "passwd"]);
|
||||
@@ -482,6 +698,7 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
}
|
||||
|
||||
const parsedHosts: Host[] = [];
|
||||
const proxyJumpByHost = new Map<Host, { value: string; row: number }>();
|
||||
const keyPassphraseCandidates: Array<{
|
||||
hostKey: string;
|
||||
keyPathKey: string;
|
||||
@@ -549,6 +766,8 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
message: `CSV row ${i + 2}: LocalShell was ignored because it is not a valid encoded local-shell value.`,
|
||||
});
|
||||
}
|
||||
const proxyJumpRaw = (proxyJumpIdx >= 0 ? row[proxyJumpIdx] : undefined)?.trim();
|
||||
const proxyJump = proxyJumpRaw ? decodeCsvProxyJump(proxyJumpRaw).trim() : "";
|
||||
|
||||
if (decodedPassphrase && isEncryptedCredentialPlaceholder(decodedPassphrase)) {
|
||||
issues.push({
|
||||
@@ -586,6 +805,9 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
if (localShellSpec.startDir) host.localStartDir = localShellSpec.startDir;
|
||||
if (localShellSpec.os) host.os = localShellSpec.os;
|
||||
}
|
||||
if (proxyJump && proxyJump.toLowerCase() !== "none") {
|
||||
proxyJumpByHost.set(host, { value: proxyJump, row: i + 2 });
|
||||
}
|
||||
parsedHosts.push(host);
|
||||
if (keyPath && passphrase) {
|
||||
const keyPathKey = normalizeKeyPathKey(keyPath);
|
||||
@@ -612,7 +834,8 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
// the shell identity is part of buildVaultHostMergeKey; otherwise importing a
|
||||
// backup with CMD and PowerShell in the same group silently keeps only the
|
||||
// first shell and flags the rest as duplicates.
|
||||
const { hosts, duplicates } = dedupeHosts(parsedHosts);
|
||||
const { hosts: dedupedHosts, duplicates } = dedupeHosts(parsedHosts);
|
||||
const hosts = attachCsvProxyJumpChains(dedupedHosts, proxyJumpByHost, issues, existingHosts);
|
||||
const keyPassphrases = hosts.flatMap((host) => {
|
||||
const selectedKeyPath = host.identityFilePaths?.find((path) => path.trim())?.trim();
|
||||
if (!selectedKeyPath) return [];
|
||||
@@ -1409,15 +1632,22 @@ const importFromMobaXterm = (
|
||||
};
|
||||
};
|
||||
|
||||
export interface VaultImportTextOptions {
|
||||
fileName?: string;
|
||||
masterPassword?: string;
|
||||
/** Hosts already in the vault. CSV ProxyJump hops bind to these by endpoint. */
|
||||
existingHosts?: readonly VaultImportExistingHostRef[];
|
||||
}
|
||||
|
||||
export const importVaultHostsFromText = (
|
||||
format: VaultImportFormat,
|
||||
text: string,
|
||||
options?: { fileName?: string; masterPassword?: string },
|
||||
options?: VaultImportTextOptions,
|
||||
): VaultImportResult => {
|
||||
const input = text ?? "";
|
||||
switch (format) {
|
||||
case "csv":
|
||||
return importFromCsv(input);
|
||||
return importFromCsv(input, options?.existingHosts);
|
||||
case "putty":
|
||||
return importFromPuttyReg(input);
|
||||
case "ssh_config":
|
||||
@@ -1508,15 +1738,40 @@ export function applyVaultHostImport(
|
||||
addedHosts: Host[];
|
||||
} {
|
||||
const skipDuplicates = options?.skipDuplicates !== false;
|
||||
const existingKeys = new Set(existingHosts.map(buildVaultHostMergeKey));
|
||||
const existingByKey = new Map(existingHosts.map((host) => [buildVaultHostMergeKey(host), host]));
|
||||
const droppedIdToExistingId = new Map<string, string>();
|
||||
let newHosts = importResult.hosts;
|
||||
let skippedExistingCount = 0;
|
||||
|
||||
if (skipDuplicates) {
|
||||
newHosts = importResult.hosts.filter((host) => {
|
||||
const duplicate = existingKeys.has(buildVaultHostMergeKey(host));
|
||||
if (duplicate) skippedExistingCount++;
|
||||
return !duplicate;
|
||||
const existing = existingByKey.get(buildVaultHostMergeKey(host));
|
||||
if (!existing) return true;
|
||||
skippedExistingCount++;
|
||||
if (existing.id !== host.id) droppedIdToExistingId.set(host.id, existing.id);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (droppedIdToExistingId.size > 0) {
|
||||
newHosts = newHosts.map((host) => {
|
||||
const currentIds = host.hostChain?.hostIds;
|
||||
if (!currentIds?.length) return host;
|
||||
const hostIds: string[] = [];
|
||||
for (const hostId of currentIds) {
|
||||
const mapped = droppedIdToExistingId.get(hostId) ?? hostId;
|
||||
if (mapped === host.id || hostIds.includes(mapped)) continue;
|
||||
hostIds.push(mapped);
|
||||
}
|
||||
if (hostIds.length === currentIds.length && hostIds.every((id, index) => id === currentIds[index])) {
|
||||
return host;
|
||||
}
|
||||
if (hostIds.length === 0) {
|
||||
const next = { ...host };
|
||||
delete next.hostChain;
|
||||
return next;
|
||||
}
|
||||
return { ...host, hostChain: { hostIds } };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ const FORMULA_PREFIX = /^[=+\-@\t\r]/u;
|
||||
const KEY_PATH_MARKER = "__netcatty_csv_keypath_v1__:";
|
||||
const PASSPHRASE_MARKER = "__netcatty_csv_passphrase_v1__:";
|
||||
const PROXY_MARKER = "__netcatty_csv_proxy_v1__:";
|
||||
const PROXY_JUMP_MARKER = "__netcatty_csv_proxyjump_v1__:";
|
||||
const LOCAL_SHELL_MARKER = "__netcatty_csv_localshell_v1__:";
|
||||
|
||||
const encodeMarkedField = (value: string, marker: string): string => (
|
||||
@@ -43,6 +44,14 @@ export const decodeCsvProxy = (value: string): string => (
|
||||
decodeMarkedField(value, PROXY_MARKER)
|
||||
);
|
||||
|
||||
export const encodeCsvProxyJump = (value: string): string => (
|
||||
encodeMarkedField(value, PROXY_JUMP_MARKER)
|
||||
);
|
||||
|
||||
export const decodeCsvProxyJump = (value: string): string => (
|
||||
decodeMarkedField(value, PROXY_JUMP_MARKER)
|
||||
);
|
||||
|
||||
export interface CsvLocalShellSpec {
|
||||
shell?: string;
|
||||
shellArgs?: string[];
|
||||
|
||||
@@ -3,8 +3,9 @@ import test from "node:test";
|
||||
|
||||
import { publishActiveGlobalDefaultProxyProfileId } from "../globalDefaultProxy.ts";
|
||||
import { applyGroupDefaults } from "../groupConfig.ts";
|
||||
import { importVaultHostsFromText } from "../vaultImport.ts";
|
||||
import { applyVaultHostImport, applyVaultImportDestination, importVaultHostsFromText } from "../vaultImport.ts";
|
||||
import { exportHostsToCsvWithStats, getVaultCsvTemplate } from "./csvExport.ts";
|
||||
import { parseCsv } from "./csvUtils.ts";
|
||||
import type { Host } from "../models.ts";
|
||||
|
||||
const hostDefaults = { username: "root", tags: [], os: "linux" as const };
|
||||
@@ -606,6 +607,230 @@ test("CSV round-trips local-shell vault host settings", () => {
|
||||
assert.equal(roundTrippedDefault.localStartDir, undefined);
|
||||
});
|
||||
|
||||
const proxyJumpCell = (csv: string, hostname: string): string => {
|
||||
const rows = parseCsv(csv.replace(/^\uFEFF/, "")).filter((row) => row.some((cell) => cell.trim() !== ""));
|
||||
const header = rows[0] ?? [];
|
||||
const jumpIndex = header.indexOf("ProxyJump");
|
||||
const hostIndex = header.findIndex((cell) => cell.toLowerCase().includes("hostname"));
|
||||
const row = rows.slice(1).find((cells) => cells[hostIndex] === hostname);
|
||||
return jumpIndex >= 0 ? (row?.[jumpIndex] ?? "") : "";
|
||||
};
|
||||
|
||||
const chainHost = (id: string, label: string, hostname: string, username: string, port = 22): Host => ({
|
||||
...hostDefaults,
|
||||
id,
|
||||
label,
|
||||
hostname,
|
||||
username,
|
||||
port,
|
||||
});
|
||||
|
||||
test("CSV export writes one-hop and two-hop ProxyJump chains", () => {
|
||||
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
|
||||
const second = chainHost("jump-2", "Second hop", "jump.example.com", "bob", 2222);
|
||||
const v6 = chainHost("jump-v6", "IPv6 hop", "2001:db8::1", "carol", 2222);
|
||||
const oneHop = chainHost("app-1", "One hop", "one.example.com", "root");
|
||||
oneHop.hostChain = { hostIds: [bastion.id] };
|
||||
const twoHop = chainHost("app-2", "Two hop", "two.example.com", "root");
|
||||
twoHop.hostChain = { hostIds: [bastion.id, second.id] };
|
||||
const viaV6 = chainHost("app-v6", "Via IPv6", "v6.example.com", "root");
|
||||
viaV6.hostChain = { hostIds: [v6.id] };
|
||||
const direct = chainHost("app-3", "Direct", "direct.example.com", "root");
|
||||
|
||||
const { csv } = exportHostsToCsvWithStats([bastion, second, v6, oneHop, twoHop, viaV6, direct]);
|
||||
|
||||
assert.equal(proxyJumpCell(csv, bastion.hostname), "");
|
||||
assert.equal(proxyJumpCell(csv, oneHop.hostname), "alice@bastion.example.com");
|
||||
assert.equal(proxyJumpCell(csv, twoHop.hostname), "alice@bastion.example.com,bob@jump.example.com:2222");
|
||||
assert.equal(proxyJumpCell(csv, viaV6.hostname), "carol@[2001:db8::1]:2222");
|
||||
assert.equal(proxyJumpCell(csv, direct.hostname), "");
|
||||
assert.match(getVaultCsvTemplate().split(/\r?\n/, 1)[0] ?? "", /(^|,)ProxyJump(,|$)/);
|
||||
});
|
||||
|
||||
test("CSV round-trips a jump host chain onto the imported hosts", () => {
|
||||
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
|
||||
const second = chainHost("jump-2", "Second hop", "jump.example.com", "bob", 2222);
|
||||
const target = chainHost("app", "App", "app.example.com", "root");
|
||||
target.group = "Prod";
|
||||
target.hostChain = { hostIds: [bastion.id, second.id] };
|
||||
|
||||
const { csv } = exportHostsToCsvWithStats([bastion, second, target]);
|
||||
const imported = importVaultHostsFromText("csv", csv);
|
||||
const byHostname = new Map(imported.hosts.map((host) => [host.hostname, host]));
|
||||
const importedTarget = byHostname.get(target.hostname);
|
||||
const importedBastion = byHostname.get(bastion.hostname);
|
||||
const importedSecond = byHostname.get(second.hostname);
|
||||
|
||||
assert.equal(imported.issues.length, 0);
|
||||
assert.deepEqual(importedTarget?.hostChain?.hostIds, [importedBastion?.id, importedSecond?.id]);
|
||||
assert.equal(importedBastion?.hostChain, undefined);
|
||||
assert.equal(importedSecond?.port, 2222);
|
||||
assert.equal(importedSecond?.username, "bob");
|
||||
});
|
||||
|
||||
test("CSV import creates an inline jump host when the hop is not in the file", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"app.example.com,root,alice@bastion.example.com:2222",
|
||||
].join("\n"));
|
||||
const target = imported.hosts.find((host) => host.hostname === "app.example.com");
|
||||
const jump = imported.hosts.find((host) => host.hostname === "bastion.example.com");
|
||||
|
||||
assert.equal(imported.hosts.length, 2);
|
||||
assert.equal(jump?.username, "alice");
|
||||
assert.equal(jump?.port, 2222);
|
||||
assert.equal(jump?.protocol, "ssh");
|
||||
assert.deepEqual(target?.hostChain?.hostIds, jump ? [jump.id] : []);
|
||||
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /inline jump host/i.test(issue.message)));
|
||||
});
|
||||
|
||||
test("CSV import binds a missing hop to an existing vault host by endpoint", () => {
|
||||
const existing = chainHost("vault-jump", "Old bastion", "bastion.example.com", "alice", 2222);
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"app.example.com,root,alice@bastion.example.com:2222",
|
||||
].join("\n"), { existingHosts: [existing] });
|
||||
|
||||
assert.equal(imported.hosts.length, 1);
|
||||
assert.equal(imported.hosts[0]?.hostname, "app.example.com");
|
||||
assert.deepEqual(imported.hosts[0]?.hostChain, { hostIds: [existing.id] });
|
||||
assert.deepEqual(imported.issues, []);
|
||||
|
||||
const moved = applyVaultImportDestination(imported, { mode: "group", group: "Moved" });
|
||||
assert.deepEqual(moved.hosts[0]?.hostChain, { hostIds: [existing.id] });
|
||||
});
|
||||
|
||||
test("CSV import keeps an old file without a ProxyJump column unchanged", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,Password",
|
||||
"legacy.example.com,root,secret",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(imported.hosts.length, 1);
|
||||
assert.equal(imported.hosts[0]?.hostname, "legacy.example.com");
|
||||
assert.equal(imported.hosts[0]?.username, "root");
|
||||
assert.equal(imported.hosts[0]?.password, "secret");
|
||||
assert.equal(imported.hosts[0]?.hostChain, undefined);
|
||||
assert.deepEqual(imported.issues, []);
|
||||
});
|
||||
|
||||
test("CSV import retargets a chain when the jump host is already in the vault", () => {
|
||||
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
|
||||
bastion.group = "Prod";
|
||||
const target = chainHost("app", "App", "app.example.com", "root");
|
||||
target.group = "Prod";
|
||||
target.hostChain = { hostIds: [bastion.id] };
|
||||
const existing = chainHost("vault-jump", "Bastion", "bastion.example.com", "alice");
|
||||
existing.group = "Prod";
|
||||
|
||||
const { csv } = exportHostsToCsvWithStats([bastion, target]);
|
||||
const imported = importVaultHostsFromText("csv", csv, { existingHosts: [existing] });
|
||||
const merged = applyVaultHostImport([existing], ["Prod"], imported);
|
||||
|
||||
assert.equal(merged.addedHosts.length, 1);
|
||||
assert.equal(merged.addedHosts[0]?.hostname, "app.example.com");
|
||||
assert.deepEqual(merged.addedHosts[0]?.hostChain, { hostIds: [existing.id] });
|
||||
assert.equal(merged.hosts.some((host) => host.id !== existing.id && host.hostname === bastion.hostname), false);
|
||||
});
|
||||
|
||||
test("CSV export materializes a jump chain inherited from group defaults", () => {
|
||||
const bastion = chainHost("jump-1", "Bastion", "bastion.example.com", "alice");
|
||||
const target = chainHost("app", "App", "app.example.com", "root");
|
||||
target.group = "Corp";
|
||||
|
||||
const { csv } = exportHostsToCsvWithStats([bastion, target], {
|
||||
groupConfigs: [{ path: "Corp", hostChain: { hostIds: [bastion.id] } }],
|
||||
});
|
||||
|
||||
assert.equal(proxyJumpCell(csv, target.hostname), "alice@bastion.example.com");
|
||||
});
|
||||
|
||||
test("CSV import warns and drops a circular ProxyJump chain", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"a.example.com,root,b.example.com",
|
||||
"b.example.com,root,a.example.com",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(imported.hosts.length, 2);
|
||||
assert.equal(imported.hosts.every((host) => host.hostChain === undefined), true);
|
||||
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /circular/i.test(issue.message)));
|
||||
});
|
||||
|
||||
test("CSV import drops inline hops created only for a rejected self-referential ProxyJump", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"ok.example.com,root,kept-hop.example.com",
|
||||
"app.example.com,root,\"new-hop.example.com,app.example.com\"",
|
||||
].join("\n"));
|
||||
|
||||
const hostnames = imported.hosts.map((host) => host.hostname).sort();
|
||||
assert.deepEqual(hostnames, ["app.example.com", "kept-hop.example.com", "ok.example.com"]);
|
||||
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
|
||||
const ok = imported.hosts.find((host) => host.hostname === "ok.example.com");
|
||||
const kept = imported.hosts.find((host) => host.hostname === "kept-hop.example.com");
|
||||
assert.equal(app?.hostChain, undefined);
|
||||
assert.deepEqual(ok?.hostChain?.hostIds, kept ? [kept.id] : []);
|
||||
assert.ok(imported.issues.some((issue) => issue.level === "warning" && /circular/i.test(issue.message)));
|
||||
});
|
||||
|
||||
test("CSV import prefers a same-group existing hop over a cross-group imported match", () => {
|
||||
const existing = chainHost("vault-jump", "Bastion", "bastion.example.com", "alice");
|
||||
existing.group = "Prod";
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Hostname,Username,ProxyJump",
|
||||
"Other,bastion.example.com,alice,",
|
||||
"Prod,app.example.com,root,alice@bastion.example.com",
|
||||
].join("\n"), { existingHosts: [existing] });
|
||||
|
||||
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
|
||||
const importedBastion = imported.hosts.find((host) => host.hostname === "bastion.example.com");
|
||||
assert.equal(importedBastion?.group, "Other");
|
||||
assert.notEqual(importedBastion?.id, existing.id);
|
||||
assert.deepEqual(app?.hostChain, { hostIds: [existing.id] });
|
||||
});
|
||||
|
||||
test("CSV import prefers a same-group existing label hop over a cross-group imported label", () => {
|
||||
const existing = chainHost("vault-jump", "Bastion", "vault-bastion.internal", "alice");
|
||||
existing.group = "Prod";
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Groups,Label,Hostname,Username,ProxyJump",
|
||||
"Other,Bastion,other-bastion.internal,alice,",
|
||||
"Prod,App,app.example.com,root,alice@Bastion",
|
||||
].join("\n"), { existingHosts: [existing] });
|
||||
|
||||
const app = imported.hosts.find((host) => host.hostname === "app.example.com");
|
||||
const importedBastion = imported.hosts.find((host) => host.hostname === "other-bastion.internal");
|
||||
assert.equal(importedBastion?.group, "Other");
|
||||
assert.equal(importedBastion?.label, "Bastion");
|
||||
assert.notEqual(importedBastion?.id, existing.id);
|
||||
assert.deepEqual(app?.hostChain, { hostIds: [existing.id] });
|
||||
});
|
||||
|
||||
test("CSV import warns and ignores a ProxyJump value that is not a jump host", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"app.example.com,root,not a hop",
|
||||
].join("\n"));
|
||||
|
||||
assert.equal(imported.hosts.length, 1);
|
||||
assert.equal(imported.hosts[0]?.hostChain, undefined);
|
||||
assert.ok(imported.issues.some((issue) => issue.level === "warning" && issue.message.includes("ProxyJump")));
|
||||
});
|
||||
|
||||
test("CSV import rejects a ProxyJump chain that contains a malformed hop", () => {
|
||||
const imported = importVaultHostsFromText("csv", [
|
||||
"Hostname,Username,ProxyJump",
|
||||
"app.example.com,root,\"bastion.example.com,not a hop\"",
|
||||
].join("\n"));
|
||||
|
||||
assert.deepEqual(imported.hosts.map((host) => host.hostname), ["app.example.com"]);
|
||||
assert.equal(imported.hosts[0]?.hostChain, undefined);
|
||||
assert.ok(imported.issues.some((issue) => (
|
||||
issue.level === "warning" && issue.message.includes("not a hop")
|
||||
)));
|
||||
});
|
||||
|
||||
test("CSV import warns and ignores a malformed LocalShell value", () => {
|
||||
const csv = [
|
||||
"Groups,Label,Tags,Notes,Hostname/IP,Protocol,Port,Username,Password,KeyPath,Passphrase,Proxy,LocalShell",
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { GroupConfig, Host, Identity, ProxyProfile } from '../models';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../groupConfig';
|
||||
import { isPluginHostProtocol } from '../pluginConnection';
|
||||
import { hasUnreadableProxyCredential, materializeHostProxyProfile, resolveProxyConfigAuth } from '../proxyProfiles';
|
||||
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, encodeCsvPassphrase, encodeCsvProxy } from './csvCredentialFields';
|
||||
import { canServeAsSshJumpHost } from '../terminalProtocol';
|
||||
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, encodeCsvPassphrase, encodeCsvProxy, encodeCsvProxyJump } from './csvCredentialFields';
|
||||
import { CSV_DIRECT_PROXY_VALUE, formatCsvProxy } from './csvProxy';
|
||||
|
||||
const UTF8_BOM = "\uFEFF";
|
||||
@@ -36,7 +37,7 @@ export const getVaultCsvTemplate = (
|
||||
opts: VaultCsvTemplateOptions = {},
|
||||
): string => {
|
||||
const includeExampleRows = opts.includeExampleRows !== false;
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "LocalShell"];
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
|
||||
const rows: string[][] = [header];
|
||||
if (includeExampleRows) {
|
||||
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "Dev web tier", "192.168.1.10", "ssh", "22", "root", "", "~/.ssh/id_ed25519", "", "socks5://127.0.0.1:1080"]);
|
||||
@@ -77,8 +78,44 @@ const getLocalShellSpec = (host: Host): CsvLocalShellSpec | null => (
|
||||
: null
|
||||
);
|
||||
|
||||
const UNSAFE_PROXY_JUMP_USERNAME = /[\s,#@]/;
|
||||
const UNSAFE_PROXY_JUMP_HOSTNAME = /[\s,#@]/;
|
||||
const DEFAULT_PROXY_JUMP_PORT = 22;
|
||||
|
||||
// OpenSSH ProxyJump: [user@]host[:port], repeated. Port 22 is omitted.
|
||||
// IPv6 is bracketed so the port separator stays unambiguous.
|
||||
const formatCsvProxyJumpHop = (host: Host): string | null => {
|
||||
if (!canServeAsSshJumpHost(host)) return null;
|
||||
const username = host.username?.trim() ?? "";
|
||||
const hostname = host.hostname.trim().replace(/^\[(.*)\]$/u, "$1");
|
||||
if (!hostname) return null;
|
||||
if (username && UNSAFE_PROXY_JUMP_USERNAME.test(username)) return null;
|
||||
if (UNSAFE_PROXY_JUMP_HOSTNAME.test(hostname)) return null;
|
||||
const port = host.port ?? DEFAULT_PROXY_JUMP_PORT;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
||||
const hostPart = hostname.includes(":") ? `[${hostname}]` : hostname;
|
||||
const withUser = username ? `${username}@${hostPart}` : hostPart;
|
||||
return port === DEFAULT_PROXY_JUMP_PORT ? withUser : `${withUser}:${port}`;
|
||||
};
|
||||
|
||||
// An empty cell means "no chain". A chain is omitted entirely when any hop
|
||||
// cannot be represented, so a shortened list is not written back as the truth.
|
||||
const formatCsvProxyJump = (host: Host, allHosts: readonly Host[]): string => {
|
||||
const ids = host.hostChain?.hostIds ?? [];
|
||||
if (ids.length === 0) return "";
|
||||
const byId = new Map(allHosts.map((item) => [item.id, item]));
|
||||
const parts: string[] = [];
|
||||
for (const id of ids) {
|
||||
const hop = byId.get(id);
|
||||
const formatted = hop ? formatCsvProxyJumpHop(hop) : null;
|
||||
if (!formatted) return "";
|
||||
parts.push(formatted);
|
||||
}
|
||||
return parts.join(",");
|
||||
};
|
||||
|
||||
const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "LocalShell"];
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
|
||||
const rows: string[][] = [header];
|
||||
let unreadableProxyCredentialCount = 0;
|
||||
|
||||
@@ -161,6 +198,9 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
}
|
||||
}
|
||||
const localShellSpec = getLocalShellSpec(host);
|
||||
// Group defaults are already on effectiveHost, same as Proxy, so a chain
|
||||
// inherited from the group survives a file that has no group-config section.
|
||||
const proxyJumpValue = formatCsvProxyJump(effectiveHost, exportableHosts);
|
||||
|
||||
rows.push([
|
||||
host.group ?? "",
|
||||
@@ -175,6 +215,7 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
encodeCsvKeyPath(keyPath),
|
||||
encodeCsvPassphrase(passphrase),
|
||||
encodeCsvProxy(proxyValue),
|
||||
encodeCsvProxyJump(proxyJumpValue),
|
||||
localShellSpec ? encodeCsvLocalShell(localShellSpec) : "",
|
||||
]);
|
||||
}
|
||||
@@ -183,9 +224,10 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
const keyPathColIdx = header.indexOf("KeyPath");
|
||||
const passphraseColIdx = header.indexOf("Passphrase");
|
||||
const proxyColIdx = header.indexOf("Proxy");
|
||||
const proxyJumpColIdx = header.indexOf("ProxyJump");
|
||||
const csv = rows.map((r, rowIdx) => r.map((c, i) => escapeCsv(
|
||||
c,
|
||||
rowIdx > 0 && (i === passwordColIdx || i === keyPathColIdx || i === passphraseColIdx || i === proxyColIdx),
|
||||
rowIdx > 0 && (i === passwordColIdx || i === keyPathColIdx || i === passphraseColIdx || i === proxyColIdx || i === proxyJumpColIdx),
|
||||
)).join(",")).join("\r\n") + "\r\n";
|
||||
return { csv, unreadableProxyCredentialCount };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
MARQUEE_MOVEMENT_THRESHOLD_PX,
|
||||
clientPointToContent,
|
||||
contentRectFromClientBox,
|
||||
exceedsMarqueeThreshold,
|
||||
hostIdsIntersectingMarquee,
|
||||
isPointerOnNativeScrollbar,
|
||||
marqueeAutoScrollDelta,
|
||||
marqueeCancelSelection,
|
||||
marqueeCommitSelection,
|
||||
marqueeRectFromPoints,
|
||||
marqueeSelectionMode,
|
||||
mergeMarqueeSelection,
|
||||
rememberHostPlacements,
|
||||
shouldStartVaultMarquee,
|
||||
type HostPlacement,
|
||||
} from './vaultMarqueeSelection.ts';
|
||||
|
||||
const box = (left: number, top: number, right: number, bottom: number) => ({
|
||||
left, top, right, bottom,
|
||||
});
|
||||
|
||||
test('marquee rect normalizes a drag that moves up and left', () => {
|
||||
assert.deepEqual(
|
||||
marqueeRectFromPoints({ x: 40, y: 50 }, { x: 10, y: 20 }),
|
||||
box(10, 20, 40, 50),
|
||||
);
|
||||
});
|
||||
|
||||
test('marquee movement threshold keeps a plain click below 4px', () => {
|
||||
const start = { x: 10, y: 10 };
|
||||
assert.equal(exceedsMarqueeThreshold(start, { x: 13, y: 12 }), false);
|
||||
assert.equal(
|
||||
exceedsMarqueeThreshold(start, { x: 10 + MARQUEE_MOVEMENT_THRESHOLD_PX, y: 10 }),
|
||||
true,
|
||||
);
|
||||
assert.equal(exceedsMarqueeThreshold(start, { x: 10, y: 14 }), true);
|
||||
});
|
||||
|
||||
test('marquee modifiers replace, add with shift, and toggle with ctrl or meta', () => {
|
||||
assert.equal(marqueeSelectionMode({ shiftKey: false, ctrlKey: false, metaKey: false }), 'replace');
|
||||
assert.equal(marqueeSelectionMode({ shiftKey: true, ctrlKey: false, metaKey: false }), 'add');
|
||||
assert.equal(marqueeSelectionMode({ shiftKey: false, ctrlKey: true, metaKey: false }), 'toggle');
|
||||
assert.equal(marqueeSelectionMode({ shiftKey: false, ctrlKey: false, metaKey: true }), 'toggle');
|
||||
assert.equal(marqueeSelectionMode({ shiftKey: true, ctrlKey: true, metaKey: false }), 'toggle');
|
||||
});
|
||||
|
||||
test('marquee selection merge uses the selection from drag start', () => {
|
||||
const baseline = new Set(['a', 'b']);
|
||||
const hits = new Set(['b', 'c']);
|
||||
|
||||
assert.deepEqual([...mergeMarqueeSelection(baseline, hits, 'replace')].sort(), ['b', 'c']);
|
||||
assert.deepEqual([...mergeMarqueeSelection(baseline, hits, 'add')].sort(), ['a', 'b', 'c']);
|
||||
assert.deepEqual([...mergeMarqueeSelection(baseline, hits, 'toggle')].sort(), ['a', 'c']);
|
||||
assert.deepEqual([...baseline].sort(), ['a', 'b']);
|
||||
});
|
||||
|
||||
test('host cards that intersect the marquee are selected, including partial overlap', () => {
|
||||
const placements = [
|
||||
{ id: 'inside', rect: box(10, 10, 30, 30) },
|
||||
{ id: 'partial', rect: box(28, 28, 50, 50) },
|
||||
{ id: 'touch', rect: box(40, 10, 55, 20) },
|
||||
{ id: 'miss', rect: box(41, 10, 60, 20) },
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
[...hostIdsIntersectingMarquee(box(0, 0, 40, 40), placements)].sort(),
|
||||
['inside', 'partial'],
|
||||
);
|
||||
});
|
||||
|
||||
test('placement cache keeps hosts that scroll out of the mounted set', () => {
|
||||
const cache = new Map<string, HostPlacement>();
|
||||
rememberHostPlacements(cache, [
|
||||
{ key: 'main:a', id: 'a', rect: box(0, 0, 10, 10) },
|
||||
{ key: 'pinned:a', id: 'a', rect: box(0, 100, 10, 110) },
|
||||
{ key: 'main:b', id: 'b', rect: box(0, 20, 10, 30) },
|
||||
]);
|
||||
rememberHostPlacements(cache, [
|
||||
{ key: 'main:b', id: 'b', rect: box(0, 20, 12, 30) },
|
||||
{ key: 'main:c', id: 'c', rect: box(0, 40, 10, 50) },
|
||||
]);
|
||||
|
||||
const hits = hostIdsIntersectingMarquee(box(0, 0, 10, 50), cache.values());
|
||||
assert.deepEqual([...hits].sort(), ['a', 'b', 'c']);
|
||||
assert.equal(cache.get('main:b')?.rect.right, 12);
|
||||
});
|
||||
|
||||
test('content coordinates stay stable when the scroll container moves', () => {
|
||||
const frame = {
|
||||
left: 100,
|
||||
top: 40,
|
||||
scrollLeft: 0,
|
||||
scrollTop: 25,
|
||||
clientLeft: 0,
|
||||
clientTop: 0,
|
||||
};
|
||||
assert.deepEqual(clientPointToContent({ x: 130, y: 80 }, frame), { x: 30, y: 65 });
|
||||
|
||||
const beforeScroll = contentRectFromClientBox(
|
||||
{ left: 120, top: 70, width: 40, height: 20 },
|
||||
frame,
|
||||
);
|
||||
const afterScroll = contentRectFromClientBox(
|
||||
{ left: 120, top: 50, width: 40, height: 20 },
|
||||
{ ...frame, scrollTop: 45 },
|
||||
);
|
||||
assert.deepEqual(beforeScroll, afterScroll);
|
||||
assert.deepEqual(beforeScroll, box(20, 55, 60, 75));
|
||||
});
|
||||
|
||||
test('auto-scroll only steps when the pointer is near a vertical edge', () => {
|
||||
assert.equal(marqueeAutoScrollDelta(10, 0, 200), -16);
|
||||
assert.equal(marqueeAutoScrollDelta(190, 0, 200), 16);
|
||||
assert.equal(marqueeAutoScrollDelta(100, 0, 200), 0);
|
||||
});
|
||||
|
||||
test('native scrollbar gutter does not start a marquee', () => {
|
||||
const metrics = {
|
||||
rectLeft: 0,
|
||||
rectTop: 0,
|
||||
clientLeft: 0,
|
||||
clientTop: 0,
|
||||
clientWidth: 200,
|
||||
clientHeight: 300,
|
||||
};
|
||||
assert.equal(isPointerOnNativeScrollbar(metrics, 190, 40), false);
|
||||
assert.equal(isPointerOnNativeScrollbar(metrics, 210, 40), true);
|
||||
assert.equal(isPointerOnNativeScrollbar(metrics, 20, 310), true);
|
||||
});
|
||||
|
||||
test('marquee starts on primary grid or list presses in empty space only', () => {
|
||||
const ready = {
|
||||
viewMode: 'grid',
|
||||
button: 0,
|
||||
pointerType: 'mouse',
|
||||
onScrollbar: false,
|
||||
blockedTarget: false,
|
||||
};
|
||||
assert.equal(shouldStartVaultMarquee(ready), true);
|
||||
assert.equal(shouldStartVaultMarquee({ ...ready, viewMode: 'list' }), true);
|
||||
assert.equal(shouldStartVaultMarquee({ ...ready, viewMode: 'tree' }), false);
|
||||
assert.equal(shouldStartVaultMarquee({ ...ready, button: 2 }), false);
|
||||
assert.equal(shouldStartVaultMarquee({ ...ready, pointerType: 'touch' }), false);
|
||||
assert.equal(shouldStartVaultMarquee({ ...ready, onScrollbar: true }), false);
|
||||
assert.equal(shouldStartVaultMarquee({ ...ready, blockedTarget: true }), false);
|
||||
});
|
||||
|
||||
test('committing a marquee enters multi-select only when hosts are selected', () => {
|
||||
assert.deepEqual(
|
||||
marqueeCommitSelection({ wasMultiSelectMode: false, nextIds: new Set(['a']) }),
|
||||
{ isMultiSelectMode: true, selectedHostIds: new Set(['a']) },
|
||||
);
|
||||
assert.deepEqual(
|
||||
marqueeCommitSelection({ wasMultiSelectMode: false, nextIds: new Set() }),
|
||||
{ isMultiSelectMode: false, selectedHostIds: new Set() },
|
||||
);
|
||||
assert.deepEqual(
|
||||
marqueeCommitSelection({ wasMultiSelectMode: true, nextIds: new Set() }),
|
||||
{ isMultiSelectMode: true, selectedHostIds: new Set() },
|
||||
);
|
||||
});
|
||||
|
||||
test('escape restores the selection captured at drag start', () => {
|
||||
assert.deepEqual(
|
||||
marqueeCancelSelection({
|
||||
wasMultiSelectMode: false,
|
||||
baselineIds: new Set(['kept']),
|
||||
}),
|
||||
{ isMultiSelectMode: false, selectedHostIds: new Set(['kept']) },
|
||||
);
|
||||
});
|
||||