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 c805917a9596abc4c03eca247c7a2d7eb04cfba5
Author: Yang Luo <[email protected]>
AuthorDate: Sun Sep 6 00:33:06 2026 +0800

    fix(connections): let a connection outlive the connector it came from
---
 controllers/connector.go          | 26 ++++++++++-
 object/connection.go              | 32 +++++++++++--
 object/connection_install.go      | 27 +++++++++--
 web/src/locales/en/data.json      |  4 ++
 web/src/locales/zh/data.json      |  4 ++
 web/src/pages/ConnectionsPage.tsx | 98 +++++++++++++++++++++++++++++++++++++--
 web/src/types.ts                  | 12 +++++
 7 files changed, 189 insertions(+), 14 deletions(-)

diff --git a/controllers/connector.go b/controllers/connector.go
index 2059ff4..bd37c7d 100644
--- a/controllers/connector.go
+++ b/controllers/connector.go
@@ -64,6 +64,18 @@ type ConnectorCatalog struct {
        Connectors []*ConnectorEntry  `json:"connectors"`
        Categories []string           `json:"categories"`
        Targets    []*ConnectorTarget `json:"targets"`
+       // Orphans are connections whose connector this build no longer has, 
after a
+       // release dropped one. There is no card to draw for them and nothing 
left to
+       // edit, but they are still written into agents, so the page has to be 
able
+       // to show them and take them away.
+       Orphans []*OrphanConnection `json:"orphans"`
+}
+
+// OrphanConnection is one of those, with only what it takes to say which it is
+// and undo it.
+type OrphanConnection struct {
+       Name   string   `json:"name"`
+       Agents []string `json:"agents"`
 }
 
 // GetConnectors lists the whole catalog with each entry's connection state, 
the
@@ -99,12 +111,24 @@ func (c *ApiController) GetConnectors() {
                entries = append(entries, entry)
        }
 
+       orphans := []*OrphanConnection{}
+       for _, connection := range connections {
+               if connection.Orphaned() {
+                       orphans = append(orphans, &OrphanConnection{Name: 
connection.Name, Agents: connection.Agents})
+               }
+       }
+
        targets, err := connectorTargets()
        if err != nil {
                c.ResponseError(err.Error())
                return
        }
-       c.ResponseOk(&ConnectorCatalog{Connectors: entries, Categories: 
connector.Categories(), Targets: targets})
+       c.ResponseOk(&ConnectorCatalog{
+               Connectors: entries,
+               Categories: connector.Categories(),
+               Targets:    targets,
+               Orphans:    orphans,
+       })
 }
 
 // connectorTargets is every installation found on this machine whose MCP
diff --git a/object/connection.go b/object/connection.go
index c0ede83..11fad51 100644
--- a/object/connection.go
+++ b/object/connection.go
@@ -47,6 +47,11 @@ type Connection struct {
        Credentials map[string]string `xorm:"mediumtext json" 
json:"credentials"`
        // Agents is every agent id this connection's MCP server was written 
into.
        Agents []string `xorm:"mediumtext json" json:"agents"`
+       // EntryName is what the server is called inside those agents' own
+       // configuration files. It is kept here rather than looked up, because a
+       // connector dropped from the catalogue in a later release would 
otherwise
+       // leave a connection nobody can take back out of the agents it reached.
+       EntryName string `xorm:"varchar(200)" json:"entryName"`
        // Endpoint and Executable are the Gateway the written entries name. A
        // Gateway whose port changed, or that moved on disk, leaves every one 
of
        // those entries starting a program that is not there or reporting to a 
port
@@ -121,16 +126,25 @@ func decryptConnection(connection *Connection) {
 // Masked is a copy safe to send to the browser: every secret field is replaced
 // by ApiKeyMask, so a stored credential never leaves this process.
 func (connection *Connection) Masked() *Connection {
-       found, ok := connector.Get(connection.Name)
-       if !ok {
-               return connection
-       }
-
        masked := *connection
        masked.Credentials = map[string]string{}
        for key, value := range connection.Credentials {
                masked.Credentials[key] = value
        }
+
+       found, ok := connector.Get(connection.Name)
+       if !ok {
+               // Nothing left says which of these are secret, so every one of 
them is
+               // treated as one. A connector dropped from the catalogue must 
not be
+               // the thing that hands a stored credential to the browser.
+               for key, value := range masked.Credentials {
+                       if value != "" {
+                               masked.Credentials[key] = ApiKeyMask
+                       }
+               }
+               return &masked
+       }
+
        for _, key := range found.SecretKeys() {
                if masked.Credentials[key] != "" {
                        masked.Credentials[key] = ApiKeyMask
@@ -139,6 +153,14 @@ func (connection *Connection) Masked() *Connection {
        return &masked
 }
 
+// Orphaned reports a connection whose connector this build no longer has. It
+// can still be disconnected, which is what EntryName is kept for; nothing else
+// can be done with it.
+func (connection *Connection) Orphaned() bool {
+       _, ok := connector.Get(connection.Name)
+       return !ok
+}
+
 func GetConnections(owner string) ([]*Connection, error) {
        connections := []*Connection{}
        err := ormer.Engine.Where("owner = ?", owner).Find(&connections)
diff --git a/object/connection_install.go b/object/connection_install.go
index 03d5cd0..6365e56 100644
--- a/object/connection_install.go
+++ b/object/connection_install.go
@@ -69,6 +69,7 @@ func InstallConnection(connection *Connection, agentIds 
[]string) ([]*agentconfi
        planned = append(planned, removeFrom(connection, found.Server.Name, 
letGo)...)
 
        connection.Agents = installed(planned, agentIds)
+       connection.EntryName = found.Server.Name
        // Remembering which Gateway the entries name is what lets a later start
        // notice that they are stale, see EnsureConnectionsCurrent.
        if executable, err := agentpatch.GatewayExecutable(); err == nil {
@@ -132,12 +133,8 @@ func UninstallConnection(owner string, name string) 
([]*agentconfig.PlanItem, er
        if err != nil || connection == nil {
                return nil, err
        }
-       found, ok := connector.Get(name)
-       if !ok {
-               return nil, fmt.Errorf("no connector named %q", name)
-       }
 
-       planned := removeFrom(connection, found.Server.Name, connection.Agents)
+       planned := removeFrom(connection, entryNameOf(connection), 
connection.Agents)
        if err := DeleteConnection(owner, name); err != nil {
                return nil, err
        }
@@ -289,6 +286,12 @@ func EnsureConnectionsCurrent() error {
                if connection.Endpoint == gatewayUrl && connection.Executable 
== executable {
                        continue
                }
+               // A connector this build no longer has cannot be written 
again: there
+               // is no server to write. Saying so once a listing would be 
noise, so it
+               // is left for the page to show as an orphan the operator can 
disconnect.
+               if connection.Orphaned() {
+                       continue
+               }
                if _, err := InstallConnection(connection, connection.Agents); 
err != nil {
                        failed = fmt.Errorf("connection %s: %w", 
connection.GetId(), err)
                }
@@ -340,3 +343,17 @@ func recordProbe(connection *Connection, result 
*mcpproxy.ProbeResult, failure e
        }
        return failure
 }
+
+// entryNameOf is what this connection is called inside an agent's own
+// configuration. Rows written before EntryName was recorded fall back to the
+// catalogue, and then to the connection's own name, which is what the entry 
was
+// called for every connector whose server is named after it.
+func entryNameOf(connection *Connection) string {
+       if connection.EntryName != "" {
+               return connection.EntryName
+       }
+       if found, ok := connector.Get(connection.Name); ok {
+               return found.Server.Name
+       }
+       return connection.Name
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 4ca04c1..a22eeea 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -847,13 +847,17 @@
     "No agent found": "No agent on this machine takes MCP servers Gateway 
knows how to write.",
     "No such connector": "This Gateway has no connector called {name}.",
     "Not authorized": "Not authorized yet",
+    "Nothing matches": "Nothing here matches {query}.",
     "Office": "Office",
+    "Orphan connection": "{name} is connected, but this version of Gateway no 
longer has that connector",
+    "Orphan hint": "Its server is still written into {count} agents and its 
credentials are still stored here. Disconnecting undoes both.",
     "Personal access token": "Personal access token",
     "Pick at least one agent": "Pick at least one agent.",
     "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.",
     "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.",
     "Settings, Developer settings, Personal access tokens. Grant the scopes 
you want the agent to have, and no more.": "Settings, Developer settings, 
Personal access tokens. Grant the scopes you want the agent to have, and no 
more.",
     "Storage": "Storage",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 51ca04c..f3fe8f0 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -918,13 +918,17 @@
     "No agent found": "这台机器上没有Gateway能写入MCP配置的Agent。",
     "No such connector": "这个Gateway里没有叫{name}的连接器。",
     "Not authorized": "尚未授权",
+    "Nothing matches": "没有匹配「{query}」的应用。",
     "Office": "办公协同",
+    "Orphan connection": "{name}已连接,但当前版本的Gateway里已经没有这个连接器",
+    "Orphan hint": "它的服务仍写在{count}个Agent里,凭据也还存在这里。断开连接会把两者一并清除。",
     "Personal access token": "个人访问令牌",
     "Pick at least one agent": "至少勾选一个Agent。",
     "Professional": "专业服务",
     "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.": 
"读写仓库、issue和pull request,检索代码与提交历史,让Agent直接在你的项目上下文里工作。",
     "Run test": "测试",
+    "Search applications": "搜索应用",
     "Search places, plan driving and walking routes, check the weather, and 
turn an address into coordinates.": "搜索地点、规划驾车与步行路线、查询天气,以及把地址转成坐标。",
     "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拥有的权限。",
     "Storage": "网盘存储",
diff --git a/web/src/pages/ConnectionsPage.tsx 
b/web/src/pages/ConnectionsPage.tsx
index 9be31ee..59fb756 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 {ExternalLink, Plug} from "lucide-react";
+import {ExternalLink, Plug, Search, TriangleAlert} from "lucide-react";
 import i18next from "i18next";
 
 import * as Setting from "@/Setting";
@@ -29,7 +29,14 @@ import {Card, CardContent} from "@/components/ui/card";
 import {Input} from "@/components/ui/input";
 import {connectorText as text} from "@/lib/connectors";
 import {cn} from "@/lib/utils";
-import type {Account, ConnectorCatalog, ConnectorEntry, ConnectorTarget, 
ConnectorTool} from "@/types";
+import type {
+  Account,
+  ConnectorCatalog,
+  ConnectorEntry,
+  ConnectorTarget,
+  ConnectorTool,
+  OrphanConnection,
+} from "@/types";
 
 const CREDENTIAL_MASK = "***";
 
@@ -59,6 +66,7 @@ export default function ConnectionsPage({account}: {account: 
Account}) {
   const [catalog, setCatalog] = React.useState<ConnectorCatalog | null>(null);
   const [loading, setLoading] = React.useState(true);
   const [category, setCategory] = React.useState("");
+  const [query, setQuery] = React.useState("");
   const [editing, setEditing] = React.useState<ConnectorEntry | null>(null);
   // An "add this to Gateway" link lands here naming one application, and what
   // it asks for is this dialog rather than anything written on its behalf.
@@ -106,8 +114,22 @@ export default function ConnectionsPage({account}: 
{account: Account}) {
   }
 
   const connectors = catalog?.connectors ?? [];
-  const shown = category === "" ? connectors : connectors.filter(entry => 
entry.category === category);
   const categories = (catalog?.categories ?? []).filter(name => 
connectors.some(entry => entry.category === name));
+  // The search reads the wording actually on the card, so what somebody types
+  // after seeing it finds it again whichever language the page is in.
+  const needle = query.trim().toLowerCase();
+  const shown = connectors.filter(entry => {
+    if (category !== "" && entry.category !== category) {
+      return false;
+    }
+    if (needle === "") {
+      return true;
+    }
+    return [entry.id, text(entry.displayName), text(entry.description)]
+      .join(" ")
+      .toLowerCase()
+      .includes(needle);
+  });
 
   return (
     <PageContainer>
@@ -121,12 +143,33 @@ export default function ConnectionsPage({account}: 
{account: Account}) {
         {categories.map(name => (
           <FilterChip key={name} active={category === name} onClick={() => 
setCategory(name)}>
             {i18next.t(categoryLabels[name] ?? name)}
+            <span className="text-muted-foreground ml-1.5 text-xs">
+              {connectors.filter(entry => entry.category === name).length}
+            </span>
           </FilterChip>
         ))}
+
+        <div className="relative ml-auto w-full sm:w-56">
+          <Search className="text-muted-foreground pointer-events-none 
absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
+          <Input
+            value={query}
+            onChange={event => setQuery(event.target.value)}
+            placeholder={i18next.t("connector:Search applications")}
+            className="h-9 pl-8"
+          />
+        </div>
       </div>
 
+      {(catalog?.orphans ?? []).map(orphan => (
+        <OrphanRow key={orphan.name} account={account} orphan={orphan} 
onDone={reload} />
+      ))}
+
       {loading && connectors.length === 0 ? (
         <p className="text-muted-foreground 
text-sm">{i18next.t("general:Loading")}</p>
+      ) : shown.length === 0 ? (
+        <p className="text-muted-foreground text-sm">
+          {i18next.t("connector:Nothing matches").replace("{query}", 
query.trim())}
+        </p>
       ) : (
         <div className="grid gap-4 lg:grid-cols-2">
           {shown.map(entry => (
@@ -151,6 +194,55 @@ export default function ConnectionsPage({account}: 
{account: Account}) {
   );
 }
 
+/**
+ * A connection whose connector went away with a release. It is not a card: 
there
+ * is nothing to fill in and nothing to test, only an entry still sitting in 
some
+ * agents and a credential still stored here, both of which disconnecting 
undoes.
+ */
+function OrphanRow({
+  account,
+  orphan,
+  onDone,
+}: {
+  account: Account;
+  orphan: OrphanConnection;
+  onDone: () => void;
+}) {
+  const [busy, setBusy] = React.useState(false);
+
+  const remove = () => {
+    setBusy(true);
+    ConnectorBackend.disconnect(account.name, orphan.name)
+      .then(response => {
+        if (response.status === "ok") {
+          Setting.showMessage("success", i18next.t("connector:Disconnected"));
+          onDone();
+        } else {
+          Setting.showMessage("error", response.msg ?? "");
+        }
+      })
+      .catch(error => Setting.showMessage("error", error.message))
+      .finally(() => setBusy(false));
+  };
+
+  return (
+    <div className="border-destructive/40 bg-destructive/5 flex items-center 
gap-3 rounded-xl border p-4">
+      <TriangleAlert className="text-destructive size-5 shrink-0" />
+      <div className="min-w-0 flex-1 text-sm">
+        <div className="font-medium">
+          {i18next.t("connector:Orphan connection").replace("{name}", 
orphan.name)}
+        </div>
+        <p className="text-muted-foreground">
+          {i18next.t("connector:Orphan hint").replace("{count}", 
`${orphan.agents.length}`)}
+        </p>
+      </div>
+      <Button variant="outline" onClick={remove} disabled={busy}>
+        {i18next.t("connector:Disconnect")}
+      </Button>
+    </div>
+  );
+}
+
 function FilterChip({active, onClick, children}: {active: boolean; onClick: () 
=> void; children: React.ReactNode}) {
   return (
     <button
diff --git a/web/src/types.ts b/web/src/types.ts
index 0b77e17..d8a7ee0 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -1612,6 +1612,18 @@ export interface ConnectorCatalog {
   connectors: ConnectorEntry[];
   categories: string[];
   targets: ConnectorTarget[];
+  /** Connections whose connector this build no longer has. */
+  orphans: OrphanConnection[];
+}
+
+/**
+ * A connection left behind when a release dropped its connector. Nothing about
+ * it can be edited; it is shown so it can be taken back out of the agents it
+ * was written into.
+ */
+export interface OrphanConnection {
+  name: string;
+  agents: string[];
 }
 
 export interface ConnectRequest {


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to