@@ -73,6 +73,10 @@
待部署
已接入
+ |
+ {{ a.image_tag }}
+ {{ a.image_tag || 'latest' }} ⚠
+ |
{{ dbName(a.service_dsn) }} |
是
@@ -327,8 +331,26 @@
部署目录{{ up.deployDir || '未填 —— 请先在「编辑」里填写远端部署目录' }}
-
-
+
+
+
+
+
+
+
+
+
+
+
+ 拉取仓库版本失败:{{ up.tagsError }}(可点上方「手动输入」直接填版本号,或点「刷新」重试)
+ ⚠ 未固定版本,升级后列表无法体现具体版本号,建议选明确版本(如 0.1.1)。
@@ -458,6 +480,12 @@ function dbName(dsn?: string) {
return m ? m[1] : '-'
}
+// 是否已固定到明确版本(非空、非 latest)→ 列表用绿色 chip 显示;否则标黄提示。
+function isPinnedVer(tag?: string) {
+ const t = (tag || '').trim()
+ return !!t && t !== 'latest'
+}
+
async function loadAll() {
loading.value = true
try {
@@ -855,6 +883,10 @@ const up = reactive({
envName: '',
deployDir: '',
tag: '',
+ tags: [] as string[], // 从仓库拉取的可用版本
+ tagMode: 'list' as 'list' | 'custom',
+ tagsLoading: false,
+ tagsError: '',
log: '',
show: false,
running: false,
@@ -869,6 +901,9 @@ function openUpgrade(a: Deployment) {
up.envName = a.env_id ? envName(a.env_id) : ''
up.deployDir = a.deploy_dir || ''
up.tag = a.image_tag || ''
+ up.tags = []
+ up.tagMode = 'list'
+ up.tagsError = ''
up.log = ''
up.show = false
up.running = false
@@ -876,6 +911,28 @@ function openUpgrade(a: Deployment) {
up.canRun = !!(a.env_id && (a.deploy_dir || '').trim())
up.btnText = '开始升级'
upgradeModal.value?.showModal()
+ if (up.canRun) loadUpgradeTags() // 弹窗弹出后异步拉可用版本;缺环境/目录时无从查,跳过
+}
+
+// 从镜像仓库拉取该部署镜像的可用 tag,填充升级弹窗下拉。失败则切到手动输入。
+async function loadUpgradeTags() {
+ if (!up.id) return
+ up.tagsLoading = true
+ up.tagsError = ''
+ try {
+ const r = await consoleApi<{ repo: string; current: string; tags: string[] }>('apps/tags', { id: up.id })
+ up.tags = r.tags || []
+ up.tagMode = 'list'
+ // 预选:优先当前记录的版本;否则第一个非 latest 的明确版本,引导选版本号而非 latest
+ if (!up.tag || !up.tags.includes(up.tag)) {
+ up.tag = (up.tag && up.tags.includes(up.tag)) ? up.tag : (up.tags.find((t) => t !== 'latest') || up.tag || '')
+ }
+ } catch (e: any) {
+ // 失败不清空 tags、不强制切模式:保留「当前记录版本」仍可选,只提示错误,避免看着像数据全没了
+ up.tagsError = e?.message ?? '拉取失败'
+ } finally {
+ up.tagsLoading = false
+ }
}
function closeUpgrade() {
@@ -892,7 +949,11 @@ async function runUpgrade() {
window.alert(`无法升级:请先关闭本弹窗,点该部署的「编辑」完成 —— ${miss.join('、')},保存后再来升级。`)
return
}
- if (!window.confirm(`确认升级【${up.label}】?\n将 SSH 连到「${up.envName}」,在 ${up.deployDir} 执行 docker compose 拉新镜像并重启容器。`)) return
+ const tag = (up.tag || '').trim()
+ if (!tag || tag === 'latest') {
+ if (!window.confirm(`未选择明确版本号(当前 = ${tag || '空 → 后端回退 latest'})。\nlatest 是可变 tag,升级后列表无法体现具体版本号,确定继续?`)) return
+ }
+ if (!window.confirm(`确认升级【${up.label}】?\n目标版本:${tag || 'latest'}\n将 SSH 连到「${up.envName}」,在 ${up.deployDir} 执行 docker compose 拉新镜像并重启容器。`)) return
up.running = true
up.show = true
diff --git a/apps/services/modules/console/registry_tags.go b/apps/services/modules/console/registry_tags.go
new file mode 100644
index 00000000..046f351d
--- /dev/null
+++ b/apps/services/modules/console/registry_tags.go
@@ -0,0 +1,282 @@
+package console
+
+import (
+ "crypto/tls"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "yunyan/pb"
+
+ "github.com/gin-gonic/gin"
+)
+
+// ============================ 镜像仓库 tag 查询域 (registry) ============================
+//
+// 升级弹窗「选版本」用:列出某部署对应镜像在私有仓库里的可用 tag,供下拉选择明确版本号(如 0.1.1)。
+// 走 Docker Registry HTTP API v2:GET /v2//tags/list。
+// · repo(镜像名) = 部署行 image_name(空则回退应用 image_name),与各 docker-compose.yml 的 image 段一致。
+// · 仓库地址/账号密码取自部署绑定的「环境节点」(app_environment.Registry/RegistryUser/RegistryPass),与升级 login 同一份凭据。
+// · 认证:先 Basic;遇 Bearer 挑战(Harbor/带 token auth 的 registry)再换 token 重试,兼容两类仓库。
+
+// registryHTTP 仓库查询 client:15s 超时;跳过证书校验(私有/自签仓库,取舍与部署 SSH 的 InsecureIgnoreHostKey 一致)。
+var registryHTTP = &http.Client{
+ Timeout: 15 * time.Second,
+ Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
+}
+
+// appsTags 列出某部署镜像在仓库里的可用 tag(已排序:latest 置顶 → 语义版本降序 → 其它降序)。
+func (this *serverComp) appsTags(c *gin.Context) {
+ var req struct {
+ Id uint32 `json:"id"`
+ }
+ _ = c.ShouldBindJSON(&req)
+ if req.Id == 0 {
+ writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填")
+ return
+ }
+ dep, err := this.module.model.getApp(req.Id)
+ if err != nil {
+ writeErr(c, pb.ErrorCode_DBError, "部署不存在: "+err.Error())
+ return
+ }
+ // repo = 部署行镜像名,空则回退应用(产品)镜像名
+ repo := strings.TrimSpace(dep.ImageName)
+ if repo == "" {
+ if p, _ := this.module.model.getProductByName(dep.AppName); p != nil {
+ repo = strings.TrimSpace(p.ImageName)
+ }
+ }
+ if repo == "" {
+ writeErr(c, pb.ErrorCode_ReqParameterError, "该部署未配置镜像名(image_name),请先在「编辑部署」里填写,如 deepglass-a11")
+ return
+ }
+ if dep.EnvId == 0 {
+ writeErr(c, pb.ErrorCode_ReqParameterError, "该部署未绑定环境节点,无法确定镜像仓库地址")
+ return
+ }
+ env, err := this.module.model.getEnv(dep.EnvId)
+ if err != nil {
+ writeErr(c, pb.ErrorCode_DBError, "环境节点不存在: "+err.Error())
+ return
+ }
+ registry := strings.TrimSpace(env.Registry)
+ if registry == "" {
+ writeErr(c, pb.ErrorCode_ReqParameterError, "环境节点未配置镜像仓库地址(registry)")
+ return
+ }
+ pass, _ := svcDecrypt(this.options.EncryptKey, env.RegistryPass)
+
+ tags, err := fetchRegistryTags(registry, repo, env.RegistryUser, pass)
+ if err != nil {
+ writeErr(c, pb.ErrorCode_SystemError, "拉取仓库 tag 失败: "+err.Error())
+ return
+ }
+ writeOK(c, gin.H{"repo": repo, "current": strings.TrimSpace(dep.ImageTag), "tags": sortImageTags(tags)})
+}
+
+// fetchRegistryTags 调 Registry v2 /tags/list:先 Basic,遇 Bearer 挑战换 token 重试。
+func fetchRegistryTags(registry, repo, user, pass string) ([]string, error) {
+ url := registryBaseURL(registry) + "/v2/" + repo + "/tags/list"
+ data, challenge, err := registryGet(url, user, pass, "")
+ if err != nil && strings.HasPrefix(strings.ToLower(strings.TrimSpace(challenge)), "bearer") {
+ token, terr := fetchRegistryToken(challenge, user, pass)
+ if terr != nil {
+ return nil, terr
+ }
+ data, _, err = registryGet(url, user, pass, token)
+ }
+ if err != nil {
+ return nil, err
+ }
+ var out struct {
+ Tags []string `json:"tags"`
+ }
+ if e := json.Unmarshal(data, &out); e != nil {
+ return nil, fmt.Errorf("解析 tag 列表失败: %w", e)
+ }
+ return out.Tags, nil
+}
+
+// registryGet 发一次 GET;401 时把 WWW-Authenticate 头作为 challenge 回传(供上层决定是否走 token)。
+func registryGet(u, user, pass, bearer string) (body []byte, challenge string, err error) {
+ reqx, err := http.NewRequest(http.MethodGet, u, nil)
+ if err != nil {
+ return nil, "", err
+ }
+ reqx.Header.Set("Accept", "application/json")
+ if bearer != "" {
+ reqx.Header.Set("Authorization", "Bearer "+bearer)
+ } else if user != "" {
+ reqx.SetBasicAuth(user, pass)
+ }
+ resp, err := registryHTTP.Do(reqx)
+ if err != nil {
+ return nil, "", err
+ }
+ defer resp.Body.Close()
+ data, _ := io.ReadAll(resp.Body)
+ switch {
+ case resp.StatusCode == http.StatusUnauthorized:
+ return nil, resp.Header.Get("WWW-Authenticate"), fmt.Errorf("仓库鉴权失败(401):账号密码不对或无该 repo 权限")
+ case resp.StatusCode == http.StatusNotFound:
+ return nil, "", fmt.Errorf("仓库中不存在该镜像(404):请核对镜像名 image_name 是否与仓库 repo 一致")
+ case resp.StatusCode >= 300:
+ return nil, "", fmt.Errorf("仓库返回 %d: %s", resp.StatusCode, strings.TrimSpace(string(data)))
+ }
+ return data, "", nil
+}
+
+// fetchRegistryToken 按 Bearer 挑战(realm/service/scope)换取访问 token。
+func fetchRegistryToken(challenge, user, pass string) (string, error) {
+ p := parseAuthChallenge(challenge)
+ realm := p["realm"]
+ if realm == "" {
+ return "", fmt.Errorf("Bearer 挑战缺少 realm")
+ }
+ u, err := url.Parse(realm)
+ if err != nil {
+ return "", err
+ }
+ q := u.Query()
+ if p["service"] != "" {
+ q.Set("service", p["service"])
+ }
+ if p["scope"] != "" {
+ q.Set("scope", p["scope"])
+ }
+ u.RawQuery = q.Encode()
+
+ reqx, _ := http.NewRequest(http.MethodGet, u.String(), nil)
+ if user != "" {
+ reqx.SetBasicAuth(user, pass)
+ }
+ resp, err := registryHTTP.Do(reqx)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ data, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode >= 300 {
+ return "", fmt.Errorf("获取仓库 token 失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
+ }
+ var tok struct {
+ Token string `json:"token"`
+ AccessToken string `json:"access_token"`
+ }
+ _ = json.Unmarshal(data, &tok)
+ if tok.Token != "" {
+ return tok.Token, nil
+ }
+ if tok.AccessToken != "" {
+ return tok.AccessToken, nil
+ }
+ return "", fmt.Errorf("token 响应为空")
+}
+
+// parseAuthChallenge 解析 `Bearer realm="..",service="..",scope=".."` → map。
+func parseAuthChallenge(h string) map[string]string {
+ out := map[string]string{}
+ h = strings.TrimSpace(h)
+ if i := strings.IndexByte(h, ' '); i >= 0 {
+ h = h[i+1:] // 去掉前导 "Bearer"
+ }
+ for _, part := range strings.Split(h, ",") {
+ kv := strings.SplitN(part, "=", 2)
+ if len(kv) != 2 {
+ continue
+ }
+ out[strings.TrimSpace(kv[0])] = strings.Trim(strings.TrimSpace(kv[1]), `"`)
+ }
+ return out
+}
+
+// registryBaseURL 补全 scheme:显式带 http(s):// 则原样,否则默认 https://。
+func registryBaseURL(registry string) string {
+ registry = strings.TrimRight(strings.TrimSpace(registry), "/")
+ if strings.HasPrefix(registry, "http://") || strings.HasPrefix(registry, "https://") {
+ return registry
+ }
+ return "https://" + registry
+}
+
+// sortImageTags 展示排序:latest 置顶 → 语义版本降序(新→旧) → 其它字符串降序。
+func sortImageTags(tags []string) []string {
+ var semver, others []string
+ hasLatest := false
+ for _, t := range tags {
+ switch {
+ case t == "latest":
+ hasLatest = true
+ case looksSemver(t):
+ semver = append(semver, t)
+ default:
+ others = append(others, t)
+ }
+ }
+ sort.Slice(semver, func(i, j int) bool { return compareSemver(semver[i], semver[j]) > 0 })
+ sort.Slice(others, func(i, j int) bool { return others[i] > others[j] })
+ out := make([]string, 0, len(tags))
+ if hasLatest {
+ out = append(out, "latest")
+ }
+ out = append(out, semver...)
+ return append(out, others...)
+}
+
+// parseVer 把 "v0.1.1" / "0.1.1-rc1" 解析成数字段([]int),取每段前导数字;无法解析返回 false。
+func parseVer(s string) ([]int, bool) {
+ s = strings.TrimPrefix(strings.TrimSpace(s), "v")
+ if s == "" {
+ return nil, false
+ }
+ nums := make([]int, 0, 4)
+ for _, seg := range strings.Split(s, ".") {
+ digits := seg
+ if i := strings.IndexFunc(seg, func(r rune) bool { return r < '0' || r > '9' }); i >= 0 {
+ digits = seg[:i] // 截断预发布后缀,如 "1-rc1" → "1"
+ }
+ if digits == "" {
+ return nil, false
+ }
+ n, err := strconv.Atoi(digits)
+ if err != nil {
+ return nil, false
+ }
+ nums = append(nums, n)
+ }
+ if len(nums) == 0 {
+ return nil, false
+ }
+ return nums, true
+}
+
+func looksSemver(s string) bool { _, ok := parseVer(s); return ok }
+
+// compareSemver 语义版本比较:a>b 返回 1,a vb {
+ return 1
+ }
+ return -1
+ }
+ }
+ return 0
+}
diff --git a/apps/services/modules/console/server.go b/apps/services/modules/console/server.go
index 883c726e..37eb0c85 100644
--- a/apps/services/modules/console/server.go
+++ b/apps/services/modules/console/server.go
@@ -100,6 +100,7 @@ func (this *serverComp) routes(eng *gin.Engine) {
apps.POST("/apps/test", this.appsTest)
apps.POST("/apps/analyze", this.appsAnalyze)
apps.POST("/apps/upgrade", this.appsUpgrade) // 远程升级:SSH 到部署环境 → docker login/pull/up 重启(NDJSON 流式回显)
+ apps.POST("/apps/tags", this.appsTags) // 列镜像仓库可用 tag(升级弹窗下拉选版本)
apps.POST("/apps/add", this.appsAdd)
apps.POST("/apps/update", this.appsUpdate)
apps.POST("/apps/del", this.appsDel)
|