You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@casbin.apache.org by hs...@apache.org on 2026/09/06 07:24:48 UTC

(casbin-gateway) 02/03: feat(connections): translate the catalog and add a way to test it all again

This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit f594149093a9c5ed7718eab225128ae11ed4fd24
Author: Yang Luo <hs...@gmail.com>
AuthorDate: Sun Sep 6 15:20:46 2026 +0800

    feat(connections): translate the catalog and add a way to test it all again
---
 controllers/connector.go                      |  30 ++++++++
 docs/user-manual/en/3-providers/3.5-import.md |  18 ++++-
 docs/user-manual/zh/3-providers/3.5-import.md |  18 ++++-
 object/connection_install.go                  |  26 +++++++
 routers/router.go                             |   1 +
 web/src/backend/ConnectorBackend.ts           |   8 ++
 web/src/locales/en/data.json                  |   2 +
 web/src/locales/zh/data.json                  | 103 ++++++++++++++++++++++++++
 web/src/pages/ConnectionsPage.tsx             |  84 ++++++++++++++++++---
 9 files changed, 277 insertions(+), 13 deletions(-)

diff --git a/controllers/connector.go b/controllers/connector.go
index f243b62..34fdd55 100644
--- a/controllers/connector.go
+++ b/controllers/connector.go
@@ -426,3 +426,33 @@ func (c *ApiController) TestConnection() {
 	}
 	c.ResponseOk(result)
 }
+
+// RetestConnections tests every connection again. It answers as soon as the
+// work is under way rather than when it is done: each test starts a server and
+// waits for it, so a machine with a dozen connections would hold the request
+// open for minutes. What each one found is read back from the listing.
+func (c *ApiController) RetestConnections() {
+	if c.RequireAdmin() {
+		return
+	}
+
+	var form struct {
+		Owner string `json:"owner"`
+	}
+	if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+
+	connections, err := object.GetConnections(form.Owner)
+	if err != nil {
+		c.ResponseError(err.Error())
+		return
+	}
+
+	go func(owner string) {
+		beego.Info("retesting", object.RetestConnections(owner), "connections")
+	}(form.Owner)
+
+	c.ResponseOk(len(connections))
+}
diff --git a/docs/user-manual/en/3-providers/3.5-import.md b/docs/user-manual/en/3-providers/3.5-import.md
index 464b22d..e8d8c94 100644
--- a/docs/user-manual/en/3-providers/3.5-import.md
+++ b/docs/user-manual/en/3-providers/3.5-import.md
@@ -15,7 +15,7 @@ If CC Switch is installed under this account, the page says so and offers what i
 
 Vendors put an "add this to my agent manager" button on their own pages, which opens a `ccswitch://` link rather than a page. Gateway reads that format instead of inventing a second one nobody would be given a button for.
 
-One link carries any of four things:
+One link carries any of five things:
 
 | Kind | What arrives |
 | --- | --- |
@@ -23,6 +23,22 @@ One link carries any of four things:
 | **MCP server** | The JSON block a server is written in, with the arguments it would run with |
 | **Instructions** | A set of instructions for an agent to read before every session |
 | **Skill source** | A repository to install skills from — adding it installs nothing |
+| **Connection** | The name of an application in Gateway's own [catalogue](../2-agents/2.5-connections.md) |
+
+### Writing a connection link
+
+A vendor whose application is in the catalogue can put a button on their own page. The link names the application and, optionally, the agents to offer it for:
+
+```
+ccswitch://v1/import?resource=connection&connector=notion&apps=claude,cursor
+```
+
+| Parameter | |
+| --- | --- |
+| `connector` | The id of the entry in Gateway's catalogue. A name Gateway does not have is **refused by name**, so the reader can say what they were sent. |
+| `apps` | Optional, comma-separated. The same app names the other kinds use. |
+
+**A connection link carries no credential**, and there is nowhere in it to put one. Opening it takes the reader to the form where they fill those in themselves — which is the only sensible thing for a link that arrives from a website.
 
 Everything the link held is laid out before any of it is written: the arguments an MCP server would be run with, the instructions in full, the repository a skill would come from. The link came from a website, so its values are read first.
 
diff --git a/docs/user-manual/zh/3-providers/3.5-import.md b/docs/user-manual/zh/3-providers/3.5-import.md
index cdae897..9c8a4c6 100644
--- a/docs/user-manual/zh/3-providers/3.5-import.md
+++ b/docs/user-manual/zh/3-providers/3.5-import.md
@@ -15,7 +15,7 @@
 
 厂商在自己页面上放"添加到我的Agent管理器"按钮,点开的是一个`ccswitch://`链接而不是网页。Gateway直接读这个格式,而不是再发明一套没人会给按钮的格式。
 
-一条链接可以携带四类东西中的任意一类:
+一条链接可以携带五类东西中的任意一类:
 
 | 类型 | 带过来什么 |
 | --- | --- |
@@ -23,6 +23,22 @@
 | **MCP服务器** | 写服务器用的那段JSON,包括它将要运行的参数 |
 | **指令** | 一份供Agent在每次会话前阅读的指令 |
 | **技能来源** | 一个用来安装技能的仓库——加进来本身不安装任何东西 |
+| **连接** | Gateway自己[目录](../2-agents/2.5-connections.md)里某个应用的名字 |
+
+### 怎么写一条连接链接
+
+只要应用在目录里,厂商就可以在自己页面上放一个按钮。链接指名应用,还可以顺带指定要装进哪些Agent:
+
+```
+ccswitch://v1/import?resource=connection&connector=notion&apps=claude,cursor
+```
+
+| 参数 | |
+| --- | --- |
+| `connector` | 目录条目的id。Gateway里没有的名字会**被指名拒绝**,这样读到的人能说清自己收到了什么。 |
+| `apps` | 可选,逗号分隔。和其他几种链接用的是同一套应用名。 |
+
+**连接链接不携带任何凭据**,格式里也没有地方放。打开它只是把人带到填凭据的表单——对一条来自网站的链接来说,这是唯一合理的做法。
 
 链接携带的一切都会在写入之前摊开给你看:MCP服务器将以什么参数运行、指令的完整内容、技能会从哪个仓库来。链接来自网站,所以它的值先读给你听。
 
diff --git a/object/connection_install.go b/object/connection_install.go
index 2ecec4c..3e6cd3b 100644
--- a/object/connection_install.go
+++ b/object/connection_install.go
@@ -363,3 +363,29 @@ func entryNameOf(connection *Connection) string {
 	}
 	return connection.Name
 }
+
+// RetestConnections tests every connection this owner has, one after another,
+// and reports how many were tried. A server that has been updated since it was
+// connected may offer different tools, and nothing else would notice: the tool
+// list is what the per-tool switches are built from, so a stale one governs an
+// agent by a list of tools that no longer exists.
+//
+// Failures are not returned. Each one is stored against its own connection,
+// which is where the page reads it, and one unreachable service must not stop
+// the rest from being tested.
+func RetestConnections(owner string) int {
+	connections, err := GetConnections(owner)
+	if err != nil {
+		return 0
+	}
+
+	tested := 0
+	for _, connection := range connections {
+		if connection.Orphaned() {
+			continue
+		}
+		_, _ = TestConnection(owner, connection.Name)
+		tested++
+	}
+	return tested
+}
diff --git a/routers/router.go b/routers/router.go
index 1045042..70a42d6 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -125,6 +125,7 @@ func initAPI() {
 	beego.Router("/api/get-connector-redirect-uri", &controllers.ApiController{}, "GET:GetConnectorRedirectUri")
 	beego.Router("/api/start-connector-auth", &controllers.ApiController{}, "POST:StartConnectorAuth")
 	beego.Router("/api/test-connection", &controllers.ApiController{}, "POST:TestConnection")
+	beego.Router("/api/retest-connections", &controllers.ApiController{}, "POST:RetestConnections")
 	// Opened by the vendor's redirect rather than by the UI, so it is reached
 	// without a session: what proves the caller is the unguessable state it
 	// carries, which is the whole point of that parameter.
diff --git a/web/src/backend/ConnectorBackend.ts b/web/src/backend/ConnectorBackend.ts
index 9b3dcfe..2c04029 100644
--- a/web/src/backend/ConnectorBackend.ts
+++ b/web/src/backend/ConnectorBackend.ts
@@ -65,3 +65,11 @@ export function startConnectorAuth(owner: string, name: string, credentials: Rec
 export function testConnection(owner: string, name: string) {
   return request<ConnectorProbeResult>("/api/test-connection", "POST", {owner: owner, name: name});
 }
+
+/**
+ * Tests every connection again. It answers as soon as the work has started, so
+ * the caller reloads the listing a little later to see what each one found.
+ */
+export function retestConnections(owner: string) {
+  return request<number>("/api/retest-connections", "POST", {owner: owner});
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index b0b4135..2ee8368 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -889,6 +889,7 @@
     "Professional": "Professional services",
     "Read and organise pages, databases and wikis: find a document, take stock of a project, and write the conclusion back.": "Read and organise pages, databases and wikis: find a document, take stock of a project, and write the conclusion back.",
     "Read and write repositories, issues and pull requests, and search code and history, so an agent works inside your project's own context.": "Read and write repositories, issues and pull requests, and search code and history, so an agent works inside your project's own context.",
+    "Retesting": "Testing {count} connections. Their tool lists update as each one answers.",
     "Run test": "Run test",
     "Search applications": "Search applications",
     "Search places, plan driving and walking routes, check the weather, and turn an address into coordinates.": "Search places, plan driving and walking routes, check the weather, and turn an address into coordinates.",
@@ -899,6 +900,7 @@
     "Test failed": "Test failed",
     "Test hint": "Starts the server and asks what tools it offers. The first run installs it, which is slow.",
     "Test result": "{server} answered, offering {count} tools.",
+    "Test them all": "Test them all",
     "Testing": "Testing...",
     "Testing in background": "Connected. Testing it now, so its tools get a switch each.",
     "The agent can read and write this directory and everything under it.": "The agent can read and write this directory and everything under it.",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index b4f031b..66fb228 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -904,81 +904,184 @@
     "case.vendor-headers.question": "厂商自己API的那些响应头回来了吗?"
   },
   "connector": {
+    "A browser that runs in Browserbase's cloud rather than on this machine, for pages that need a real one and a clean address.": "跑在Browserbase云端而不是本机的浏览器,适合那些需要真实浏览器和干净出口IP的页面。",
+    "A knowledge graph the agent writes to and reads back, so what it learned in one session is there in the next.": "一张Agent可以写入和回读的知识图谱,这次会话学到的东西下次还在。",
+    "A tool for working a hard problem through in steps, revising earlier ones as the shape of it becomes clear.": "把难题拆成一步步来想的工具,问题轮廓清晰之后还能回头修正前面的步骤。",
+    "A verified address on your Resend account. Leave it blank to name one per message.": "你Resend账号下已验证的地址。留空则每封信单独指定。",
+    "API URL": "API地址",
     "API key": "API Key",
+    "API token": "API令牌",
+    "Account email": "账号邮箱",
+    "Add a group robot in WeCom and copy the address it gives you.": "在企业微信群里添加群机器人,把它给出的地址复制过来。",
+    "Alipay": "支付宝",
+    "Alipay public key": "支付宝公钥",
     "All": "全部",
     "Amap": "高德地图",
+    "AntV Charts": "AntV图表",
+    "Apify console, Settings, Integrations.": "Apify控制台→Settings→Integrations。",
     "App ID": "App ID",
+    "App password": "应用专用密码",
     "App secret": "App Secret",
+    "AppKey": "AppKey",
+    "AppSecret": "AppSecret",
+    "Application private key": "应用私钥",
     "Authorization": "授权",
     "Authorization hint": "先在厂商平台注册应用并填好上面的信息,再在弹出的标签页里完成授权。",
     "Authorize": "去授权",
     "Authorize again": "重新授权",
     "Authorized": "已授权",
+    "Baidu Maps": "百度地图",
     "Billed by vendor": "第三方收费",
     "Callback URL": "回调地址",
     "Callback URL hint": "把这个地址原样填进应用的回调地址,多一个字符都不行。",
+    "Change this for a self-hosted GitLab.": "自建GitLab请改成你自己的地址。",
+    "China Railway 12306": "12306火车票",
+    "Client ID": "Client ID",
+    "Client secret": "Client Secret",
+    "CloudBase": "腾讯云开发",
+    "Cluster URL": "集群地址",
     "Connect": "连接",
     "Connected in_other": "已装入{{count}}个Agent",
+    "Connection string": "连接串",
     "Connections": "连接",
     "Connections detail": "连接常用的应用,让Agent帮你跨工具完成任务。一次连接保存在这里,并写入你勾选的每个Agent。",
     "Create an internal integration in Notion, then share with it the pages it may read.": "在Notion开发者后台建一个internal integration,再把允许它读取的页面共享给它。",
+    "Create and manage Redis and Vector databases on Upstash, and read what they hold.": "在Upstash上创建和管理Redis与Vector数据库,并读取里面的数据。",
+    "Create payments, check their status and issue refunds through your own Alipay merchant application.": "通过你自己的支付宝商户应用发起支付、查询支付状态、发起退款。",
     "Development": "开发工具",
+    "DingTalk": "钉钉",
     "Directory to allow": "允许访问的目录",
     "Disconnect": "断开连接",
     "Disconnected": "已断开",
     "Documents": "文档知识",
     "Drive a browser: open pages, click, fill forms, take screenshots, and read the console and network, for end-to-end tests and scraping.": "让Agent操作浏览器:打开页面、点击、填表单、截图、读取控制台和网络请求,用于端到端测试和网页取数。",
+    "Email": "邮箱",
+    "Email address": "邮箱地址",
+    "Explore collections, run queries and read indexes and schemas on a MongoDB deployment, without leaving the editor.": "在编辑器里就能浏览MongoDB的集合、执行查询、查看索引和结构。",
     "Failed": "写入失败",
+    "Figma, Settings, Security, Personal access tokens. Read-only file access is enough.": "Figma→Settings→Security→Personal access tokens。只读的文件权限就够了。",
+    "Flight status, schedules and how late a route usually runs, from Variflight's data.": "基于飞常准的数据查询航班状态、时刻表,以及某条航线通常晚点多久。",
+    "From a Slack app installed in the workspace, under OAuth & Permissions.": "在工作区已安装的Slack应用里,OAuth & Permissions页面获取。",
     "From a self-built app on the Lark open platform. Its permissions are what your own administrator approved.": "来自飞书开放平台上的企业自建应用,权限范围由你们的管理员审批。",
     "From context7.com. It works without one, under a stricter rate limit.": "在context7.com注册后获取,留空也可使用,但速率限制更严。",
+    "From the Alipay open platform, for your merchant application.": "在支付宝开放平台上,你那个商户应用里获取。",
     "From the Amap open platform. Pick the web service key type, not the one for apps.": "在高德开放平台申请,注意选择「Web服务」类型的Key,不是应用类型的。",
+    "From the Baidu Maps open platform. Create a server-side key, not a browser one.": "在百度地图开放平台申请,注意建「服务端」类型的Key,不是浏览器端的。",
+    "From the Firecrawl dashboard.": "在Firecrawl控制台获取。",
+    "From the OAuth app you register. Set its callback URL to the address shown above.": "来自你自己注册的OAuth应用。把它的回调地址设成上面那个。",
+    "From the Tavily dashboard.": "在Tavily控制台获取。",
+    "From the Variflight developer platform.": "在飞常准开放平台获取。",
+    "From your own self-built app on the Lark open platform.": "在飞书开放平台上你自己的企业自建应用里获取。",
+    "From your self-built app on the DingTalk open platform, where it is also called the Client ID.": "在钉钉开放平台的企业自建应用里获取,那里也叫Client ID。",
+    "Generates audited Solidity, Cairo and Stellar contracts from the OpenZeppelin Wizard: tokens, access control, upgradeable proxies.": "用OpenZeppelin Wizard生成经过审计的Solidity、Cairo和Stellar合约:代币、权限控制、可升级代理。",
+    "GitHub (OAuth app)": "GitHub(OAuth应用)",
+    "GitLab, Preferences, Access tokens. The api scope covers everything here.": "GitLab→Preferences→Access tokens。勾选api权限就覆盖了这里用到的全部功能。",
     "Give an agent one directory to read and write. Nothing outside the folder you name is reachable through it.": "把一个指定目录交给Agent读写,范围仅限你填的那个路径,不影响机器上的其他文件。",
     "Hand Chrome DevTools to an agent: record performance profiles, inspect the DOM, read network requests and console errors on a real page.": "把Chrome开发者工具交给Agent:录制性能剖面、检查DOM、读取网络请求和控制台报错,用于排查真实页面的问题。",
+    "HowToCook": "做饭指南",
+    "IMAP host": "IMAP服务器",
+    "IMAP port": "IMAP端口",
     "In no agent": "未装入任何Agent",
     "Install into": "装入哪些Agent",
     "Install into hint": "这条连接会写入下面勾选的Agent,取消勾选会把对应的MCP服务从它的配置里删掉。它们都通过Gateway访问这个应用,所以Gateway没运行时连接不可用。",
     "Integration token": "集成令牌",
+    "Kibana, Stack Management, API keys.": "Kibana→Stack Management→API keys。",
     "Lark": "飞书",
+    "Lark (sign-in)": "飞书(登录授权)",
     "Last tested": "上次测试于{time}",
     "Life": "生活服务",
     "Loading": "加载中...",
     "Local files": "本地文件",
+    "Looks up Wikipedia articles and the images in them, so a question of fact is answered from the encyclopedia rather than from memory.": "查询维基百科的词条和其中的配图,事实性问题从百科里找答案,而不是靠模型记忆。",
+    "Looks up trains, seats and fares on China Railway: which trains run, what is left, and how long a transfer takes.": "查12306的车次、余票和票价:有哪些车、还剩什么座、中转要多久。",
     "Mail": "邮件服务",
     "Manage": "管理",
+    "Management API key": "管理API Key",
+    "Memory": "记忆",
     "Never tested": "尚未测试",
     "No agent found": "这台机器上没有Gateway能写入MCP配置的Agent。",
     "No such connector": "这个Gateway里没有叫{name}的连接器。",
     "Not authorized": "尚未授权",
+    "Not the password you sign in with: create an app password in your mail account's security settings.": "不是你登录用的密码:请在邮箱的安全设置里生成一个应用专用密码。",
     "Not written_other": "有{{count}}个Agent没能写入。",
     "Nothing matches": "没有匹配「{query}」的应用。",
     "Office": "办公协同",
+    "One connection for PostgreSQL, MySQL, MariaDB, SQL Server and SQLite: read the schema, run queries, explain a plan.": "一个连接覆盖PostgreSQL、MySQL、MariaDB、SQL Server和SQLite:读表结构、跑查询、看执行计划。",
+    "Only needed when the address alone is not enough to find the server.": "只有在光凭邮箱地址找不到服务器时才需要填。",
+    "OpenZeppelin Contracts": "OpenZeppelin合约",
     "Orphan connection": "{name}已连接,但当前版本的Gateway里已经没有这个连接器",
     "Orphan hint": "它的服务仍写在{count}个Agent里,凭据也还存在这里。断开连接会把两者一并清除。",
     "Personal access token": "个人访问令牌",
     "Pick at least one agent": "至少勾选一个Agent。",
+    "Posts messages into a WeCom group through its own robot webhook: a build result, an alert, a summary at the end of a run.": "通过群机器人的webhook往企业微信群里发消息:构建结果、告警,或者一轮跑完的总结。",
     "Professional": "专业服务",
+    "Project ID": "项目ID",
     "Read and organise pages, databases and wikis: find a document, take stock of a project, and write the conclusion back.": "读取和整理页面、数据库与知识库:查找文档、梳理项目状态,并把结论写回页面。",
+    "Read and write customers, products, prices, invoices and payments, so an agent can work against your billing without a dashboard.": "读写客户、商品、价格、账单和支付,让Agent不用打开后台就能处理你的计费。",
+    "Read and write projects, issues, merge requests and pipelines on GitLab, self-hosted instances included.": "读写GitLab的项目、issue、合并请求和流水线,自建实例同样支持。",
     "Read and write repositories, issues and pull requests, and search code and history, so an agent works inside your project's own context.": "读写仓库、issue和pull request,检索代码与提交历史,让Agent直接在你的项目上下文里工作。",
+    "Read channels and threads, search the history and post messages in a Slack workspace.": "在Slack工作区里读频道和话题、搜历史消息、发消息。",
+    "Read issues, events and stack traces from Sentry, so an agent debugging a crash starts from what actually happened in production.": "读取Sentry里的问题、事件和调用栈,让Agent排查崩溃时从生产环境真实发生的事情开始。",
+    "Reads a Figma file's layout, styles and components, so an agent can build a screen from the design rather than from a screenshot.": "读取Figma文件的布局、样式和组件,让Agent照着设计稿而不是截图来实现界面。",
+    "Reads and changes the cluster your kubeconfig points at: pods, deployments, logs, events. The switches are where you decide how much of that an agent may do.": "读取并修改kubeconfig指向的集群:Pod、Deployment、日志、事件。具体放开多少,由权限开关来定。",
+    "Reads and sends mail over IMAP and SMTP, so it works with Gmail, Outlook, QQ, 163 and anything self-hosted without registering an application anywhere.": "通过IMAP和SMTP收发邮件,Gmail、Outlook、QQ邮箱、163和自建邮箱都能用,不需要去任何平台注册应用。",
+    "Reads the contact directory and calendars of a DingTalk organisation through your own self-built application.": "通过你自己的企业自建应用读取钉钉组织的通讯录和日程。",
+    "Recipes and a plan for the week from the HowToCook cookbook: pick by ingredients, by how many people, or let it decide.": "来自《程序员做饭指南》的菜谱和一周搭配:按食材挑、按人数挑,或者让它替你决定。",
+    "Retesting": "正在测试{count}个连接,每个测完后它的工具清单会随之更新。",
+    "Robot webhook URL": "群机器人Webhook地址",
+    "Run any of Apify's scrapers and automations, so a page that needs a real browser and a proxy is somebody else's problem.": "调用Apify上的各种爬虫和自动化脚本,需要真实浏览器和代理才能抓的页面交给它们去处理。",
     "Run test": "测试",
+    "Runs commands and edits files anywhere on this machine. Powerful enough to be worth being deliberate about: give it its own switches rather than leaving it open.": "在本机任意位置执行命令和编辑文件。能力很大,值得单独对待:给它逐个工具设开关,别整个放开。",
+    "SMTP host": "SMTP服务器",
+    "SMTP port": "SMTP端口",
+    "SQL databases": "SQL数据库",
+    "Scrapes and crawls websites into clean text the model can read, including pages that only render once their scripts have run.": "把网站抓取成模型能读的干净文本,包括那些要等脚本跑完才渲染出内容的页面。",
     "Search applications": "搜索应用",
+    "Search indices, read mappings and run queries against an Elasticsearch cluster in the agent's own words.": "用自然语言检索Elasticsearch集群:搜索索引、查看mapping、执行查询。",
     "Search places, plan driving and walking routes, check the weather, and turn an address into coordinates.": "搜索地点、规划驾车与步行路线、查询天气,以及把地址转成坐标。",
+    "Search places, plan routes, check the weather and turn an address into coordinates, over Baidu's map data.": "基于百度地图数据搜索地点、规划路线、查询天气,以及把地址转成坐标。",
+    "Searches Airbnb listings and reads one in detail: price, rules, reviews and what is nearby.": "搜索Airbnb房源并查看单个房源的详情:价格、入住规则、评价,以及周边有什么。",
+    "Searches videos and pulls their subtitles, transcript, audio or the file itself, through yt-dlp. Needs yt-dlp installed on this machine.": "通过yt-dlp搜索视频,并取回字幕、文字稿、音频或视频本身。需要本机已安装yt-dlp。",
+    "Secret key": "密钥",
+    "Sender address": "发件人地址",
+    "Sends mail through Resend: a report at the end of a run, an alert, anything an agent should put in somebody's inbox rather than in a log.": "通过Resend发信:跑完一轮的报告、一条告警,以及任何该进别人收件箱而不是日志的内容。",
+    "Sentry, Settings, Auth tokens. Read scopes are enough.": "Sentry→Settings→Auth tokens。只读权限就够了。",
+    "Sequential Thinking": "逐步思考",
+    "Server-side AK": "服务端AK",
     "Settings, Developer settings, Personal access tokens. Grant the scopes you want the agent to have, and no more.": "在Settings→Developer settings→Personal access tokens生成,只勾选你愿意让Agent拥有的权限。",
+    "Start with a read-only user. The agent can do whatever this account can.": "先从只读账号开始。这个账号能做什么,Agent就能做什么。",
     "Storage": "网盘存储",
     "Taken out": "已移出",
+    "Tencent CloudBase from inside the agent: environments, cloud functions, the database, hosting and deployments. Signing in happens through its own tool the first time.": "在Agent里操作腾讯云开发:环境、云函数、数据库、静态托管和部署。首次使用时通过它自带的工具完成登录。",
     "Test": "测试连接",
     "Test failed": "测试失败",
     "Test hint": "启动这个服务并问它提供哪些工具。首次运行要先安装,会比较慢。",
     "Test result": "{server}已响应,提供{count}个工具。",
+    "Test them all": "全部重新测试",
     "Testing": "测试中...",
     "Testing in background": "已连接。正在测试它,好给每个工具生成开关。",
     "The agent can read and write this directory and everything under it.": "Agent只能读写这个目录及其子目录。",
+    "The database this connection may reach. Point it at a replica or a read-only user first.": "这条连接能访问的数据库。建议先指向从库或只读账号。",
+    "The same GitHub access, granted by signing in instead of pasting a token. The application is one you register yourself, so the grant and its audit trail stay in your own organisation.": "同样是访问GitHub,但通过登录授权而不是粘贴令牌。应用是你自己注册的,授权记录和审计轨迹都留在你自己的组织里。",
+    "The same Lark access, granted by signing in rather than by handing over the application secret. What the agent may reach is what you may reach, not everything the app was approved for.": "同样是访问飞书,但通过登录授权而不是交出应用密钥。Agent能碰到的范围是「你」能碰到的范围,而不是这个应用被批准的全部权限。",
     "Tool count_other": "{{count}}个工具",
+    "Turns data into a chart image: lines, bars, pies, maps and a couple of dozen more, without a plotting library in the project.": "把数据画成图:折线、柱状、饼图、地图等二十多种,项目里不用引入任何绘图库。",
     "Unverified": "未验证",
     "Up-to-date documentation and code samples for third-party libraries, so the model stops writing API calls from memory.": "为Agent提供第三方库的最新文档和代码示例,避免模型凭记忆写出过时的API调用。",
+    "Upstash console, Account, Management API.": "Upstash控制台→Account→Management API。",
+    "Use a restricted key in test mode first. A live secret key can move real money.": "建议先用测试环境的受限密钥。正式环境的密钥是能动真钱的。",
+    "Used to check the signature on what Alipay sends back.": "用于校验支付宝回传数据的签名。",
+    "User OAuth token": "用户OAuth令牌",
+    "User auth token": "用户认证令牌",
+    "Variflight": "飞常准",
+    "Video and subtitles": "视频与字幕",
     "Waiting for approval": "等待授权完成...",
+    "WeCom robot": "企业微信机器人",
+    "Web search built for models rather than people: a question goes in, sourced answers come back instead of a page of links.": "为模型而不是为人做的网页搜索:给一个问题,返回带出处的答案,而不是一页链接。",
     "Web service API key": "Web服务API Key",
     "Where it went": "写到了哪里",
     "Where these come from": "去哪里获取这些凭据",
+    "Wikipedia": "维基百科",
     "Will remove": "将从{names}移出。",
     "Will write": "将写入{names}。",
     "Work with Lark from inside an agent: messages and groups, documents, spreadsheets, calendars and contacts, through your own company's app.": "在Agent里直接操作飞书:消息与群组、云文档、多维表格、日历和通讯录,凭据来自你自己企业的自建应用。",
diff --git a/web/src/pages/ConnectionsPage.tsx b/web/src/pages/ConnectionsPage.tsx
index 2e3b9a9..a4f9ec8 100644
--- a/web/src/pages/ConnectionsPage.tsx
+++ b/web/src/pages/ConnectionsPage.tsx
@@ -14,7 +14,7 @@
 
 import * as React from "react";
 import {useSearchParams} from "react-router-dom";
-import {Check, ExternalLink, Plug, Search, TriangleAlert} from "lucide-react";
+import {Check, ExternalLink, Plug, RefreshCw, Search, TriangleAlert} from "lucide-react";
 import i18next from "i18next";
 
 import * as Setting from "@/Setting";
@@ -86,6 +86,7 @@ export default function ConnectionsPage({account}: {account: Account}) {
   // it asks for is this dialog rather than anything written on its behalf.
   const [params, setParams] = useSearchParams();
   const asked = params.get("connect") ?? "";
+  const [retesting, setRetesting] = React.useState(false);
 
   const reload = React.useCallback(() => {
     if (!isAdmin) {
@@ -123,6 +124,27 @@ export default function ConnectionsPage({account}: {account: Account}) {
     setParams(params, {replace: true});
   }, [asked, catalog, params, setParams]);
 
+  // A server that has been updated since it was connected may offer different
+  // tools, and the per-tool switches are built from the list the last test
+  // found. This is how that list is brought up to date.
+  const retestAll = () => {
+    setRetesting(true);
+    ConnectorBackend.retestConnections(account.name)
+      .then(response => {
+        if (response.status !== "ok") {
+          Setting.showMessage("error", response.msg ?? "");
+          return;
+        }
+        Setting.showMessage("success", i18next.t("connector:Retesting").replace("{count}", `${response.data ?? 0}`));
+        // Each test starts a server and waits for it, so the answers arrive
+        // over the next while rather than at once.
+        window.setTimeout(reload, 20000);
+        window.setTimeout(reload, 60000);
+      })
+      .catch(error => Setting.showMessage("error", error.message))
+      .finally(() => setRetesting(false));
+  };
+
   if (!isAdmin) {
     return <UnauthorizedResult />;
   }
@@ -147,7 +169,18 @@ export default function ConnectionsPage({account}: {account: Account}) {
 
   return (
     <PageContainer>
-      <PageHeader title={i18next.t("connector:Connections")} description={i18next.t("connector:Connections detail")} />
+      <PageHeader
+        title={i18next.t("connector:Connections")}
+        description={i18next.t("connector:Connections detail")}
+        actions={
+          connectors.some(entry => entry.connected) ? (
+            <Button variant="outline" onClick={retestAll} disabled={retesting}>
+              <RefreshCw className={cn("size-4", retesting && "animate-spin")} />
+              {i18next.t("connector:Test them all")}
+            </Button>
+          ) : null
+        }
+      />
 
       <div className="flex flex-wrap items-center gap-2">
         <FilterChip active={category === ""} onClick={() => setCategory("")}>
@@ -187,7 +220,12 @@ export default function ConnectionsPage({account}: {account: Account}) {
       ) : (
         <div className="grid gap-4 lg:grid-cols-2">
           {shown.map(entry => (
-            <ConnectorCard key={entry.id} entry={entry} onConnect={() => setEditing(entry)} />
+            <ConnectorCard
+              key={entry.id}
+              entry={entry}
+              targets={catalog?.targets ?? []}
+              onConnect={() => setEditing(entry)}
+            />
           ))}
         </div>
       )}
@@ -273,8 +311,17 @@ function FilterChip({active, onClick, children}: {active: boolean; onClick: () =
   );
 }
 
-function ConnectorCard({entry, onConnect}: {entry: ConnectorEntry; onConnect: () => void}) {
+function ConnectorCard({
+  entry,
+  targets,
+  onConnect,
+}: {
+  entry: ConnectorEntry;
+  targets: ConnectorTarget[];
+  onConnect: () => void;
+}) {
   const image = iconUrl(entry.icon);
+  const nameOf = (agentId: string) => targets.find(target => target.agentId === agentId)?.name ?? agentId;
   return (
     <Card className="bg-muted/40 border-none shadow-none">
       <CardContent className="flex items-start gap-4 p-5">
@@ -285,12 +332,8 @@ function ConnectorCard({entry, onConnect}: {entry: ConnectorEntry; onConnect: ()
         <div className="min-w-0 flex-1">
           <div className="flex flex-wrap items-center gap-2">
             <span className="font-semibold">{text(entry.displayName)}</span>
-            {entry.connected ? (
-              entry.agents.length > 0 ? (
-                <Badge variant="secondary">{i18next.t("connector:Connected in", {count: entry.agents.length})}</Badge>
-              ) : (
-                <Badge variant="destructive">{i18next.t("connector:In no agent")}</Badge>
-              )
+            {entry.connected && entry.agents.length === 0 ? (
+              <Badge variant="destructive">{i18next.t("connector:In no agent")}</Badge>
             ) : null}
             {entry.tools && entry.tools.length > 0 ? (
               <Badge variant="outline">
@@ -302,9 +345,28 @@ function ConnectorCard({entry, onConnect}: {entry: ConnectorEntry; onConnect: ()
             {entry.unverified ? <Badge variant="outline">{i18next.t("connector:Unverified")}</Badge> : null}
           </div>
           <p className="text-muted-foreground mt-1 line-clamp-2 text-sm">{text(entry.description)}</p>
+
+          {entry.agents.length > 0 ? (
+            <div className="mt-2 flex flex-wrap items-center gap-1.5">
+              <span className="text-muted-foreground text-xs">{i18next.t("connector:In agents")}</span>
+              {entry.agents.map(agentId => (
+                <span
+                  key={agentId}
+                  className="bg-background flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium"
+                >
+                  <AgentIcon agent={agentId} size={14} />
+                  {nameOf(agentId)}
+                </span>
+              ))}
+            </div>
+          ) : null}
         </div>
 
-        <Button className="shrink-0 rounded-full" onClick={onConnect}>
+        <Button
+          variant={entry.connected ? "outline" : "default"}
+          className="shrink-0 rounded-full"
+          onClick={onConnect}
+        >
           {entry.connected ? i18next.t("connector:Manage") : i18next.t("connector:Connect")}
         </Button>
       </CardContent>


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@casbin.apache.org
For additional commands, e-mail: commits-help@casbin.apache.org