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 6345f053d72991870aeffc9cd554c41cd4393f54 Author: Yang Luo <[email protected]> AuthorDate: Fri Sep 4 18:00:10 2026 +0800 feat(agents): add a version management page --- README.md | 3 +- README_zh.md | 3 +- agentinstall/job.go | 3 + agentinstall/plan.go | 157 +++++++-- agentinstall/versions.go | 545 ++++++++++++++++++++++++++++++ controllers/agent.go | 3 + controllers/agent_install.go | 98 +++++- routers/router.go | 4 + web/src/App.tsx | 5 + web/src/backend/AgentBackend.ts | 46 ++- web/src/components/AgentGridCard.tsx | 6 + web/src/components/AgentVersionDialog.tsx | 268 +++++++++++++++ web/src/lib/agents.ts | 105 +++++- web/src/locales/en/data.json | 24 ++ web/src/locales/zh/data.json | 24 ++ web/src/nav.ts | 8 + web/src/pages/AgentDetailPage.tsx | 34 +- web/src/pages/AgentVersionsPage.tsx | 329 ++++++++++++++++++ web/src/pages/AgentsPage.tsx | 72 +++- web/src/pages/HomePage.tsx | 10 +- web/src/types.ts | 39 ++- 21 files changed, 1733 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 97e5c8d..02bc3ed 100644 --- a/README.md +++ b/README.md @@ -198,7 +198,8 @@ Gateway installed some other way, or in a directory it cannot write to, says so | Page | What you get | What it needs | | --- | --- | --- | -| **Agents** | Every AI coding agent installed on this machine — Claude Code, Codex CLI, Cursor, the Gemini CLI, opencode and more — four cards to a row, each naming the account it is signed in to, the provider it answers to, what it has spent — day by day over the last month, wherever its own transcripts carry that much — and whether it is running right now. Start or stop one from its card, or run several **instances** of the same agent at once, each with a state directory and an accoun [...] +| **Agents** | Every AI coding agent installed on this machine — Claude Code, Codex CLI, Cursor, the Gemini CLI, opencode and more — four cards to a row, each naming the account it is signed in to, the provider it answers to, what it has spent — day by day over the last month, wherever its own transcripts carry that much — and whether it is running right now. Start or stop one from its card, or run several **instances** of the same agent at once, each with a state directory and an accoun [...] +| **Agent versions** | Every agent Gateway knows in one table: the build on this machine, the release its package manager publishes, and whether the two differ. Install one, upgrade it, put it back on an older release, or remove it, from the row it is on. | Nothing | | **Skills, MCP & Prompts** | Every skill, MCP server and instruction file of every agent in one table. Install skills from a GitHub repository, a `.zip` or `.tar.gz`, or a folder on this machine, into one agent or several at once. Add an MCP server the same way, edit the instructions an agent reads before every session, open one, delete it, or copy it into another agent. | Nothing | | **Sessions** | Every session those agents have had, read from the transcripts they leave on disk: the whole conversation, message by message. How many there are, how many ran today and how many came from a transcript rather than from monitoring, then filtered down to one agent or one of those two sources. | Nothing | | **Activity** | What a monitored agent is doing as it does it — each tool call, its target and how long it took. | Monitoring on for an agent | diff --git a/README_zh.md b/README_zh.md index 1e040e5..1afa5f3 100644 --- a/README_zh.md +++ b/README_zh.md @@ -196,7 +196,8 @@ Gateway 会在自己的窗口里打开 —— 不用登录:它只服务本机 | 页面 | 你能得到什么 | 需要什么 | | --- | --- | --- | -| **Agents** | 本机安装的每一个 AI 编程 Agent —— Claude Code、Codex CLI、Cursor、Gemini CLI、opencode 等等 —— 一行四张卡片,每张写明它登录的是哪个账号、接的是哪个 Provider、花了多少(会话记录够长的话,还会按天画出最近一个月的曲线)、此刻是不是在跑。可以在卡片上直接启动或停止,也可以给同一个 Agent 开多个**实例**,每个实例有自己的状态目录和自己的登录账号,同时运行。本机没装的 Agent 也会列出来,可以直接用宿主机已有的包管理器安装或升级。 | 无 | +| **Agents** | 本机安装的每一个 AI 编程 Agent —— Claude Code、Codex CLI、Cursor、Gemini CLI、opencode 等等 —— 一行四张卡片,每张写明它登录的是哪个账号、接的是哪个 Provider、花了多少(会话记录够长的话,还会按天画出最近一个月的曲线)、此刻是不是在跑。可以在卡片上直接启动或停止,也可以给同一个 Agent 开多个**实例**,每个实例有自己的状态目录和自己的登录账号,同时运行。本机没装的 Agent 也会列出来,可以直接用宿主机已有的包管理器安装或升级。同一个包管理器还会告诉你哪些安装有新版本可用,可以切换到它仍在发布的任意版本 —— 新版本出了问题就退回旧版 —— 也可以卸载,Agent 自己的登录状态和历史记录会原样保留。 | 无 | +| **Agent versions** | Gateway 认识的每一个 Agent 都在这张表里:本机装的是哪个版本、包管理器发布到了哪个版本、两者差在哪里。安装、升级、退回旧版本、卸载,都在这一行上完成。 | 无 | | **Skills, MCP & Prompts** | 所有 Agent 的所有技能、MCP 服务器和提示词文件汇总在一张表里。可以从 GitHub 仓库、`.zip` / `.tar.gz` 压缩包,或本机的一个目录安装技能,一次装进一个或多个 Agent。MCP 服务器也一样,还能编辑某个 Agent 每次会话前读到的提示词,或者打开、删除、复制到另一个 Agent。 | 无 | | **Sessions** | 这些 Agent 的每一次会话,从它们留在磁盘上的会话记录里读出来:完整的对话,一条一条。一共多少次、今天跑了多少次、其中多少条是从会话记录里读来而不是监控到的,都在页头;再按 Agent 或按这两种来源筛。 | 无 | | **Activity** | 被监控的 Agent 正在做什么:每一次工具调用、调用的目标,以及耗时。 | 给某个 Agent 打开监控 | diff --git a/agentinstall/job.go b/agentinstall/job.go index 9b5c876..fab3a55 100644 --- a/agentinstall/job.go +++ b/agentinstall/job.go @@ -46,6 +46,8 @@ type Job struct { Action string `json:"action"` Manager string `json:"manager"` Command string `json:"command"` + // Version is the release the job pinned, empty for the current one. + Version string `json:"version,omitempty"` Running bool `json:"running"` // Ok is the outcome of a finished job, false while one runs. Ok bool `json:"ok"` @@ -86,6 +88,7 @@ func Start(plan Plan) (Job, error) { Action: plan.Action, Manager: plan.Manager, Command: plan.Command, + Version: plan.Version, Running: true, StartTime: now(), }} diff --git a/agentinstall/plan.go b/agentinstall/plan.go index 972ff44..77cdcb9 100644 --- a/agentinstall/plan.go +++ b/agentinstall/plan.go @@ -29,8 +29,10 @@ import ( // The actions a plan describes. const ( - ActionInstall = "install" - ActionUpgrade = "upgrade" + ActionInstall = "install" + ActionUpgrade = "upgrade" + ActionDowngrade = "downgrade" + ActionUninstall = "uninstall" ) // The package managers Gateway can drive. They are also the install methods a @@ -48,7 +50,10 @@ type Plan struct { Action string `json:"action"` Manager string `json:"manager,omitempty"` // Command is the command line as it will run, shown before it does. - Command string `json:"command,omitempty"` + Command string `json:"command,omitempty"` + // Version is the release a pinned install asks for, empty for the plans + // that take whatever the manager calls latest. + Version string `json:"version,omitempty"` Available bool `json:"available"` Detail string `json:"detail,omitempty"` // InstallUrl is the vendor's own page, which is all that is left for an @@ -62,8 +67,23 @@ type Plan struct { // InstallPlan picks the manager that can install an agent this host does not // have: the one it is published on, whose own program is on PATH. func InstallPlan(agentId string) Plan { + return InstallVersionPlan(agentId, "") +} + +// InstallVersionPlan installs a chosen release rather than the current one, for +// a machine that wants the version the rest of its fleet is on. +func InstallVersionPlan(agentId string, version string) Plan { + if version != "" && !IsValidVersion(version) { + return Plan{AgentId: agentId, Action: ActionInstall, Version: version, + InstallUrl: agent.PackagesOf(agentId).InstallUrl, + Detail: "\"" + version + "\" is not a version number"} + } + return installPlan(agentId, version) +} + +func installPlan(agentId string, version string) Plan { packages := agent.PackagesOf(agentId) - plan := Plan{AgentId: agentId, Action: ActionInstall, InstallUrl: packages.InstallUrl} + plan := Plan{AgentId: agentId, Action: ActionInstall, Version: version, InstallUrl: packages.InstallUrl} for _, manager := range installOrder() { switch manager { @@ -72,10 +92,12 @@ func InstallPlan(agentId string) Plan { continue } if program := lookup("npm"); program != "" { - return fill(plan, ManagerNpm, program, "install", "-g", packages.Npm+"@latest") + return fill(plan, ManagerNpm, program, "install", "-g", packages.Npm+"@"+requestedRelease(version)) } case ManagerHomebrew: - if packages.HomebrewCask == "" { + // A cask names one version, so a pinned install has to go somewhere + // that keeps the older ones. + if packages.HomebrewCask == "" || version != "" { continue } if program := lookup("brew"); program != "" { @@ -86,7 +108,11 @@ func InstallPlan(agentId string) Plan { continue } if program := lookup("winget"); program != "" { - return fill(plan, ManagerWinget, program, append([]string{"install", "--id", packages.Winget, "--exact"}, wingetFlags...)...) + args := []string{"install", "--id", packages.Winget, "--exact"} + if version != "" { + args = append(args, "--version", version) + } + return fill(plan, ManagerWinget, program, append(args, wingetFlags...)...) } } } @@ -98,43 +124,127 @@ func InstallPlan(agentId string) Plan { // UpgradePlan upgrades an installation in place, through the manager that put // it there: another one would install a second copy beside it. func UpgradePlan(agentId string, installMethod string) Plan { + return managerPlan(agentId, installMethod, ActionUpgrade, "") +} + +// VersionPlan moves an installation onto one published release, through the +// manager that owns its tree. Going back is what it is for: an agent whose new +// release broke something is pinned to the one before it. +func VersionPlan(agentId string, installMethod string, version string, current string) Plan { + action := ActionUpgrade + if CompareVersions(version, current) < 0 { + action = ActionDowngrade + } + + plan := managerPlan(agentId, installMethod, action, version) + if !IsValidVersion(version) { + return Plan{AgentId: agentId, Action: action, Version: version, + InstallUrl: plan.InstallUrl, Detail: "\"" + version + "\" is not a version number"} + } + return plan +} + +// UninstallPlan removes an installation with the manager that installed it. +// Only the program goes: an agent's own state directory holds its sign-in and +// its history, and a reinstall finds them where it left them. +func UninstallPlan(agentId string, installMethod string) Plan { + return managerPlan(agentId, installMethod, ActionUninstall, "") +} + +// managerPlan builds the command for one action against an installation whose +// manager is known. Everything but the version comes from the fingerprint. +func managerPlan(agentId string, installMethod string, action string, version string) Plan { packages := agent.PackagesOf(agentId) - plan := Plan{AgentId: agentId, Action: ActionUpgrade, InstallUrl: packages.InstallUrl} + plan := Plan{AgentId: agentId, Action: action, Version: version, InstallUrl: packages.InstallUrl} switch installMethod { case ManagerNpm: if packages.Npm == "" { break } - if program := lookup("npm"); program != "" { - return fill(plan, ManagerNpm, program, "install", "-g", packages.Npm+"@latest") + program := lookup("npm") + if program == "" { + plan.Detail = "npm installed this agent but is not on Gateway's PATH" + return plan } - plan.Detail = "npm installed this agent but is not on Gateway's PATH" - return plan + if action == ActionUninstall { + return fill(plan, ManagerNpm, program, "uninstall", "-g", packages.Npm) + } + return fill(plan, ManagerNpm, program, "install", "-g", packages.Npm+"@"+requestedRelease(version)) case ManagerHomebrew: if packages.HomebrewCask == "" { break } - if program := lookup("brew"); program != "" { - return fill(plan, ManagerHomebrew, program, "upgrade", "--cask", packages.HomebrewCask) + program := lookup("brew") + if program == "" { + plan.Detail = "Homebrew installed this agent but is not on Gateway's PATH" + return plan } - plan.Detail = "Homebrew installed this agent but is not on Gateway's PATH" + switch action { + case ActionUninstall: + return fill(plan, ManagerHomebrew, program, "uninstall", "--cask", packages.HomebrewCask) + case ActionUpgrade: + if version == "" { + return fill(plan, ManagerHomebrew, program, "upgrade", "--cask", packages.HomebrewCask) + } + } + // A cask carries one version, the current one, so there is no older + // release to ask it for. + plan.Detail = "Homebrew only installs the version its cask names; use the vendor's own downloads for an older one" return plan case ManagerWinget: if packages.Winget == "" { break } - if program := lookup("winget"); program != "" { - return fill(plan, ManagerWinget, program, append([]string{"upgrade", "--id", packages.Winget, "--exact"}, wingetFlags...)...) + program := lookup("winget") + if program == "" { + plan.Detail = "winget installed this agent but is not on Gateway's PATH" + return plan } - plan.Detail = "winget installed this agent but is not on Gateway's PATH" - return plan + exact := []string{"--id", packages.Winget, "--exact"} + switch action { + case ActionUninstall: + return fill(plan, ManagerWinget, program, append(append([]string{"uninstall"}, exact...), wingetUninstallFlags...)...) + case ActionUpgrade: + if version == "" { + return fill(plan, ManagerWinget, program, append(append([]string{"upgrade"}, exact...), wingetFlags...)...) + } + } + // An older version needs the install verb: upgrade refuses to move back. + return fill(plan, ManagerWinget, program, + append(append([]string{"install"}, append(exact, "--version", version)...), wingetFlags...)...) } - plan.Detail = "this agent was installed as \"" + installMethod + "\", which Gateway cannot upgrade; use its own updater" + plan.Detail = "this agent was installed as \"" + installMethod + "\", which Gateway cannot " + action + "; use its own updater" return plan } +// requestedRelease is the npm tag or version an install asks for. +func requestedRelease(version string) string { + if version == "" { + return "latest" + } + return version +} + +// IsValidVersion accepts what a package manager would recognise as a release +// and nothing else: a version reaches here from a request, and it is the one +// part of a command that Gateway does not build from a fingerprint. +func IsValidVersion(version string) bool { + if version == "" || len(version) > 64 || (version[0] < '0' || version[0] > '9') { + return false + } + for i := 0; i < len(version); i++ { + c := version[i] + digit := c >= '0' && c <= '9' + letter := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + if !digit && !letter && c != '.' && c != '-' && c != '+' && c != '_' { + return false + } + } + return true +} + // wingetFlags keep an install unattended: winget otherwise stops on a licence // prompt no one is there to answer. var wingetFlags = []string{ @@ -144,6 +254,13 @@ var wingetFlags = []string{ "--disable-interactivity", } +// wingetUninstallFlags are the ones an uninstall takes: it has no package +// agreement to accept, and rejects the flag that would accept one. +var wingetUninstallFlags = []string{ + "--silent", + "--disable-interactivity", +} + // installOrder prefers the manager that installs into the user's own account, // since Gateway does not run as an administrator: npm with a user prefix on // Windows and Linux, Homebrew on macOS, where it owns its own tree. diff --git a/agentinstall/versions.go b/agentinstall/versions.go new file mode 100644 index 0000000..7e9c7ec --- /dev/null +++ b/agentinstall/versions.go @@ -0,0 +1,545 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package agentinstall + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os/exec" + "runtime" + "sort" + "strings" + "sync" + "time" + + "github.com/apache/casbin-gateway/agent" + "github.com/apache/casbin-gateway/proxy" +) + +const ( + // catalogTTL is how long a registry answer is trusted. Every card on the + // agents page asks for one, and the registries publish a few times a day. + catalogTTL = 30 * time.Minute + catalogTimeout = 20 * time.Second + // maxVersions bounds the list handed to the page: npm keeps every release + // an agent ever published, and a picker cannot use a thousand of them. + maxVersions = 60 +) + +// Catalog is what one agent's package manager publishes: the release it calls +// current, and the ones a downgrade can go back to. +type Catalog struct { + AgentId string `json:"agentId"` + Manager string `json:"manager,omitempty"` + Package string `json:"package,omitempty"` + Latest string `json:"latest,omitempty"` + // Versions are the published releases, newest first. Empty for a manager + // that only ever names the current one. + Versions []string `json:"versions"` + // CommandTemplate is the command a version change would run, with the + // version left as "{version}". Empty for a manager that installs only the + // release its own index names, which is every Homebrew cask. + CommandTemplate string `json:"commandTemplate,omitempty"` + // Detail says why there is nothing to list, when there is nothing. + Detail string `json:"detail,omitempty"` + CheckedAt string `json:"checkedAt,omitempty"` +} + +// versionPlaceholder stands in for the release in a command template. +const versionPlaceholder = "{version}" + +// Update is one installation measured against what its manager publishes. +type Update struct { + AgentId string `json:"agentId"` + Path string `json:"path"` + Owner string `json:"owner"` + Manager string `json:"manager,omitempty"` + Current string `json:"current,omitempty"` + Latest string `json:"latest,omitempty"` + // Available is a newer release than the one on disk, which is the whole + // point of asking. False while either version is unknown: an installation + // whose version could not be read is not called out of date on a guess. + Available bool `json:"available"` + Detail string `json:"detail,omitempty"` + CheckedAt string `json:"checkedAt,omitempty"` +} + +var catalogs = struct { + sync.Mutex + byKey map[string]catalogEntry + // pending keeps one lookup per package: a page listing a dozen agents asks + // for each of them at once, and they would otherwise all leave the host. + pending map[string]*sync.WaitGroup +}{byKey: map[string]catalogEntry{}, pending: map[string]*sync.WaitGroup{}} + +type catalogEntry struct { + catalog Catalog + // full marks an answer that carries the version list, not only the release + // the manager calls current. + full bool + at time.Time +} + +var catalogClient = &http.Client{Timeout: catalogTimeout, Transport: proxy.Transport()} + +// VersionsOf lists what the manager that owns an installation publishes for it. +// installMethod picks the manager, since an agent published on two of them is +// upgraded through the one that put it on this host; an empty method means the +// agent is not installed, and the manager an install would use answers instead. +func VersionsOf(agentId string, installMethod string, force bool) Catalog { + return lookupCatalog(agentId, installMethod, true, force) +} + +// LatestOf is only the release the manager calls current, which is one small +// request where the version list is a document of many megabytes. It is what a +// listing of installations needs, and the picker asks for the rest. +func LatestOf(agentId string, installMethod string, force bool) Catalog { + return lookupCatalog(agentId, installMethod, false, force) +} + +func lookupCatalog(agentId string, installMethod string, full bool, force bool) Catalog { + manager, packageId := managerPackage(agentId, installMethod) + result := Catalog{AgentId: agentId, Manager: manager, Package: packageId, Versions: []string{}} + if manager == "" { + result.Detail = catalogUnavailableDetail(agentId, installMethod) + return result + } + + key := manager + "|" + packageId + for { + catalogs.Lock() + cached, ok := catalogs.byKey[key] + // A cached list answers a question about the current release too; the + // other way round it does not. + if ok && !force && time.Since(cached.at) < catalogTTL && (cached.full || !full) { + catalogs.Unlock() + return withAgent(cached.catalog, agentId, installMethod) + } + waiting, running := catalogs.pending[key] + if !running { + break + } + catalogs.Unlock() + // Another caller is already asking; take its answer rather than a + // second round trip, and stop forcing so the wait cannot repeat. + waiting.Wait() + force = false + } + + group := &sync.WaitGroup{} + group.Add(1) + catalogs.pending[key] = group + catalogs.Unlock() + + fetched := fetchCatalog(manager, packageId, full) + fetched.Manager, fetched.Package = manager, packageId + + catalogs.Lock() + catalogs.byKey[key] = catalogEntry{catalog: fetched, full: full, at: time.Now()} + delete(catalogs.pending, key) + catalogs.Unlock() + group.Done() + + return withAgent(fetched, agentId, installMethod) +} + +// UpdatesFor measures every installation against its manager, looking each +// package up once however many installations share it. +func UpdatesFor(installations []agent.Installation, force bool) []Update { + warmCatalogs(installations, force) + + result := make([]Update, 0, len(installations)) + for _, installation := range installations { + // The lookups are already done and cached, so this asks for none. + catalog := LatestOf(installation.AgentId, installation.InstallMethod, false) + update := Update{ + AgentId: installation.AgentId, + Path: installation.Path, + Owner: installation.Owner, + Manager: catalog.Manager, + Current: installation.Version, + Latest: catalog.Latest, + Detail: catalog.Detail, + CheckedAt: catalog.CheckedAt, + } + update.Available = installation.Version != "" && catalog.Latest != "" && + CompareVersions(catalog.Latest, installation.Version) > 0 + result = append(result, update) + } + return result +} + +// warmCatalogs fetches what a listing needs, several at a time: a host with a +// dozen agents would otherwise hold the request open for a dozen round trips. +func warmCatalogs(installations []agent.Installation, force bool) { + const parallel = 6 + + slots := make(chan struct{}, parallel) + group := sync.WaitGroup{} + asked := map[string]bool{} + for _, installation := range installations { + key := installation.AgentId + "|" + installation.InstallMethod + if asked[key] { + continue + } + asked[key] = true + + group.Add(1) + go func(agentId string, installMethod string) { + defer group.Done() + + slots <- struct{}{} + defer func() { <-slots }() + LatestOf(agentId, installMethod, force) + }(installation.AgentId, installation.InstallMethod) + } + group.Wait() +} + +// UpdatesForMissing reports the release an install would land on for every +// agent this host has none of, so one listing can cover all of them. The rows +// carry no path or owner, which is what marks them as installations that are +// not there. +func UpdatesForMissing(installed []agent.Installation, force bool) []Update { + here := map[string]bool{} + for _, installation := range installed { + here[installation.AgentId] = true + } + + missing := []agent.Installation{} + for _, known := range agent.KnownAgents() { + if !here[known.AgentId] { + missing = append(missing, agent.Installation{AgentId: known.AgentId}) + } + } + return UpdatesFor(missing, force) +} + +func withAgent(catalog Catalog, agentId string, installMethod string) Catalog { + catalog.AgentId = agentId + if catalog.Versions == nil { + catalog.Versions = []string{} + } + // The template is built here rather than cached: it names the program the + // PATH search resolved, which the cache outlives. + plan := managerPlan(agentId, installMethod, ActionDowngrade, versionPlaceholder) + if installMethod == "" { + // Nothing installed, so there is no manager that owns a tree: the one + // an install would reach for answers instead. + plan = installPlan(agentId, versionPlaceholder) + } + if plan.Available { + catalog.CommandTemplate = plan.Command + } + return catalog +} + +// managerPackage is the manager that owns an installation and the id it knows +// the agent by. An installMethod no manager matches leaves both empty, and so +// does one whose manager does not publish this agent. +func managerPackage(agentId string, installMethod string) (string, string) { + packages := agent.PackagesOf(agentId) + if installMethod == "" { + // Not installed: the manager an install would reach for is the one + // whose releases are worth listing. + for _, candidate := range installOrder() { + if manager, packageId := managerPackage(agentId, candidate); manager != "" { + return manager, packageId + } + } + return "", "" + } + + switch installMethod { + case ManagerNpm: + return withPackage(ManagerNpm, packages.Npm) + case ManagerHomebrew: + return withPackage(ManagerHomebrew, packages.HomebrewCask) + case ManagerWinget: + if runtime.GOOS != "windows" { + return "", "" + } + return withPackage(ManagerWinget, packages.Winget) + } + return "", "" +} + +func withPackage(manager string, packageId string) (string, string) { + if packageId == "" { + return "", "" + } + return manager, packageId +} + +func catalogUnavailableDetail(agentId string, installMethod string) string { + if installMethod != "" && installMethod != ManagerNpm && + installMethod != ManagerHomebrew && installMethod != ManagerWinget { + return "this agent was installed as \"" + installMethod + "\", which publishes no version list Gateway can read" + } + if agent.PackagesOf(agentId).Desktop { + return "this is a desktop app; its versions come from the vendor's own downloads" + } + return "no package manager on this platform publishes this agent" +} + +func fetchCatalog(manager string, packageId string, full bool) Catalog { + var catalog Catalog + var err error + switch manager { + case ManagerNpm: + if full { + catalog, err = npmCatalog(packageId) + } else { + catalog, err = npmLatest(packageId) + } + case ManagerHomebrew: + catalog, err = homebrewCatalog(packageId) + case ManagerWinget: + catalog, err = wingetCatalog(packageId) + } + if err != nil { + catalog.Detail = err.Error() + } + if catalog.Versions == nil { + catalog.Versions = []string{} + } + catalog.CheckedAt = time.Now().Format(time.RFC3339) + return catalog +} + +// npmLatest reads the tags alone, which is a few hundred bytes where the +// package document of a busy agent is fifteen megabytes. +func npmLatest(packageId string) (Catalog, error) { + var payload map[string]string + if err := npmGet("/-/package/"+url.PathEscape(packageId)+"/dist-tags", "", &payload); err != nil { + return Catalog{}, err + } + return Catalog{Latest: payload["latest"]}, nil +} + +// npmCatalog reads the registry rather than running npm: the abbreviated +// document carries the tags and every published version, and asking for it +// costs one request instead of starting a package manager. +func npmCatalog(packageId string) (Catalog, error) { + var payload struct { + DistTags map[string]string `json:"dist-tags"` + Versions map[string]json.RawMessage `json:"versions"` + } + if err := npmGet("/"+url.PathEscape(packageId), "application/vnd.npm.install-v1+json", &payload); err != nil { + return Catalog{}, err + } + + published := make([]string, 0, len(payload.Versions)) + for version := range payload.Versions { + published = append(published, version) + } + return Catalog{Latest: payload.DistTags["latest"], Versions: newestFirst(published)}, nil +} + +func npmGet(path string, accept string, into any) error { + req, err := http.NewRequest("GET", "https://registry.npmjs.org"+path, nil) + if err != nil { + return err + } + if accept != "" { + req.Header.Set("Accept", accept) + } + req.Header.Set("User-Agent", "casbin-gateway") + + resp, err := catalogClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // The abbreviated document of an agent that publishes several times a day + // runs to tens of megabytes, so the cap is generous rather than tight. + body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20)) + if err != nil { + return err + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("the npm registry answered HTTP %d for %s", resp.StatusCode, path) + } + if err := json.Unmarshal(body, into); err != nil { + return fmt.Errorf("the npm registry answered something that is not a package: %w", err) + } + return nil +} + +// homebrewCatalog reads the cask, which names the one version it installs. +func homebrewCatalog(cask string) (Catalog, error) { + address := "https://formulae.brew.sh/api/cask/" + url.PathEscape(cask) + ".json" + req, err := http.NewRequest("GET", address, nil) + if err != nil { + return Catalog{}, err + } + req.Header.Set("User-Agent", "casbin-gateway") + + resp, err := catalogClient.Do(req) + if err != nil { + return Catalog{}, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return Catalog{}, err + } + if resp.StatusCode != http.StatusOK { + return Catalog{}, fmt.Errorf("Homebrew answered HTTP %d for the %s cask", resp.StatusCode, cask) + } + + var payload struct { + Version string `json:"version"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return Catalog{}, fmt.Errorf("Homebrew answered something that is not a cask: %w", err) + } + + latest := strings.TrimSpace(payload.Version) + catalog := Catalog{Latest: latest} + if latest != "" { + catalog.Versions = []string{latest} + } + catalog.Detail = "Homebrew publishes only the current version of a cask" + return catalog, nil +} + +// wingetCatalog asks winget itself: the community repository has no API a +// client is meant to read, and the program that installed the agent is here. +func wingetCatalog(packageId string) (Catalog, error) { + program := lookup("winget") + if program == "" { + return Catalog{}, fmt.Errorf("winget is not on Gateway's PATH") + } + + ctx, cancel := context.WithTimeout(context.Background(), catalogTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, program, "show", "--id", packageId, "--exact", "--versions", + "--disable-interactivity", "--accept-source-agreements") + hideWindow(cmd) + out, err := cmd.Output() + if err != nil { + return Catalog{}, fmt.Errorf("winget could not list the versions of %s", packageId) + } + + published := []string{} + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + // The table is preceded by a heading and a rule of dashes, and every + // row after them is one version. + if line == "" || strings.HasPrefix(line, "-") || !IsValidVersion(line) { + continue + } + published = append(published, line) + } + if len(published) == 0 { + return Catalog{}, fmt.Errorf("winget listed no versions for %s", packageId) + } + + sorted := newestFirst(published) + return Catalog{Latest: sorted[0], Versions: sorted}, nil +} + +// newestFirst sorts releases and keeps the newest maxVersions of them. The +// prereleases go: an agent that publishes a nightly every day would fill the +// picker with builds nobody is choosing to run, and the versions worth going +// back to are the ones the vendor released. An agent that has only ever +// published prereleases keeps them, since otherwise it lists nothing. +func newestFirst(versions []string) []string { + stable := make([]string, 0, len(versions)) + for _, version := range versions { + if _, prerelease := splitPrerelease(version); prerelease == "" { + stable = append(stable, version) + } + } + if len(stable) > 0 { + versions = stable + } + + sort.SliceStable(versions, func(left, right int) bool { + return CompareVersions(versions[left], versions[right]) > 0 + }) + if len(versions) > maxVersions { + versions = versions[:maxVersions] + } + return versions +} + +// CompareVersions orders two releases the way their publishers do: numbers +// compare as numbers, a missing part counts as zero, and a prerelease sorts +// below the release it leads to. An unreadable version sorts below a readable +// one, which keeps a build string out of the "newer" answer. +func CompareVersions(left string, right string) int { + leftCore, leftPre := splitPrerelease(left) + rightCore, rightPre := splitPrerelease(right) + + if order := compareParts(leftCore, rightCore); order != 0 { + return order + } + switch { + case leftPre == rightPre: + return 0 + case leftPre == "": + return 1 + case rightPre == "": + return -1 + } + return strings.Compare(leftPre, rightPre) +} + +func splitPrerelease(version string) ([]string, string) { + version = strings.TrimSpace(version) + version = strings.TrimPrefix(version, "v") + if plus := strings.IndexByte(version, '+'); plus >= 0 { + version = version[:plus] + } + core, prerelease, _ := strings.Cut(version, "-") + return strings.Split(core, "."), prerelease +} + +func compareParts(left []string, right []string) int { + for i := 0; i < len(left) || i < len(right); i++ { + leftPart, rightPart := numberAt(left, i), numberAt(right, i) + if leftPart != rightPart { + if leftPart < rightPart { + return -1 + } + return 1 + } + } + return 0 +} + +func numberAt(parts []string, index int) int { + if index >= len(parts) { + return 0 + } + number := 0 + for i := 0; i < len(parts[index]); i++ { + digit := parts[index][i] + if digit < '0' || digit > '9' { + return number + } + number = number*10 + int(digit-'0') + } + return number +} diff --git a/controllers/agent.go b/controllers/agent.go index c4d124c..c912893 100644 --- a/controllers/agent.go +++ b/controllers/agent.go @@ -54,6 +54,8 @@ type discoveredAgent struct { // Upgrade is the command that would update this installation in place, // through the package manager that installed it. Upgrade agentinstall.Plan `json:"upgrade"` + // Uninstall is the command that would remove it with that same manager. + Uninstall agentinstall.Plan `json:"uninstall"` // SupportsInstances says whether this agent can be run more than once at a // time, each copy signed in to an account of its own. SupportsInstances bool `json:"supportsInstances"` @@ -93,6 +95,7 @@ func (c *ApiController) GetAgents() { Mode: object.ModeGateway, ProviderConfig: agentprovider.StatusOf(providerTarget(target)), Upgrade: agentinstall.UpgradePlan(installation.AgentId, installation.InstallMethod), + Uninstall: agentinstall.UninstallPlan(installation.AgentId, installation.InstallMethod), SupportsInstances: agent.SupportsInstances(installation.AgentId), } diff --git a/controllers/agent_install.go b/controllers/agent_install.go index 571346d..99911ae 100644 --- a/controllers/agent_install.go +++ b/controllers/agent_install.go @@ -31,6 +31,9 @@ func (c *ApiController) InstallAgent() { var form struct { AgentId string `json:"agentId"` + // Version pins the install to one published release, empty for the one + // the manager calls current. + Version string `json:"version"` } if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil { c.ResponseError(err.Error()) @@ -41,7 +44,7 @@ func (c *ApiController) InstallAgent() { return } - job, err := agentinstall.Start(agentinstall.InstallPlan(form.AgentId)) + job, err := agentinstall.Start(agentinstall.InstallVersionPlan(form.AgentId, form.Version)) if err != nil { c.ResponseError(err.Error()) return @@ -81,3 +84,96 @@ func (c *ApiController) GetAgentInstallJobs() { c.ResponseOk(agentinstall.Jobs()) } + +// SetAgentVersion moves one installation onto a chosen release, up or down, +// through the package manager that installed it. The version is the only part +// of the command that comes from the request, and it is refused unless it reads +// as a version number. +func (c *ApiController) SetAgentVersion() { + if c.RequireAdmin() { + return + } + + var form struct { + Version string `json:"version"` + } + if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil { + c.ResponseError(err.Error()) + return + } + + installation, ok := c.readAgentInstallation() + if !ok { + return + } + + plan := agentinstall.VersionPlan( + installation.AgentId, installation.InstallMethod, form.Version, installation.Version) + job, err := agentinstall.Start(plan) + if err != nil { + c.ResponseError(err.Error()) + return + } + c.ResponseOk(job) +} + +// UninstallAgent removes one discovered installation with the manager that put +// it there. Only the program goes: the agent's own state directory keeps its +// sign-in and its history, so reinstalling it finds them again. +func (c *ApiController) UninstallAgent() { + if c.RequireAdmin() { + return + } + + installation, ok := c.readAgentInstallation() + if !ok { + return + } + + job, err := agentinstall.Start( + agentinstall.UninstallPlan(installation.AgentId, installation.InstallMethod)) + if err != nil { + c.ResponseError(err.Error()) + return + } + c.ResponseOk(job) +} + +// GetAgentVersions lists what the package manager publishes for one agent: the +// release it calls current, and the older ones a downgrade can go back to. +func (c *ApiController) GetAgentVersions() { + if c.RequireAdmin() { + return + } + + agentId := c.GetString("agentId") + if !agent.IsKnownAgentId(agentId) { + c.ResponseError("unknown agent: " + agentId) + return + } + + c.ResponseOk(agentinstall.VersionsOf(agentId, c.GetString("installMethod"), c.GetString("refresh") == "true")) +} + +// GetAgentUpdates reports which installations have a newer release waiting. +// The lookups are cached, so the pages can ask on every load; refresh asks the +// registries again. scope=all adds a row for every agent this host has none of, +// naming the release an install would land on. +func (c *ApiController) GetAgentUpdates() { + if c.RequireAdmin() { + return + } + + installations, err := agent.Scan(false) + if err != nil { + c.ResponseError(err.Error()) + return + } + + force := c.GetString("refresh") == "true" + updates := agentinstall.UpdatesFor(installations, force) + if c.GetString("scope") == "all" { + updates = append(updates, agentinstall.UpdatesForMissing(installations, force)...) + } + c.ResponseOk(updates) +} diff --git a/routers/router.go b/routers/router.go index 36f7c38..4574bca 100644 --- a/routers/router.go +++ b/routers/router.go @@ -64,6 +64,10 @@ func initAPI() { beego.Router("/api/get-agent-install-jobs", &controllers.ApiController{}, "GET:GetAgentInstallJobs") beego.Router("/api/install-agent", &controllers.ApiController{}, "POST:InstallAgent") beego.Router("/api/upgrade-agent", &controllers.ApiController{}, "POST:UpgradeAgent") + beego.Router("/api/set-agent-version", &controllers.ApiController{}, "POST:SetAgentVersion") + beego.Router("/api/uninstall-agent", &controllers.ApiController{}, "POST:UninstallAgent") + beego.Router("/api/get-agent-versions", &controllers.ApiController{}, "GET:GetAgentVersions") + beego.Router("/api/get-agent-updates", &controllers.ApiController{}, "GET:GetAgentUpdates") beego.Router("/api/patch-agent", &controllers.ApiController{}, "POST:PatchAgent") beego.Router("/api/unpatch-agent", &controllers.ApiController{}, "POST:UnpatchAgent") beego.Router("/api/update-agent-routing", &controllers.ApiController{}, "POST:UpdateAgentRouting") diff --git a/web/src/App.tsx b/web/src/App.tsx index 8f6fc8d..6fac971 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -34,6 +34,7 @@ import AgentDetailPage from "@/pages/AgentDetailPage"; import AgentRecordsPage from "@/pages/AgentRecordsPage"; import AgentSessionPage from "@/pages/AgentSessionPage"; import AgentSessionsPage from "@/pages/AgentSessionsPage"; +import AgentVersionsPage from "@/pages/AgentVersionsPage"; import AgentsPage from "@/pages/AgentsPage"; import AuthCallback from "@/pages/AuthCallback"; import AuthenticityPage from "@/pages/AuthenticityPage"; @@ -284,6 +285,10 @@ export default function App() { /> <Route path="/account" element={requireSignin(user => <AccountPage account={user} />)} /> <Route path="/agents" element={requireSignin(user => <AgentsPage account={user} />)} /> + <Route + path="/agent-versions" + element={requireSignin(user => <AgentVersionsPage account={user} />)} + /> <Route path="/agents/:agentId" element={requireSignin(user => <AgentDetailPage account={user} />)} diff --git a/web/src/backend/AgentBackend.ts b/web/src/backend/AgentBackend.ts index 4b7b08c..90644e3 100644 --- a/web/src/backend/AgentBackend.ts +++ b/web/src/backend/AgentBackend.ts @@ -24,7 +24,9 @@ import type { AgentRuntime, AgentSession, AgentTranscript, + AgentUpdate, AgentUsage, + AgentVersionCatalog, } from "@/types"; export interface PatchTarget { @@ -43,9 +45,12 @@ export function getAgentCatalog() { return request<AgentCatalogEntry[]>("/api/get-agent-catalog"); } -/** Installs an agent this machine lacks; the job runs on past this response. */ -export function installAgent(agentId: string) { - return request<AgentInstallJob>("/api/install-agent", "POST", {agentId: agentId}); +/** + * Installs an agent this machine lacks; the job runs on past this response. + * `version` pins it to one published release, empty for the current one. + */ +export function installAgent(agentId: string, version = "") { + return request<AgentInstallJob>("/api/install-agent", "POST", {agentId: agentId, version: version}); } /** Updates one installation through the package manager that installed it. */ @@ -53,6 +58,41 @@ export function upgradeAgent(target: PatchTarget) { return request<AgentInstallJob>("/api/upgrade-agent", "POST", target); } +/** Moves one installation onto a chosen release, up or down. */ +export function setAgentVersion(target: PatchTarget, version: string) { + return request<AgentInstallJob>("/api/set-agent-version", "POST", {...target, version: version}); +} + +/** Removes one installation with the manager that installed it. */ +export function uninstallAgent(target: PatchTarget) { + return request<AgentInstallJob>("/api/uninstall-agent", "POST", target); +} + +/** The releases one agent's package manager publishes, newest first. */ +export function getAgentVersions(agentId: string, installMethod = "", forceRefresh = false) { + return request<AgentVersionCatalog>( + `/api/get-agent-versions${query({ + agentId: agentId, + installMethod: installMethod, + refresh: forceRefresh ? "true" : "", + })}`, + ); +} + +/** + * Which installations have a newer release waiting, from cached lookups. + * `scope` of "all" adds a row per agent this machine has none of, naming the + * release a first install would land on. + */ +export function getAgentUpdates(forceRefresh = false, scope: "installed" | "all" = "installed") { + return request<AgentUpdate[]>( + `/api/get-agent-updates${query({ + refresh: forceRefresh ? "true" : "", + scope: scope === "all" ? "all" : "", + })}`, + ); +} + /** Every install and upgrade this Gateway process has run, newest first. */ export function getAgentInstallJobs() { return request<AgentInstallJob[]>("/api/get-agent-install-jobs"); diff --git a/web/src/components/AgentGridCard.tsx b/web/src/components/AgentGridCard.tsx index 9d4c012..de9bcc5 100644 --- a/web/src/components/AgentGridCard.tsx +++ b/web/src/components/AgentGridCard.tsx @@ -20,6 +20,7 @@ import i18next from "i18next"; import {AgentIcon} from "@/components/AgentIcon"; import {AgentCardInstances} from "@/components/AgentInstances"; import {AgentUsageTrend, hasUsageTrend} from "@/components/AgentUsageTrend"; +import {AgentUpdateBadge} from "@/components/AgentVersionDialog"; import {RunButton, RunDot} from "@/components/AgentRunControl"; import {ProviderIcon} from "@/components/ProviderIcon"; import {QuotaBadge} from "@/components/ProviderQuota"; @@ -49,6 +50,7 @@ import type { Agent, AgentAccount, AgentRuntime, + AgentUpdate, AgentUsageStat, LlmAgentStat, Provider, @@ -162,6 +164,7 @@ export function AgentGridCard({ stats, usage, status, + update, instances, recording, busy, @@ -183,6 +186,8 @@ export function AgentGridCard({ */ usage?: AgentUsageStat; status?: AgentRuntime; + /** The release check for this installation, absent until it lands. */ + update?: AgentUpdate; /** The extra copies of this agent, for the agents that can run more than one. */ instances?: AgentInstanceControls; /** False while llmRecordMode is off, when a zero would be a lie. */ @@ -245,6 +250,7 @@ export function AgentGridCard({ {agent.path} </p> </SimpleTooltip> + <AgentUpdateBadge update={update} className="mt-1 text-[11px]" /> <AccountLine account={agent.account} /> </div> <RunDot status={status} /> diff --git a/web/src/components/AgentVersionDialog.tsx b/web/src/components/AgentVersionDialog.tsx new file mode 100644 index 0000000..d0079ac --- /dev/null +++ b/web/src/components/AgentVersionDialog.tsx @@ -0,0 +1,268 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as React from "react"; +import {ArrowUpCircle, History, RefreshCw, Trash2} from "lucide-react"; +import i18next from "i18next"; + +import * as AgentBackend from "@/backend/AgentBackend"; +import {ConfirmDialog} from "@/components/shared/confirm-dialog"; +import {Badge} from "@/components/ui/badge"; +import {Button} from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import {SimpleTooltip} from "@/components/ui/tooltip"; +import type {Agent, AgentUpdate, AgentVersionCatalog} from "@/types"; + +/** + * The mark on an installation a newer release is waiting for. Nothing is shown + * while the lookup is out, or for an agent whose manager publishes no list: an + * installation is never called out of date on a version nobody could read. + */ +export function AgentUpdateBadge({update, className}: {update?: AgentUpdate; className?: string}) { + if (!update?.available) { + return null; + } + + return ( + <SimpleTooltip title={`${i18next.t("agent:Latest version")}: ${update.latest}`}> + <Badge variant="warning" className={className}> + <ArrowUpCircle className="size-3" /> + {i18next.t("agent:New version")} {update.latest} + </Badge> + </SimpleTooltip> + ); +} + +/** + * The picker behind a version change. An agent is moved onto any release its + * package manager still publishes, which is how a broken update is undone: the + * command that will run is spelled out before the click that runs it. + * + * It serves an agent this machine does not have too, where the releases are the + * ones a first install can ask for and `installedVersion` is empty. + */ +export function AgentVersionDialog({ + agentId, + name, + installMethod = "", + installedVersion = "", + update, + busy, + fallbackDetail, + onSelect, +}: { + agentId: string; + name: string; + /** The manager that owns the tree, empty for an agent that is not installed. */ + installMethod?: string; + installedVersion?: string; + /** What the manager publishes, when the page has already looked it up. */ + update?: AgentUpdate; + busy: boolean; + /** Why there may be nothing to pick, from whatever the page already knows. */ + fallbackDetail?: string; + onSelect: (version: string) => void; +}) { + const [open, setOpen] = React.useState(false); + const [catalog, setCatalog] = React.useState<AgentVersionCatalog>(); + const [loading, setLoading] = React.useState(false); + const [chosen, setChosen] = React.useState(""); + + const load = React.useCallback( + (forceRefresh = false) => { + setLoading(true); + AgentBackend.getAgentVersions(agentId, installMethod, forceRefresh) + .then(res => setCatalog(res.status === "ok" ? res.data : undefined)) + .catch(() => setCatalog(undefined)) + .then(() => setLoading(false)); + }, + [agentId, installMethod], + ); + + // The list is asked for when the dialog opens rather than with the page: a + // registry lookup per installed agent on every load is a lot of waiting for + // something most visits never open. + React.useEffect(() => { + if (open) { + setChosen(""); + load(); + } + }, [open, load]); + + const versions = catalog?.versions ?? []; + const template = catalog?.commandTemplate ?? ""; + // Without a template the manager cannot be asked for a named release, which + // is every Homebrew cask: it installs the one version its own index carries. + const canPick = versions.length > 0 && template !== ""; + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button size="sm" variant="outline" loading={busy}> + <History /> + {i18next.t("agent:Versions")} + {update?.available ? <span className="bg-warning size-1.5 rounded-full" /> : null} + </Button> + </DialogTrigger> + <DialogContent> + <DialogHeader> + <DialogTitle>{i18next.t("agent:Versions of {agent}").replace("{agent}", name)}</DialogTitle> + <DialogDescription> + {i18next.t(installedVersion ? "agent:Version picker hint" : "agent:Version install hint")} + </DialogDescription> + </DialogHeader> + + <div className="space-y-3 text-sm"> + <div className="flex flex-wrap items-center gap-2"> + <span className="text-muted-foreground">{i18next.t("agent:Installed version")}</span> + <Badge variant="secondary" className="tabular-nums"> + {installedVersion || i18next.t("agent:Not installed")} + </Badge> + {catalog?.latest ? ( + <> + <span className="text-muted-foreground">{i18next.t("agent:Latest version")}</span> + <Badge variant={update?.available ? "warning" : "muted"} className="tabular-nums"> + {catalog.latest} + </Badge> + </> + ) : null} + <Button + size="sm" + variant="ghost" + className="ml-auto" + loading={loading} + onClick={() => load(true)} + > + <RefreshCw /> + {i18next.t("general:Refresh")} + </Button> + </div> + + {canPick ? ( + <Select value={chosen} onValueChange={setChosen}> + <SelectTrigger className="w-full"> + <SelectValue placeholder={i18next.t("agent:Pick a version")} /> + </SelectTrigger> + <SelectContent> + {versions.map(version => ( + <SelectItem key={version} value={version}> + <span className="tabular-nums">{version}</span> + {version === catalog?.latest ? ( + <Badge variant="muted" className="ml-2 text-[11px]"> + {i18next.t("agent:Latest")} + </Badge> + ) : null} + {version === installedVersion ? ( + <Badge variant="muted" className="ml-2 text-[11px]"> + {i18next.t("agent:Installed")} + </Badge> + ) : null} + </SelectItem> + ))} + </SelectContent> + </Select> + ) : ( + <p className="text-muted-foreground text-xs"> + {catalog?.detail || fallbackDetail || i18next.t("agent:No versions to list")} + </p> + )} + + {chosen && chosen !== installedVersion ? ( + <code className="bg-muted block rounded p-2 text-xs break-all"> + {template.replace("{version}", chosen)} + </code> + ) : null} + </div> + + <DialogFooter> + <Button variant="outline" onClick={() => setOpen(false)}> + {i18next.t("general:Cancel")} + </Button> + <Button + disabled={!chosen || chosen === installedVersion} + onClick={() => { + setOpen(false); + onSelect(chosen); + }} + > + {i18next.t(installedVersion ? "agent:Switch version" : "agent:Install")} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +/** + * The guard in front of an uninstall. The program goes; the agent's own state + * directory, with its sign-in and its history, stays where it is, so the + * dialog says so rather than leaving that to be discovered. + */ +export function ToolUninstallConfirmDialog({ + agent, + busy, + onConfirm, +}: { + agent: Agent; + busy: boolean; + onConfirm: () => void; +}) { + const plan = agent.uninstall; + + const button = ( + <Button size="sm" variant="outline" disabled={!plan?.available} loading={busy}> + <Trash2 /> + {i18next.t("agent:Uninstall")} + </Button> + ); + + if (!plan?.available) { + return ( + <SimpleTooltip title={plan?.detail}> + <span>{button}</span> + </SimpleTooltip> + ); + } + + return ( + <ConfirmDialog + title={i18next.t("agent:Uninstall {agent}?").replace("{agent}", agent.name)} + description={ + <span className="space-y-2"> + <span className="block">{i18next.t("agent:Uninstall hint")}</span> + <code className="bg-muted block rounded p-2 text-xs break-all">{plan.command}</code> + </span> + } + confirmText={i18next.t("agent:Uninstall")} + onConfirm={onConfirm} + > + {button} + </ConfirmDialog> + ); +} diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts index 3f0d1d0..7d888a9 100644 --- a/web/src/lib/agents.ts +++ b/web/src/lib/agents.ts @@ -26,6 +26,7 @@ import type { AgentInstance, AgentRuntime, AgentSession, + AgentUpdate, AgentUsage, AgentUsageStat, Provider, @@ -552,7 +553,7 @@ export function useAgentInstall(enabled = true, onFinished?: () => void) { Setting.showMessage( job.ok ? "success" : "error", job.ok - ? `${i18next.t(job.action === "upgrade" ? "agent:Agent upgraded" : "agent:Agent installed")}: ${job.name}` + ? `${i18next.t(installOutcomeKey(job.action))}: ${job.name}${job.version ? ` ${job.version}` : ""}` : `${i18next.t("agent:Failed to install the agent")}: ${job.error || job.name}`, ); finished.current?.(); @@ -578,19 +579,109 @@ export function useAgentInstall(enabled = true, onFinished?: () => void) { ); const install = React.useCallback( - (agentId: string) => start(agentId, () => AgentBackend.installAgent(agentId)), + (agentId: string, version = "") => + start(agentId, () => AgentBackend.installAgent(agentId, version)), [start], ); const upgrade = React.useCallback( - (agent: Agent) => - start(agent.agentId, () => - AgentBackend.upgradeAgent({agentId: agent.agentId, path: agent.path, owner: agent.owner}), - ), + (agent: Agent) => start(agent.agentId, () => AgentBackend.upgradeAgent(installTarget(agent))), [start], ); - return {jobs, busyId, install, upgrade, reload: load}; + const setVersion = React.useCallback( + (agent: Agent, version: string) => + start(agent.agentId, () => AgentBackend.setAgentVersion(installTarget(agent), version)), + [start], + ); + + const uninstall = React.useCallback( + (agent: Agent) => start(agent.agentId, () => AgentBackend.uninstallAgent(installTarget(agent))), + [start], + ); + + return {jobs, busyId, install, upgrade, setVersion, uninstall, reload: load}; +} + +function installTarget(agent: Agent) { + return {agentId: agent.agentId, path: agent.path, owner: agent.owner}; +} + +/** What a finished job is reported as, which is not the same for all four. */ +function installOutcomeKey(action: string) { + switch (action) { + case "upgrade": + return "agent:Agent upgraded"; + case "downgrade": + return "agent:Agent moved to an older version"; + case "uninstall": + return "agent:Agent uninstalled"; + default: + return "agent:Agent installed"; + } +} + +/** How often the registries are asked again while a page stays open. */ +const updatePollMs = 10 * 60 * 1000; + +/** + * useAgentUpdates asks which installations have a newer release waiting. The + * answer comes from a registry rather than from disk, so it is loaded on its + * own: a page shows its agents at once and marks the outdated ones when the + * lookups land. + * + * `scope` of "all" also names the release a first install would land on for the + * agents this machine has none of, which is what a page listing every agent + * needs and what one listing only installations does not. + */ +export function useAgentUpdates(enabled = true, scope: "installed" | "all" = "installed") { + const [list, setList] = React.useState<AgentUpdate[]>([]); + const [checking, setChecking] = React.useState(false); + + const load = React.useCallback( + (forceRefresh = false) => { + if (!enabled) { + return; + } + + setChecking(true); + AgentBackend.getAgentUpdates(forceRefresh, scope) + .then(res => setList(res.status === "ok" ? (res.data ?? []) : [])) + .catch(() => undefined) + .then(() => setChecking(false)); + }, + [enabled, scope], + ); + + React.useEffect(() => load(), [load]); + + React.useEffect(() => { + if (!enabled) { + return; + } + + const timer = window.setInterval(() => load(true), updatePollMs); + return () => window.clearInterval(timer); + }, [enabled, load]); + + // An installation is keyed by where it is, an agent that has none by its id: + // the rows for the missing ones carry no path to tell them apart by. + const updates = React.useMemo( + () => Object.fromEntries(list.filter(item => item.path !== "").map(item => [agentKey(item), item])), + [list], + ); + const missing = React.useMemo( + () => Object.fromEntries(list.filter(item => item.path === "").map(item => [item.agentId, item])), + [list], + ); + const outdated = list.filter(item => item.available).length; + + return {updates, missing, outdated, checking, reload: load}; +} + +/** The release check for one installation, before the lookups have landed. */ +export function updateOf(updates: Record<string, AgentUpdate>, agent: Pick<Agent, "owner" | "path">) { + return updates[agentKey(agent)]; } /** The run state of one installation, before the first listing has landed. */ diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json index af4c0e0..4087dae 100644 --- a/web/src/locales/en/data.json +++ b/web/src/locales/en/data.json @@ -31,10 +31,13 @@ "Agent configuration": "Agent configuration", "Agent installation not found": "Agent installation not found", "Agent installed": "Agent installed", + "Agent moved to an older version": "Agent moved to an older version", "Agent path": "Agent path", "Agent started": "Agent started", "Agent stopped": "Agent stopped", + "Agent uninstalled": "Agent uninstalled", "Agent upgraded": "Agent upgraded", + "Agent versions": "Agent versions", "Agents": "Agents", "Agents on this machine": "Agents on this machine", "Aider config hint": "aider keeps the endpoint in ~/.aider.conf.yml: openai-api-base and openai-api-key, with the model named openai/<model> so aider reaches it over the OpenAI API instead of looking the name up. The command line and the environment are read before this file.", @@ -65,6 +68,7 @@ "Casbin configuration copied": "Casbin configuration copied to clipboard", "Casbin hint": "The switches above compile to this policy, and the first rule that matches decides. Casbin is what every relayed request is put through.", "Changing the project": "Changing the project", + "Check for updates": "Check for updates", "Claude Code config hint": "The same variables can live in the \"env\" block of ~/.claude/settings.json, so every session picks them up without exporting anything.", "Claude Desktop sandbox hint": "Claude Desktop runs its Cowork and Code sessions in a sandbox of its own, where 127.0.0.1 is that sandbox rather than this host. The URL above names this machine on its own network instead, and Gateway answers there as well as on loopback.", "Clear it with": "Clear it with", @@ -135,6 +139,8 @@ "Install an AI agent on this machine, then scan again": "Install an AI agent on this machine, then scan again", "Install page": "The vendor's own install page", "Installation": "Installation", + "Installed": "Installed", + "Installed version": "Installed version", "Installing": "Installing", "Instance added": "Instance added", "Instance removed": "Instance removed", @@ -144,6 +150,8 @@ "Kimi Code config hint": "Kimi Code keeps the endpoint in ~/.kimi-code/config.toml: a [providers.casbin-gateway] table with type \"openai\", base_url and api_key, and one [models.*] alias per model, picked by default_model at the root of the file. It reads no credential from the environment, and KIMI_CODE_HOME moves the whole directory.", "Last activity": "Last activity", "Last {count} records": "Last {count} records", + "Latest": "Latest", + "Latest version": "Latest version", "Leave plan mode": "Leave plan mode", "List a directory": "List a directory", "Look at an image": "Look at an image", @@ -157,6 +165,7 @@ "Monitoring enabled": "Monitoring enabled", "Monitoring, no activity yet": "Monitoring, no activity yet", "Move or rename a file": "Move or rename a file", + "New version": "New version", "No agent found": "No agent found", "No agent records yet - patch an agent to start collecting them": "No activity yet - turn on monitoring for an agent to start collecting it", "No agent sessions yet": "No agent sessions yet", @@ -167,6 +176,7 @@ "No rule yet": "no rule yet", "No supported agents found": "No supported agents found", "No usage yet": "No requests have been relayed for this agent yet", + "No versions to list": "No versions to list", "Not installed": "Not installed", "Not installed hint": "Agents Gateway works with that were not found on this machine. Install one with a package manager this host already has, or open its vendor's page.", "Not patched": "Not monitored", @@ -192,6 +202,7 @@ "Permissions page hint": "What every agent on this machine may ask Gateway for. You set the switches; Casbin decides every request they relay.", "Permissions saved": "Permissions saved", "Pi config hint": "pi keeps the endpoint in ~/.pi/agent/models.json: a providers entry with baseUrl, api set to openai-completions and its own model list, started on through defaultProvider and defaultModel in settings.json beside it. PI_CODING_AGENT_DIR moves both files elsewhere.", + "Pick a version": "Pick a version", "Planning and delegation": "Planning and delegation", "Preview": "Preview", "Preview hint": "What Gateway would write, with the keys masked. Everything else in these files is left as it is.", @@ -247,12 +258,14 @@ "Start app hint": "Opens the app on the machine Gateway runs on.", "Start console hint": "Opens a console window running the CLI on the machine Gateway runs on.", "State directory": "State directory", + "Status": "Status", "Still calling Gateway": "This agent is set to its own model, but its configuration still points at Gateway, so every request fails. Put the configuration back.", "Stop": "Stop", "Stop a running command": "Stop a running command", "Stop hint": "Ends every process of this installation, losing whatever it is working on.", "Stopped": "Stopped", "Switch takes effect at once": "Picking another provider above rewrites this file at once, so switching takes one click.", + "Switch version": "Switch version", "Target / Model": "Target / Model", "Terminal": "Terminal", "The internet": "The internet", @@ -268,10 +281,15 @@ "Tool use ID": "Tool use ID", "Transcript truncated": "Some of this session was too large to show, so what follows is not all of it", "Turn on monitoring to collect activity": "Turn on monitoring to collect activity", + "Uninstall": "Uninstall", + "Uninstall hint": "The package manager removes the program. The agent's own directory, with its sign-in and its history, stays where it is.", + "Uninstall {agent}?": "Uninstall {agent}?", "Unknown": "Unknown", "Unpatch": "Turn off monitoring", "Unrestricted": "Unrestricted", "Unrestricted hint": "This agent may ask for any model, reach any provider and use every tool.", + "Up to date": "Up to date", + "Updates available": "Updates available", "Upgrade": "Upgrade", "Upgrade hint": "Replaces this installation with the latest version, through the package manager that installed it. Restart the agent afterwards to run the new one.", "Upgrade {agent}?": "Upgrade {agent}?", @@ -279,6 +297,11 @@ "Usage": "Usage", "User": "User", "Version": "Version", + "Version install hint": "Install this agent at any release its package manager still publishes, not only the current one.", + "Version page hint": "Which build of each agent is on this machine, and what its package manager publishes", + "Version picker hint": "Move this agent onto any release its package manager still publishes, including an older one.", + "Versions": "Versions", + "Versions of {agent}": "Versions of {agent}", "View Records": "View Records", "View all": "View all", "Waiting for the sign-in link": "Waiting for the sign-in link", @@ -294,6 +317,7 @@ "iFlow CLI config hint": "iFlow CLI keeps the endpoint in ~/.iflow/settings.json itself: baseUrl, apiKey and modelName at the root of the file, with \"selectedAuthType\": \"openai-compatible\" beside them.", "opencode config hint": "opencode reads no base URL from the environment: the endpoint is a \"provider\" entry in ~/.config/opencode/opencode.json, loaded through @ai-sdk/openai-compatible and selected with \"model\": \"<provider>/<model>\".", "switches": "switches", + "updates available": "updates available", "{count} from transcripts": "{count} from transcripts", "{count} messages": "{count} messages" }, diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json index 50271cc..c4fc079 100644 --- a/web/src/locales/zh/data.json +++ b/web/src/locales/zh/data.json @@ -31,10 +31,13 @@ "Agent configuration": "Agent 配置文件", "Agent installation not found": "没有找到这个智能体的安装", "Agent installed": "安装完成", + "Agent moved to an older version": "已降级 Agent", "Agent path": "Agent 路径", "Agent started": "已启动", "Agent stopped": "已停止", + "Agent uninstalled": "已卸载 Agent", "Agent upgraded": "升级完成", + "Agent versions": "版本管理", "Agents": "智能体", "Agents on this machine": "本机的智能体", "Aider config hint": "aider 的端点写在 ~/.aider.conf.yml:openai-api-base 和 openai-api-key,模型名写成 openai/<model>,这样 aider 才会走 OpenAI API 而不是去自己的目录里查这个名字。命令行和环境变量会在这个文件之前被读取。", @@ -65,6 +68,7 @@ "Casbin configuration copied": "Casbin 配置已复制到剪贴板", "Casbin hint": "上面的开关会编译成这份策略,第一条匹配上的规则说了算。每个转发的请求都要过一遍 Casbin。", "Changing the project": "修改项目", + "Check for updates": "检查更新", "Claude Code config hint": "这两个变量也可以写进 ~/.claude/settings.json 的 env 段,这样不用每次 export,所有会话都会生效。", "Claude Desktop sandbox hint": "Claude Desktop 的 Cowork 和 Code 会话跑在它自己的沙箱里,那里的 127.0.0.1 是沙箱本身,不是这台主机。所以上面的地址用的是本机在局域网上的地址,Gateway 除回环外也会在该地址上监听。", "Clear it with": "清除命令", @@ -135,6 +139,8 @@ "Install an AI agent on this machine, then scan again": "在本机安装一个 AI 智能体后,再扫描一次", "Install page": "厂商的安装页面", "Installation": "安装信息", + "Installed": "已安装", + "Installed version": "已安装版本", "Installing": "安装中", "Instance added": "已添加实例", "Instance removed": "已移除实例", @@ -144,6 +150,8 @@ "Kimi Code config hint": "Kimi Code 的端点写在 ~/.kimi-code/config.toml 里:一个 [providers.casbin-gateway] 表,含 type \"openai\"、base_url 和 api_key,每个模型再写一个 [models.*] 别名,由文件根部的 default_model 选中。它不从环境变量读取任何凭据,KIMI_CODE_HOME 会把整个目录挪到别处。", "Last activity": "最后活动", "Last {count} records": "最近 {count} 条记录", + "Latest": "最新", + "Latest version": "最新版本", "Leave plan mode": "退出计划模式", "List a directory": "列出目录", "Look at an image": "查看图片", @@ -157,6 +165,7 @@ "Monitoring enabled": "已开启监控", "Monitoring, no activity yet": "监控中,暂无活动", "Move or rename a file": "移动或重命名文件", + "New version": "新版本", "No agent found": "没有找到智能体", "No agent records yet - patch an agent to start collecting them": "还没有活动记录,开启某个智能体的监控后才会开始收集", "No agent sessions yet": "还没有会话", @@ -167,6 +176,7 @@ "No rule yet": "还没有任何规则", "No supported agents found": "没有发现本机安装的智能体", "No usage yet": "该 Agent 还没有经过网关的请求", + "No versions to list": "没有可列出的版本", "Not installed": "未安装", "Not installed hint": "本机未找到、但 Gateway 支持的代理。可直接用本机已有的包管理器安装,或打开厂商页面。", "Not patched": "未开启", @@ -192,6 +202,7 @@ "Permissions page hint": "这台机器上每个智能体能向网关要什么。开关由你来设,每个转发的请求都由 Casbin 依此判定。", "Permissions saved": "权限已保存", "Pi config hint": "pi 的端点写在 ~/.pi/agent/models.json 里:providers 下的一个条目,含 baseUrl、api(openai-completions)和它自己的模型列表;启动时用哪个则由旁边 settings.json 中的 defaultProvider 和 defaultModel 决定。PI_CODING_AGENT_DIR 会把这两个文件挪到别处。", + "Pick a version": "选择版本", "Planning and delegation": "规划与委派", "Preview": "预览", "Preview hint": "Gateway 将要写入的内容,密钥已打码。文件中的其他配置保持不变。", @@ -247,12 +258,14 @@ "Start app hint": "在运行 Gateway 的机器上打开该应用。", "Start console hint": "在运行 Gateway 的机器上打开一个控制台窗口来运行该命令行。", "State directory": "状态目录", + "Status": "状态", "Still calling Gateway": "该 Agent 已设为使用自带模型,但它的配置仍指向网关,所以每个请求都会失败。请恢复它的配置。", "Stop": "停止", "Stop a running command": "停止运行中的命令", "Stop hint": "结束该安装的所有进程,正在进行的工作会丢失。", "Stopped": "未运行", "Switch takes effect at once": "在上面换一个供应商会立即改写这个文件,所以切换只需要点一下。", + "Switch version": "切换版本", "Target / Model": "目标 / 模型", "Terminal": "终端", "The internet": "访问网络", @@ -268,10 +281,15 @@ "Tool use ID": "工具调用 ID", "Transcript truncated": "这个会话有部分内容太大,没能全部展示出来", "Turn on monitoring to collect activity": "开启监控后才会收集活动", + "Uninstall": "卸载", + "Uninstall hint": "包管理器只删除程序本身。Agent 自己的目录(登录状态和历史记录)会原样保留。", + "Uninstall {agent}?": "卸载 {agent}?", "Unknown": "未知", "Unpatch": "关闭监控", "Unrestricted": "不受限制", "Unrestricted hint": "该智能体可以使用任意模型、任意供应商和全部工具。", + "Up to date": "已是最新", + "Updates available": "可更新", "Upgrade": "升级", "Upgrade hint": "通过当初安装它的包管理器,将该安装替换为最新版本。升级后需重启代理才能用上新版本。", "Upgrade {agent}?": "升级 {agent}?", @@ -279,6 +297,11 @@ "Usage": "用量", "User": "用户", "Version": "版本", + "Version install hint": "可以安装该 Agent 的任意一个仍在发布的版本,不限于当前版本。", + "Version page hint": "本机每个 Agent 装的是哪个版本,包管理器又发布到了哪个版本", + "Version picker hint": "把该 Agent 切换到包管理器仍在发布的任意版本,包括更早的版本。", + "Versions": "版本", + "Versions of {agent}": "{agent} 的版本", "View Records": "查看活动", "View all": "查看全部", "Waiting for the sign-in link": "正在等待登录链接", @@ -294,6 +317,7 @@ "iFlow CLI config hint": "iFlow CLI 的端点就写在 ~/.iflow/settings.json 里:文件根部的 baseUrl、apiKey 和 modelName,旁边是 \"selectedAuthType\": \"openai-compatible\"。", "opencode config hint": "opencode 不从环境变量读取 base URL:端点是 ~/.config/opencode/opencode.json 里的一个 \"provider\" 条目,用 @ai-sdk/openai-compatible 加载,再用 \"model\": \"<provider>/<model>\" 选中它。", "switches": "个开关", + "updates available": "个可更新", "{count} from transcripts": "{count} 个来自会话记录", "{count} messages": "{count} 条消息" }, diff --git a/web/src/nav.ts b/web/src/nav.ts index 2ab339a..f49f03b 100644 --- a/web/src/nav.ts +++ b/web/src/nav.ts @@ -21,6 +21,7 @@ import { Link2, Logs, MessageSquare, + PackageCheck, Plug, Settings, ShieldCheck, @@ -66,6 +67,13 @@ export const navGroups: NavGroup[] = [ // The table of installations is the home screen's own advanced view, linked // from it rather than competing with it in the rail. {key: "/agents", label: "agent:Advanced view", icon: Table2, path: "/agents", adminOnly: true, hidden: true}, + { + key: "/agent-versions", + label: "agent:Agent versions", + icon: PackageCheck, + path: "/agent-versions", + adminOnly: true, + }, {key: "/providers", label: "provider:Providers", icon: Plug, path: "/providers"}, // Reached by clicking a vendor's "add this to Gateway" link, which is why it // is named by the breadcrumb but not offered in the rail. diff --git a/web/src/pages/AgentDetailPage.tsx b/web/src/pages/AgentDetailPage.tsx index 5c392cd..bdb4415 100644 --- a/web/src/pages/AgentDetailPage.tsx +++ b/web/src/pages/AgentDetailPage.tsx @@ -26,6 +26,11 @@ import {AgentIcon} from "@/components/AgentIcon"; import {AgentInstances} from "@/components/AgentInstances"; import {accountLabel} from "@/components/AgentGridCard"; import {RunBadge, RunButton} from "@/components/AgentRunControl"; +import { + AgentUpdateBadge, + AgentVersionDialog, + ToolUninstallConfirmDialog, +} from "@/components/AgentVersionDialog"; import {InstallOutput, ToolUpgradeConfirmDialog} from "@/components/ToolUpgradeConfirmDialog"; import {DataTable, type Column} from "@/components/shared/data-table"; import {PermissionCard} from "@/components/PermissionCard"; @@ -44,9 +49,11 @@ import { getOutcomeVariant, monitorAgentId, runtimeOf, + updateOf, useAgentInstall, useAgents, useAgentSessions, + useAgentUpdates, } from "@/lib/agents"; import {formatCost, formatTokens} from "@/lib/usage"; import type { @@ -242,8 +249,12 @@ export default function AgentDetailPage({account}: {account: Account}) { setRouting, writeProvider, } = useAgents(isAdmin); + const updates = useAgentUpdates(isAdmin); // The upgrade rescans when it ends, so the version above is the new one. - const installer = useAgentInstall(isAdmin, () => scan(true)); + const installer = useAgentInstall(isAdmin, () => { + scan(true); + updates.reload(true); + }); const [tab, setTab] = React.useState<Tab>("Agent Sessions"); const [records, setRecords] = React.useState<AgentRecord[]>([]); const [recordError, setRecordError] = React.useState(""); @@ -436,6 +447,7 @@ export default function AgentDetailPage({account}: {account: Account}) { /> <h1 className="text-xl font-semibold tracking-tight">{agent.name}</h1> <Badge variant="secondary">{agent.version || i18next.t("agent:Unknown")}</Badge> + <AgentUpdateBadge update={updateOf(updates.updates, agent)} /> <Button variant="outline" size="sm" @@ -491,7 +503,10 @@ export default function AgentDetailPage({account}: {account: Account}) { <code className="text-xs">{agent.agentId}</code> </InfoRow> <InfoRow label={i18next.t("agent:Version")}> - {agent.version || i18next.t("agent:Unknown")} + <span className="flex flex-wrap items-center gap-2"> + <span className="tabular-nums">{agent.version || i18next.t("agent:Unknown")}</span> + <AgentUpdateBadge update={updateOf(updates.updates, agent)} /> + </span> </InfoRow> <InfoRow label={i18next.t("agent:Install Method")}> <span className="flex flex-wrap items-center gap-2"> @@ -502,6 +517,21 @@ export default function AgentDetailPage({account}: {account: Account}) { busy={installer.busyId === agent.agentId} onConfirm={() => installer.upgrade(agent)} /> + <AgentVersionDialog + agentId={agent.agentId} + name={agent.name} + installMethod={agent.installMethod} + installedVersion={agent.version} + update={updateOf(updates.updates, agent)} + busy={installer.busyId === agent.agentId} + fallbackDetail={agent.upgrade?.detail} + onSelect={version => installer.setVersion(agent, version)} + /> + <ToolUninstallConfirmDialog + agent={agent} + busy={installer.busyId === agent.agentId} + onConfirm={() => installer.uninstall(agent)} + /> </span> </InfoRow> <InfoRow label={i18next.t("general:Owner")}>{agent.owner}</InfoRow> diff --git a/web/src/pages/AgentVersionsPage.tsx b/web/src/pages/AgentVersionsPage.tsx new file mode 100644 index 0000000..a8f6532 --- /dev/null +++ b/web/src/pages/AgentVersionsPage.tsx @@ -0,0 +1,329 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {Link} from "react-router-dom"; +import {Bot, Download, ExternalLink, RefreshCw} from "lucide-react"; +import i18next from "i18next"; + +import * as Setting from "@/Setting"; +import {AgentIcon} from "@/components/AgentIcon"; +import { + AgentUpdateBadge, + AgentVersionDialog, + ToolUninstallConfirmDialog, +} from "@/components/AgentVersionDialog"; +import {InstallOutput, ToolUpgradeConfirmDialog} from "@/components/ToolUpgradeConfirmDialog"; +import {DataTable, type Column} from "@/components/shared/data-table"; +import {UnauthorizedResult} from "@/components/shared/misc"; +import {PageContainer, PageHeader} from "@/components/shared/page-header"; +import {MessageAlert} from "@/components/ui/alert"; +import {Badge} from "@/components/ui/badge"; +import {Button} from "@/components/ui/button"; +import {Card, CardContent} from "@/components/ui/card"; +import {SimpleTooltip} from "@/components/ui/tooltip"; +import { + agentDetailPath, + agentKey, + useAgentCatalog, + useAgentInstall, + useAgents, + useAgentUpdates, +} from "@/lib/agents"; +import type {Account, Agent, AgentCatalogEntry, AgentUpdate} from "@/types"; + +/** + * One line of the table. An installed agent brings the installation it was + * found as; one this machine does not have brings its catalogue entry instead, + * and the two are never both set. + */ +interface VersionRow { + key: string; + agentId: string; + name: string; + installed?: Agent; + missing?: AgentCatalogEntry; + update?: AgentUpdate; +} + +/** + * The version page: every agent Gateway knows, the release each one is on, the + * release its package manager publishes, and the four things that can be done + * about the difference - install, upgrade, go back to an older version, or + * remove it. The agents page is about what an agent is bound to; this one is + * about which build of it is on disk. + */ +export default function AgentVersionsPage({account}: {account: Account}) { + const isAdmin = Setting.isAdminUser(account); + const {agents, loading, error, scanned, scan} = useAgents(isAdmin); + const missing = useAgentCatalog(agents, isAdmin && scanned); + // Every agent, installed or not, so a row can name the release an install + // would land on as well as the one an upgrade would. + const updates = useAgentUpdates(isAdmin, "all"); + const installer = useAgentInstall(isAdmin, () => { + scan(true); + updates.reload(true); + }); + + if (!isAdmin) { + return <UnauthorizedResult />; + } + + const rows: VersionRow[] = [ + ...agents.map(agent => ({ + key: agentKey(agent), + agentId: agent.agentId, + name: agent.name, + installed: agent, + update: updates.updates[agentKey(agent)], + })), + ...missing.map(entry => ({ + key: `catalog:${entry.agentId}`, + agentId: entry.agentId, + name: entry.name, + missing: entry, + update: updates.missing[entry.agentId], + })), + ]; + + const columns: Column<VersionRow>[] = [ + { + title: i18next.t("agent:Agent"), + key: "name", + dataIndex: "name", + width: "220px", + sorter: (left, right) => left.name.localeCompare(right.name), + render: (_value, row) => ( + <span className="flex items-center gap-2 whitespace-nowrap"> + <AgentIcon agent={row.agentId || row.name} fallback={<Bot className="text-muted-foreground size-4" />} /> + {row.installed ? ( + <Link to={agentDetailPath(row.installed, agents)} className="font-medium hover:underline"> + {row.name} + </Link> + ) : ( + <span className="text-muted-foreground font-medium">{row.name}</span> + )} + </span> + ), + }, + { + title: i18next.t("agent:Installed version"), + key: "version", + width: "130px", + render: (_value, row) => + row.installed ? ( + <span className="tabular-nums">{row.installed.version || i18next.t("agent:Unknown")}</span> + ) : ( + <span className="text-muted-foreground">-</span> + ), + }, + { + title: i18next.t("agent:Latest version"), + key: "latest", + width: "130px", + render: (_value, row) => + row.update?.latest ? ( + <span className="tabular-nums">{row.update.latest}</span> + ) : ( + <SimpleTooltip title={row.update?.detail}> + <span className="text-muted-foreground">{updates.checking ? "..." : "-"}</span> + </SimpleTooltip> + ), + }, + { + title: i18next.t("agent:Status"), + key: "status", + width: "160px", + render: (_value, row) => <StatusBadge row={row} />, + }, + { + title: i18next.t("agent:Install Method"), + key: "installMethod", + width: "120px", + render: (_value, row) => ( + <Badge variant="muted"> + {row.installed ? row.installed.installMethod || "-" : row.missing?.install.manager || "-"} + </Badge> + ), + }, + { + title: i18next.t("general:Action"), + key: "action", + render: (_value, row) => ( + <div className="flex flex-wrap items-center gap-2"> + {row.installed ? ( + <> + <ToolUpgradeConfirmDialog + agent={row.installed} + job={installer.jobs[row.agentId]} + busy={installer.busyId === row.agentId} + onConfirm={() => installer.upgrade(row.installed as Agent)} + /> + <ToolUninstallConfirmDialog + agent={row.installed} + busy={installer.busyId === row.agentId} + onConfirm={() => installer.uninstall(row.installed as Agent)} + /> + </> + ) : ( + <InstallButton row={row} installer={installer} /> + )} + <AgentVersionDialog + agentId={row.agentId} + name={row.name} + installMethod={row.installed?.installMethod ?? ""} + installedVersion={row.installed?.version ?? ""} + update={row.update} + busy={installer.busyId === row.agentId} + fallbackDetail={row.installed?.upgrade?.detail ?? row.missing?.install.detail} + onSelect={version => + row.installed + ? installer.setVersion(row.installed, version) + : installer.install(row.agentId, version) + } + /> + </div> + ), + }, + ]; + + const outdated = updates.outdated; + + return ( + <PageContainer> + <PageHeader + title={i18next.t("agent:Agent versions")} + description={`${account.hostname} · ${i18next.t("agent:Version page hint")}`} + /> + + {error ? <MessageAlert title={error} /> : null} + + <div className="grid gap-3 sm:grid-cols-3"> + <SummaryTile label={i18next.t("agent:Installed")} value={agents.length} /> + <SummaryTile + label={i18next.t("agent:Updates available")} + value={outdated} + variant={outdated > 0 ? "warning" : undefined} + /> + <SummaryTile label={i18next.t("agent:Not installed")} value={missing.length} /> + </div> + + <DataTable + title={i18next.t("agent:Agents")} + description={`${rows.length} ${i18next.t("agent:Agents")}`} + columns={columns} + dataSource={rows} + rowKey="key" + loading={loading} + pageSize={0} + searchable + emptyIcon={Bot} + emptyText={i18next.t("agent:No supported agents found")} + toolbar={ + <Button + variant="outline" + size="sm" + loading={loading || updates.checking} + onClick={() => { + scan(true); + updates.reload(true); + }} + > + <RefreshCw /> + {i18next.t("agent:Check for updates")} + </Button> + } + expandable={{ + rowExpandable: row => installer.jobs[row.agentId] !== undefined, + expandedRowRender: row => <InstallOutput job={installer.jobs[row.agentId]} />, + }} + /> + </PageContainer> + ); +} + +function SummaryTile({ + label, + value, + variant, +}: { + label: string; + value: number; + variant?: "warning"; +}) { + return ( + <Card> + <CardContent className="p-4"> + <p className="text-muted-foreground text-xs">{label}</p> + <p className={`text-2xl font-semibold tabular-nums ${variant === "warning" ? "text-warning" : ""}`}> + {value} + </p> + </CardContent> + </Card> + ); +} + +/** Where one row stands: on the current release, behind it, or not there. */ +function StatusBadge({row}: {row: VersionRow}) { + if (!row.installed) { + return <Badge variant="muted">{i18next.t("agent:Not installed")}</Badge>; + } + if (row.update?.available) { + return <AgentUpdateBadge update={row.update} />; + } + // Neither version could be read, so nothing is known about the difference. + if (!row.installed.version || !row.update?.latest) { + return ( + <SimpleTooltip title={row.update?.detail}> + <Badge variant="muted">{i18next.t("agent:Unknown")}</Badge> + </SimpleTooltip> + ); + } + return <Badge variant="success">{i18next.t("agent:Up to date")}</Badge>; +} + +/** The install of an agent this machine does not have, or its vendor's page. */ +function InstallButton({ + row, + installer, +}: { + row: VersionRow; + installer: ReturnType<typeof useAgentInstall>; +}) { + const plan = row.missing?.install; + const job = installer.jobs[row.agentId]; + const running = job?.running === true; + const fallbackUrl = plan?.available ? undefined : row.missing?.installUrl; + + return ( + <SimpleTooltip title={plan?.available ? plan.command : fallbackUrl ? i18next.t("agent:Install page") : plan?.detail}> + <span> + <Button + size="sm" + disabled={(!plan?.available && !fallbackUrl) || running} + loading={installer.busyId === row.agentId || running} + onClick={() => { + if (plan?.available) { + installer.install(row.agentId); + } else if (fallbackUrl) { + window.open(fallbackUrl, "_blank", "noreferrer"); + } + }} + > + {fallbackUrl ? <ExternalLink /> : <Download />} + {i18next.t(running ? "agent:Installing" : "agent:Install")} + </Button> + </span> + </SimpleTooltip> + ); +} diff --git a/web/src/pages/AgentsPage.tsx b/web/src/pages/AgentsPage.tsx index 4e253b2..7bd24e5 100644 --- a/web/src/pages/AgentsPage.tsx +++ b/web/src/pages/AgentsPage.tsx @@ -20,6 +20,11 @@ import * as Setting from "@/Setting"; import {AgentCatalog} from "@/components/AgentCatalog"; import {AgentIcon} from "@/components/AgentIcon"; import {RunBadge, RunButton} from "@/components/AgentRunControl"; +import { + AgentUpdateBadge, + AgentVersionDialog, + ToolUninstallConfirmDialog, +} from "@/components/AgentVersionDialog"; import {ToolUpgradeConfirmDialog} from "@/components/ToolUpgradeConfirmDialog"; import {ConfirmDialog} from "@/components/shared/confirm-dialog"; import {DataTable, type Column} from "@/components/shared/data-table"; @@ -36,8 +41,10 @@ import { directMode, monitorAgentId, runtimeOf, + updateOf, useAgentInstall, useAgents, + useAgentUpdates, } from "@/lib/agents"; import type {Account, Agent} from "@/types"; @@ -57,9 +64,15 @@ export default function AgentsPage({account}: {account: Account}) { toggleRunning, togglePatch, } = useAgents(isAdmin); + // The release check runs against the registries, so it loads on its own and + // marks the rows once it lands. + const updates = useAgentUpdates(isAdmin); // One installer for the page: it installs what is missing below the table and - // upgrades what is in it. - const installer = useAgentInstall(isAdmin, () => scan(true)); + // upgrades, downgrades or removes what is in it. + const installer = useAgentInstall(isAdmin, () => { + scan(true); + updates.reload(true); + }); if (!isAdmin) { return <UnauthorizedResult />; @@ -85,8 +98,12 @@ export default function AgentsPage({account}: {account: Account}) { { title: i18next.t("agent:Version"), key: "version", - dataIndex: "version", - render: (value: string) => value || i18next.t("agent:Unknown"), + render: (_value, record) => ( + <span className="flex flex-wrap items-center gap-1.5"> + <span className="tabular-nums">{record.version || i18next.t("agent:Unknown")}</span> + <AgentUpdateBadge update={updateOf(updates.updates, record)} /> + </span> + ), }, { title: i18next.t("agent:Install Method"), @@ -194,6 +211,21 @@ export default function AgentsPage({account}: {account: Account}) { busy={installer.busyId === record.agentId} onConfirm={() => installer.upgrade(record)} /> + <AgentVersionDialog + agentId={record.agentId} + name={record.name} + installMethod={record.installMethod} + installedVersion={record.version} + update={updateOf(updates.updates, record)} + busy={installer.busyId === record.agentId} + fallbackDetail={record.upgrade?.detail} + onSelect={version => installer.setVersion(record, version)} + /> + <ToolUninstallConfirmDialog + agent={record} + busy={installer.busyId === record.agentId} + onConfirm={() => installer.uninstall(record)} + /> {patchButton(record)} </div> ), @@ -264,18 +296,26 @@ export default function AgentsPage({account}: {account: Account}) { inContainer ? "agent:Running in a container detail" : "agent:No supported agents found", )} toolbar={ - <Button - variant="outline" - size="sm" - onClick={() => { - scan(true); - loadRuntime(); - }} - loading={loading} - > - <RefreshCw /> - {i18next.t("agent:Scan")} - </Button> + <div className="flex items-center gap-2"> + {updates.outdated > 0 ? ( + <Badge variant="warning"> + {`${updates.outdated} ${i18next.t("agent:updates available")}`} + </Badge> + ) : null} + <Button + variant="outline" + size="sm" + onClick={() => { + scan(true); + loadRuntime(); + updates.reload(true); + }} + loading={loading || updates.checking} + > + <RefreshCw /> + {i18next.t("agent:Scan")} + </Button> + </div> } /> diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index cbe9b14..2dcd4f9 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -38,6 +38,8 @@ import { useAgentInstall, useAgentInstances, useAgents, + useAgentUpdates, + updateOf, usageOf, } from "@/lib/agents"; import type { @@ -83,8 +85,13 @@ export default function HomePage({account}: {account: Account}) { togglePatch, activateProvider, } = useAgents(isAdmin); + // Which cards have a newer release waiting, looked up in the registries. + const updates = useAgentUpdates(isAdmin); // Installs the agents this machine is missing, listed under the cards. - const installer = useAgentInstall(isAdmin, () => scan(true)); + const installer = useAgentInstall(isAdmin, () => { + scan(true); + updates.reload(true); + }); // Every agent's extra copies in one listing, so a page of cards costs one // request rather than one per card. const instances = useAgentInstances("", isAdmin); @@ -290,6 +297,7 @@ export default function HomePage({account}: {account: Account}) { stats={stats.find(item => item.agent === agent.agentId)} usage={usageOf(usage, agent)} status={runtimeOf(runtime, agent)} + update={updateOf(updates.updates, agent)} instances={instances} recording={recording} busy={busyKey === agentKey(agent)} diff --git a/web/src/types.ts b/web/src/types.ts index cd640b5..0154ce9 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -187,6 +187,8 @@ export interface Agent { proxyBaseUrl?: string; /** The command that would update this installation where it stands. */ upgrade?: AgentInstallPlan; + /** The command that would remove it with the manager that installed it. */ + uninstall?: AgentInstallPlan; /** Whether a second copy of this agent can run beside the first. */ supportsInstances?: boolean; } @@ -274,17 +276,50 @@ export interface AgentInstance { */ export interface AgentInstallPlan { agentId: string; - /** "install" for an agent this machine lacks, "upgrade" for one it has. */ + /** "install", "upgrade", "downgrade" or "uninstall". */ action: string; /** The package manager that would do it: npm, winget or homebrew. */ manager?: string; command?: string; + /** The release a pinned install asks for, absent for the current one. */ + version?: string; available: boolean; /** Why there is no command, when there is none. */ detail?: string; installUrl?: string; } +/** What one agent's package manager publishes, for the version picker. */ +export interface AgentVersionCatalog { + agentId: string; + manager?: string; + package?: string; + /** The release the manager calls current. */ + latest?: string; + /** Every published release, newest first. */ + versions: string[]; + /** The command a version change runs, with the release left as "{version}". + * Absent for a manager that only installs the one version it indexes. */ + commandTemplate?: string; + /** Why there is nothing to list, when there is nothing. */ + detail?: string; + checkedAt?: string; +} + +/** One installation measured against what its package manager publishes. */ +export interface AgentUpdate { + agentId: string; + path: string; + owner: string; + manager?: string; + current?: string; + latest?: string; + /** A newer release is waiting. False while either version is unknown. */ + available: boolean; + detail?: string; + checkedAt?: string; +} + /** One install or upgrade, polled while the package manager works. */ export interface AgentInstallJob { agentId: string; @@ -294,6 +329,8 @@ export interface AgentInstallJob { command: string; running: boolean; ok: boolean; + /** The release a pinned install asked for, absent for the current one. */ + version?: string; /** The tail of what the package manager printed, on both streams. */ output: string; error?: string; --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
