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 e25dab1b477dad59007582d8b84098b8e87ad859
Author: Yang Luo <[email protected]>
AuthorDate: Sat Sep 5 19:26:14 2026 +0800

    feat(connections): carry connections in a snapshot and test one as it is 
made
---
 controllers/connector.go                 |  12 +++
 object/connection_install.go             |  36 ++++++-
 object/snapshot.go                       | 172 ++++++++++++++++++++++++++++---
 web/src/components/settings/snapshot.tsx |   3 +
 web/src/locales/en/data.json             |   5 +-
 web/src/locales/zh/data.json             |   5 +-
 web/src/pages/ConnectionsPage.tsx        |  11 +-
 web/src/types.ts                         |   2 +
 8 files changed, 229 insertions(+), 17 deletions(-)

diff --git a/controllers/connector.go b/controllers/connector.go
index 6205856..2059ff4 100644
--- a/controllers/connector.go
+++ b/controllers/connector.go
@@ -30,6 +30,7 @@ import (
        "github.com/apache/casbin-gateway/connector"
        "github.com/apache/casbin-gateway/mcpproxy"
        "github.com/apache/casbin-gateway/object"
+       "github.com/beego/beego"
 )
 
 // ConnectorEntry is one catalog card: the connector itself, plus whether this
@@ -198,6 +199,17 @@ func (c *ApiController) Connect() {
                c.ResponseError(err.Error())
                return
        }
+
+       // Testing here rather than waiting to be asked is what gives a 
connection
+       // its per-tool switches from the moment it is made. It runs beside the
+       // answer because installing a server from npm takes longer than anybody
+       // would wait on this button, and its result is read from the row later.
+       go func(owner string, name string) {
+               if _, err := object.TestConnection(owner, name); err != nil {
+                       beego.Error("testing connection", name, "after 
connecting failed:", err)
+               }
+       }(form.Owner, form.Name)
+
        c.ResponseOk(planned)
 }
 
diff --git a/object/connection_install.go b/object/connection_install.go
index a6a8f1a..03d5cd0 100644
--- a/object/connection_install.go
+++ b/object/connection_install.go
@@ -65,7 +65,8 @@ func InstallConnection(connection *Connection, agentIds 
[]string) ([]*agentconfi
                planned = append(planned, items...)
        }
 
-       planned = append(planned, removeFrom(connection, found.Server.Name, 
dropped(connection.Agents, agentIds))...)
+       letGo := dropped(connection.Agents, agentIds)
+       planned = append(planned, removeFrom(connection, found.Server.Name, 
letGo)...)
 
        connection.Agents = installed(planned, agentIds)
        // Remembering which Gateway the entries name is what lets a later start
@@ -79,6 +80,7 @@ func InstallConnection(connection *Connection, agentIds 
[]string) ([]*agentconfi
        if err := SaveConnection(connection); err != nil {
                return nil, err
        }
+       revokeUnusedTokens(connection.Owner, letGo)
        return planned, nil
 }
 
@@ -139,9 +141,41 @@ func UninstallConnection(owner string, name string) 
([]*agentconfig.PlanItem, er
        if err := DeleteConnection(owner, name); err != nil {
                return nil, err
        }
+       revokeUnusedTokens(owner, connection.Agents)
        return planned, nil
 }
 
+// revokeUnusedTokens gives back the loopback credential of every agent this
+// owner no longer reaches any connection from. The credential is issued per
+// agent rather than per connection, so it can only go when the last connection
+// in that agent does; leaving it would be a live token nothing holds.
+//
+// It is not the credential an agent's monitoring uses: that one is issued
+// against the installation's path, and this one against no path at all, so 
they
+// are separate entries and revoking here leaves monitoring alone.
+func revokeUnusedTokens(owner string, agentIds []string) {
+       if len(agentIds) == 0 {
+               return
+       }
+       connections, err := GetConnections(owner)
+       if err != nil {
+               return
+       }
+
+       remaining := map[string]bool{}
+       for _, connection := range connections {
+               for _, agentId := range connection.Agents {
+                       remaining[agentId] = true
+               }
+       }
+       for _, agentId := range agentIds {
+               if remaining[agentId] {
+                       continue
+               }
+               _ = agentpatch.RevokeIngestToken(agentpatch.Target{AgentId: 
agentId, Owner: owner})
+       }
+}
+
 // ResolveConnection is what the proxy asks for when an agent starts it: the
 // server behind one connection, with its credentials filled in. It is answered
 // over loopback to a process this Gateway's own configuration launched, and 
the
diff --git a/object/snapshot.go b/object/snapshot.go
index bc84a28..6700613 100644
--- a/object/snapshot.go
+++ b/object/snapshot.go
@@ -19,6 +19,7 @@ import (
        "fmt"
        "sort"
 
+       "github.com/apache/casbin-gateway/connector"
        "github.com/apache/casbin-gateway/util"
        "github.com/apache/casbin-gateway/version"
 )
@@ -30,11 +31,12 @@ const SnapshotVersion = 1
 // The sections a snapshot is made of. They are named rather than positional
 // because a snapshot is often taken, and restored, one section at a time.
 const (
-       SectionProviders  = "providers"
-       SectionAgents     = "agents"
-       SectionProbeCases = "probeCases"
-       SectionLlmPrices  = "llmPrices"
-       SectionSetting    = "setting"
+       SectionProviders   = "providers"
+       SectionConnections = "connections"
+       SectionAgents      = "agents"
+       SectionProbeCases  = "probeCases"
+       SectionLlmPrices   = "llmPrices"
+       SectionSetting     = "setting"
 )
 
 // How an import treats a row that is already there.
@@ -62,12 +64,17 @@ const (
 // SnapshotScope is which sections a snapshot carries, and whether the
 // credentials inside them come with it.
 type SnapshotScope struct {
-       Providers  bool `json:"providers"`
-       Agents     bool `json:"agents"`
-       ProbeCases bool `json:"probeCases"`
-       LlmPrices  bool `json:"llmPrices"`
-       Setting    bool `json:"setting"`
-       // Secrets carries the provider API keys and the secret half of the 
settings.
+       Providers bool `json:"providers"`
+       // Connections are the applications this machine is connected to. They 
are
+       // their own section rather than part of the agents, because a 
connection
+       // belongs to the account and only names the agents it was written into.
+       Connections bool `json:"connections"`
+       Agents      bool `json:"agents"`
+       ProbeCases  bool `json:"probeCases"`
+       LlmPrices   bool `json:"llmPrices"`
+       Setting     bool `json:"setting"`
+       // Secrets carries the provider API keys, the credentials of every
+       // connection, and the secret half of the settings.
        // A snapshot without them describes a configuration that cannot yet 
serve
        // traffic: every key has to be typed again on the machine it lands on.
        Secrets bool `json:"secrets"`
@@ -76,11 +83,15 @@ type SnapshotScope struct {
 // FullScope is everything, which is what a backup takes: a backup that leaves
 // the keys out is not one anybody can restore from.
 func FullScope() SnapshotScope {
-       return SnapshotScope{Providers: true, Agents: true, ProbeCases: true, 
LlmPrices: true, Setting: true, Secrets: true}
+       return SnapshotScope{
+               Providers: true, Connections: true, Agents: true,
+               ProbeCases: true, LlmPrices: true, Setting: true, Secrets: true,
+       }
 }
 
 func (scope SnapshotScope) isEmpty() bool {
-       return !scope.Providers && !scope.Agents && !scope.ProbeCases && 
!scope.LlmPrices && !scope.Setting
+       return !scope.Providers && !scope.Connections && !scope.Agents &&
+               !scope.ProbeCases && !scope.LlmPrices && !scope.Setting
 }
 
 // Snapshot is the whole document, and the file an export downloads. It holds
@@ -101,6 +112,7 @@ type Snapshot struct {
        Setting *Setting      `json:"setting,omitempty"`
 
        Providers      []*Provider      `json:"providers,omitempty"`
+       Connections    []*Connection    `json:"connections,omitempty"`
        Agents         []*Agent         `json:"agents,omitempty"`
        AgentInstances []*AgentInstance `json:"agentInstances,omitempty"`
        // AgentPermissions travel with the agents: what an agent is allowed to 
ask
@@ -114,6 +126,7 @@ type Snapshot struct {
 // without reading the whole file back to the browser.
 type SnapshotCounts struct {
        Providers      int `json:"providers"`
+       Connections    int `json:"connections"`
        Agents         int `json:"agents"`
        AgentInstances int `json:"agentInstances"`
        ProbeCases     int `json:"probeCases"`
@@ -124,6 +137,7 @@ type SnapshotCounts struct {
 func (snapshot *Snapshot) Counts() SnapshotCounts {
        counts := SnapshotCounts{
                Providers:      len(snapshot.Providers),
+               Connections:    len(snapshot.Connections),
                Agents:         len(snapshot.Agents),
                AgentInstances: len(snapshot.AgentInstances),
                ProbeCases:     len(snapshot.ProbeCases),
@@ -201,6 +215,14 @@ func BuildSnapshot(scope SnapshotScope, reason string) 
(*Snapshot, error) {
                snapshot.Providers = providers
        }
 
+       if scope.Connections {
+               connections, err := getAllConnections()
+               if err != nil {
+                       return nil, err
+               }
+               snapshot.Connections = connections
+       }
+
        if scope.Agents {
                agents, err := GetAgents()
                if err != nil {
@@ -269,6 +291,16 @@ func redactSnapshot(snapshot *Snapshot) {
                }
        }
 
+       for _, connection := range snapshot.Connections {
+               found, ok := connector.Get(connection.Name)
+               if !ok {
+                       continue
+               }
+               for _, key := range found.SecretKeys() {
+                       delete(connection.Credentials, key)
+               }
+       }
+
        if snapshot.Setting != nil {
                snapshot.Setting.ApiKeyEncryptionKey = ""
                snapshot.Setting.ClientSecret = ""
@@ -312,6 +344,12 @@ func ImportSnapshot(snapshot *Snapshot, scope 
SnapshotScope, mode string, dryRun
                        return nil, err
                }
        }
+       // Connections name the agents they were written into, so they follow 
them.
+       if scope.Connections && snapshot.Scope.Connections {
+               if err := importConnections(snapshot, mode, dryRun, report); 
err != nil {
+                       return nil, err
+               }
+       }
        if scope.ProbeCases && snapshot.Scope.ProbeCases {
                if err := importProbeCases(snapshot, mode, dryRun, report); err 
!= nil {
                        return nil, err
@@ -409,6 +447,114 @@ func importProviders(snapshot *Snapshot, mode string, 
dryRun bool, report *Impor
        return nil
 }
 
+// importConnections writes the connections a snapshot carries. Nothing is
+// written into an agent's own configuration here: the row keeps the agents it
+// names but forgets which Gateway wrote it, so EnsureConnectionsCurrent puts 
the
+// entries in with this machine's port and executable the next time the agents
+// are listed. That is also what makes a snapshot restorable onto a machine
+// whose Gateway lives somewhere else entirely.
+func importConnections(snapshot *Snapshot, mode string, dryRun bool, report 
*ImportReport) error {
+       stored, err := getAllConnections()
+       if err != nil {
+               return err
+       }
+
+       existing := map[string]*Connection{}
+       for _, connection := range stored {
+               existing[connection.GetId()] = connection
+       }
+
+       named := map[string]bool{}
+       for _, incoming := range snapshot.Connections {
+               connection := *incoming
+               id := connection.GetId()
+               named[id] = true
+
+               current, found := existing[id]
+               if found && mode == ImportMerge {
+                       report.record(SectionConnections, id, ChangeSkip, "this 
application is already connected here")
+                       continue
+               }
+               if _, known := connector.Get(connection.Name); !known {
+                       report.record(SectionConnections, id, ChangeSkip, "this 
Gateway has no connector by that name")
+                       continue
+               }
+
+               change := ChangeAdd
+               if found {
+                       change = ChangeUpdate
+               }
+               if dryRun {
+                       report.record(SectionConnections, id, change, 
credentiallessDetail(&connection))
+                       continue
+               }
+
+               connection.Credentials = keptCredentials(&connection, current)
+               // The entries are written again from here, not carried over 
from
+               // wherever this snapshot was taken.
+               connection.Endpoint, connection.Executable = "", ""
+               detail := credentiallessDetail(&connection)
+               if err := saveWithoutRendering(&connection); err != nil {
+                       report.record(SectionConnections, id, ChangeFail, 
err.Error())
+                       continue
+               }
+               report.record(SectionConnections, id, change, detail)
+       }
+
+       if mode != ImportReplace {
+               return nil
+       }
+
+       for _, connection := range stored {
+               id := connection.GetId()
+               if named[id] {
+                       continue
+               }
+               if dryRun {
+                       report.record(SectionConnections, id, ChangeDelete, "")
+                       continue
+               }
+               if _, err := UninstallConnection(connection.Owner, 
connection.Name); err != nil {
+                       report.record(SectionConnections, id, ChangeFail, 
err.Error())
+                       continue
+               }
+               report.record(SectionConnections, id, ChangeDelete, "")
+       }
+       return nil
+}
+
+// keptCredentials layers what the snapshot carries over what is already 
stored.
+// A snapshot taken without secrets carries none, and that means "leave them
+// alone" rather than "clear them", the same way an empty provider key does.
+func keptCredentials(incoming *Connection, current *Connection) 
map[string]string {
+       credentials := map[string]string{}
+       if current != nil {
+               for key, value := range current.Credentials {
+                       credentials[key] = value
+               }
+       }
+       for key, value := range incoming.Credentials {
+               if value == "" {
+                       continue
+               }
+               credentials[key] = value
+       }
+       return credentials
+}
+
+// credentiallessDetail marks a connection that arrives unusable, which is the
+// one thing somebody has to finish by hand after importing a redacted 
snapshot.
+func credentiallessDetail(connection *Connection) string {
+       found, ok := connector.Get(connection.Name)
+       if !ok {
+               return ""
+       }
+       if _, err := found.Render(connection.Credentials); err != nil {
+               return "the snapshot carries no credentials for it"
+       }
+       return ""
+}
+
 // keylessDetail marks a provider that arrives with no key, which is the one
 // thing somebody has to finish by hand after importing a redacted snapshot.
 func keylessDetail(provider *Provider) string {
diff --git a/web/src/components/settings/snapshot.tsx 
b/web/src/components/settings/snapshot.tsx
index 9cae4ed..0476371 100644
--- a/web/src/components/settings/snapshot.tsx
+++ b/web/src/components/settings/snapshot.tsx
@@ -22,6 +22,7 @@ import type {ImportChange, ImportReport, Snapshot, 
SnapshotCounts, SnapshotScope
 /** The sections a snapshot is made of, in the order they are worth reading. */
 export const SNAPSHOT_SECTIONS = [
   {key: "providers", label: "setting:Providers"},
+  {key: "connections", label: "connector:Connections"},
   {key: "agents", label: "setting:Agent routing"},
   {key: "probeCases", label: "setting:Probe cases"},
   {key: "llmPrices", label: "setting:Model prices"},
@@ -32,6 +33,7 @@ export type SnapshotSection = (typeof 
SNAPSHOT_SECTIONS)[number]["key"];
 
 export const FULL_SCOPE: SnapshotScope = {
   providers: true,
+  connections: true,
   agents: true,
   probeCases: true,
   llmPrices: true,
@@ -124,6 +126,7 @@ export function countsOf(snapshot: Snapshot | null): 
SnapshotCounts {
   const length = (section: string) => (snapshot?.[section] as unknown[] | 
undefined)?.length ?? 0;
   return {
     providers: length("providers"),
+    connections: length("connections"),
     agents: length("agents"),
     agentInstances: length("agentInstances"),
     probeCases: length("probeCases"),
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index bb533c6..fb0c83e 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -834,14 +834,16 @@
     "Give an agent one directory to read and write. Nothing outside the folder 
you name is reachable through it.": "Give an agent one directory to read and 
write. Nothing outside the folder you name is reachable through it.",
     "Hand Chrome DevTools to an agent: record performance profiles, inspect 
the DOM, read network requests and console errors on a real page.": "Hand 
Chrome DevTools to an agent: record performance profiles, inspect the DOM, read 
network requests and console errors on a real page.",
     "Install into": "Install into",
-    "Install into hint": "The agents this connection is written into. 
Unticking one takes the server out of it.",
+    "Install into hint": "The agents this connection is written into. 
Unticking one takes the server out of it. Each of them reaches the application 
through Gateway, so a connection works only while Gateway is running.",
     "Integration token": "Integration token",
     "Lark": "Lark",
+    "Last tested": "Last tested {time}",
     "Life": "Life",
     "Loading": "Loading...",
     "Local files": "Local files",
     "Mail": "Mail",
     "Manage": "Manage",
+    "Never tested": "Not tested yet",
     "No agent found": "No agent on this machine takes MCP servers Gateway 
knows how to write.",
     "Not authorized": "Not authorized yet",
     "Office": "Office",
@@ -858,6 +860,7 @@
     "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.",
     "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.",
     "Tool count_one": "1 tool",
     "Tool count_other": "{{count}} tools",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 5aa565c..00d08e8 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -905,14 +905,16 @@
     "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、读取网络请求和控制台报错,用于排查真实页面的问题。",
     "Install into": "装入哪些Agent",
-    "Install into hint": "这条连接会写入下面勾选的Agent,取消勾选会把对应的MCP服务从它的配置里删掉。",
+    "Install into hint": 
"这条连接会写入下面勾选的Agent,取消勾选会把对应的MCP服务从它的配置里删掉。它们都通过Gateway访问这个应用,所以Gateway没运行时连接不可用。",
     "Integration token": "集成令牌",
     "Lark": "飞书",
+    "Last tested": "上次测试于{time}",
     "Life": "生活服务",
     "Loading": "加载中...",
     "Local files": "本地文件",
     "Mail": "邮件服务",
     "Manage": "管理",
+    "Never tested": "尚未测试",
     "No agent found": "这台机器上没有Gateway能写入MCP配置的Agent。",
     "Not authorized": "尚未授权",
     "Office": "办公协同",
@@ -929,6 +931,7 @@
     "Test hint": "启动这个服务并问它提供哪些工具。首次运行要先安装,会比较慢。",
     "Test result": "{server}已响应,提供{count}个工具。",
     "Testing": "测试中...",
+    "Testing in background": "已连接。正在测试它,好给每个工具生成开关。",
     "The agent can read and write this directory and everything under it.": 
"Agent只能读写这个目录及其子目录。",
     "Tool count_other": "{{count}}个工具",
     "Unverified": "未验证",
diff --git a/web/src/pages/ConnectionsPage.tsx 
b/web/src/pages/ConnectionsPage.tsx
index 08b9a73..e613bfc 100644
--- a/web/src/pages/ConnectionsPage.tsx
+++ b/web/src/pages/ConnectionsPage.tsx
@@ -324,8 +324,9 @@ function ConnectDialog({
     ConnectorBackend.connect({owner: account.name, name: entry.id, 
credentials: credentials, agents: agents})
       .then(response => {
         if (response.status === "ok") {
-          Setting.showMessage("success", i18next.t("connector:Connected"));
+          Setting.showMessage("success", i18next.t("connector:Testing in 
background"));
           onDone();
+          window.setTimeout(onDone, 12000);
         } else {
           Setting.showMessage("error", response.msg ?? "");
         }
@@ -439,6 +440,9 @@ function ConnectDialog({
               {testing ? i18next.t("connector:Testing") : 
i18next.t("connector:Run test")}
             </Button>
             <div className="min-w-0 flex-1 pt-1.5 text-sm">
+              {!testing && !probe.error && !probe.tools ? (
+                <span 
className="text-muted-foreground">{i18next.t("connector:Never tested")}</span>
+              ) : null}
               {probe.error ? (
                 <span className="text-destructive 
break-words">{probe.error}</span>
               ) : probe.tools ? (
@@ -449,6 +453,11 @@ function ConnectDialog({
                     .replace("{count}", `${probe.tools.length}`)}
                 </span>
               ) : null}
+              {!testing && entry.probedTime && !probe.error ? (
+                <span className="text-muted-foreground ml-2 text-xs">
+                  {i18next.t("connector:Last tested").replace("{time}", new 
Date(entry.probedTime).toLocaleString())}
+                </span>
+              ) : null}
             </div>
           </div>
           {probe.tools && probe.tools.length > 0 ? (
diff --git a/web/src/types.ts b/web/src/types.ts
index 2c66c55..d171fca 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -944,6 +944,7 @@ export type BackupMode = "auto" | "off";
  *  come with it. */
 export interface SnapshotScope {
   providers: boolean;
+  connections: boolean;
   agents: boolean;
   probeCases: boolean;
   llmPrices: boolean;
@@ -953,6 +954,7 @@ export interface SnapshotScope {
 
 export interface SnapshotCounts {
   providers: number;
+  connections: number;
   agents: number;
   agentInstances: number;
   probeCases: number;


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

Reply via email to