From 56f9fd86668afd1eff097df02e77e24549679cd6 Mon Sep 17 00:00:00 2001 From: l429609201 <429609201@qq.com> Date: Thu, 27 Aug 2026 16:18:44 +0800 Subject: [PATCH 01/34] =?UTF-8?q?```=20feat(container):=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20Docker=20API=20=E7=89=88=E6=9C=AC=E6=84=9F=E7=9F=A5?= =?UTF-8?q?=E7=9A=84=E5=AE=B9=E5=99=A8=E9=85=8D=E7=BD=AE=E6=B8=85=E7=90=86?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改 SanitizeCreateConfig 函数签名,增加 apiVersion 参数用于版本感知清理 - 新增 DockerManager.APIVersion 方法获取协商后的 API 版本信息 - 实现对低版本 daemon (API < 1.44) 的 per-network MacAddress 字段自动清理 - 修复在群晖 DSM 等非标准环境中容器重建失败的问题 - 添加完整的集成测试验证不同 API 版本环境下的配置处理逻辑 - 更新所有调用点以传递正确的 API 版本信息 ``` --- internal/module/containerops/recreate.go | 2 +- internal/svc/dockermanager.go | 16 ++ internal/utiles/helper.go | 2 +- internal/utiles/restorecontainer.go | 2 +- internal/utiles/sanitizecontainerconfig.go | 45 ++++-- ...anitizecontainerconfig_integration_test.go | 138 ++++++++++++++++++ .../utiles/sanitizecontainerconfig_test.go | 50 ++++++- internal/utiles/updatecontainer.go | 2 +- 8 files changed, 234 insertions(+), 23 deletions(-) create mode 100644 internal/utiles/sanitizecontainerconfig_integration_test.go diff --git a/internal/module/containerops/recreate.go b/internal/module/containerops/recreate.go index dd26f291..d96b6bff 100644 --- a/internal/module/containerops/recreate.go +++ b/internal/module/containerops/recreate.go @@ -129,7 +129,7 @@ func (s *Service) Recreate(ctx context.Context, id string, spec EditSpec, progre } // 修正非标准守护进程(典型为群晖 DSM)返回的配置,避免删除旧容器后创建失败 - utiles.SanitizeCreateConfig(name, &newConfig, &newHostConfig, networkingConfig) + utiles.SanitizeCreateConfig(name, cli.ClientVersion(), &newConfig, &newHostConfig, networkingConfig) report(30, "停止旧容器") timeout := 10 diff --git a/internal/svc/dockermanager.go b/internal/svc/dockermanager.go index 587203a7..2cfe71ac 100644 --- a/internal/svc/dockermanager.go +++ b/internal/svc/dockermanager.go @@ -243,6 +243,22 @@ func (m *DockerManager) sortedRemoteIDs() []string { return ids } +// APIVersion 返回指定主机协商后的 Docker API 版本(如 "1.43")。 +// hostID 为空视为本地;主机无可用连接时返回空串。 +// +// 用途:部分请求字段有最低 API 版本要求(如 per-network MacAddress 需 1.44), +// 面对低版本 daemon 必须提前剔除,否则 SDK 会在本地直接拒绝请求。 +// +// 注意 WithAPIVersionNegotiation 是惰性协商:首次真实请求之前返回的是 SDK 默认上限。 +// 调用方应在已发生过 inspect 等请求之后取用,此时值才反映 daemon 真实能力。 +func (m *DockerManager) APIVersion(hostID string) string { + cli, ok := m.GetClient(hostID) + if !ok || cli == nil { + return "" + } + return cli.ClientVersion() +} + // Ping 测试指定主机连通性;hostID 为空测试本地。 func (m *DockerManager) Ping(ctx context.Context, hostID string) error { cli, ok := m.GetClient(hostID) diff --git a/internal/utiles/helper.go b/internal/utiles/helper.go index 2ee66dc0..b4e548d4 100644 --- a/internal/utiles/helper.go +++ b/internal/utiles/helper.go @@ -89,7 +89,7 @@ func doHelperUpdate(ctx context.Context, cli *client.Client, targetID, targetNam // 修正非标准守护进程(典型为群晖 DSM)返回的配置,避免删除旧容器后创建失败。 // 与 UpdateContainerOnHost 保持一致:必须放在停止/删除旧容器之前, // 保证配置有问题时旧容器仍然完好,可直接返回而无需回滚。 - SanitizeCreateConfig(targetName, cfg, hostCfg, netCfg) + SanitizeCreateConfig(targetName, cli.ClientVersion(), cfg, hostCfg, netCfg) // 停止旧容器(主程序)。给足超时,等它优雅退出。 timeout := 15 diff --git a/internal/utiles/restorecontainer.go b/internal/utiles/restorecontainer.go index cd5db61b..f5963c29 100644 --- a/internal/utiles/restorecontainer.go +++ b/internal/utiles/restorecontainer.go @@ -78,7 +78,7 @@ func RestoreContainer(ctx *svc.ServiceContext, filename string, taskID string) e continue } // 备份文件可能来自群晖等非标准环境,创建前先修正不兼容字段 - SanitizeCreateConfig(containerInfo.Name, containerInfo.Config, containerInfo.HostConfig, containerInfo.NetworkingConfig) + SanitizeCreateConfig(containerInfo.Name, ctx.DockerClient.ClientVersion(), containerInfo.Config, containerInfo.HostConfig, containerInfo.NetworkingConfig) _, err = ctx.DockerClient.ContainerCreate(context.TODO(), containerInfo.Config, containerInfo.HostConfig, containerInfo.NetworkingConfig, nil, containerInfo.Name) if err != nil { logx.Errorf("Failed to create container: %v", err) diff --git a/internal/utiles/sanitizecontainerconfig.go b/internal/utiles/sanitizecontainerconfig.go index aa26fe4c..48a0d271 100644 --- a/internal/utiles/sanitizecontainerconfig.go +++ b/internal/utiles/sanitizecontainerconfig.go @@ -14,30 +14,39 @@ package utiles // "invalid port range: value is empty" 拒绝。 // 3. HostConfig.Devices 的 CgroupPermissions 为空:创建时报 "empty device mode"。 // 4. 非 host 网络下 EndpointsConfig 里残留 MacAddress,与新容器网络配置冲突。 +// 5. daemon API < 1.44 不支持 per-network MacAddress,带上会被 SDK 直接拒绝 +// (报错 "specify mac-address per network" requires API version 1.44)。 // // 处理原则:只做「补齐」与「剔除明显非法值」,不改变用户的有效配置语义。 import ( "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/versions" "github.com/docker/go-connections/nat" "github.com/zeromicro/go-zero/core/logx" ) +// endpointMacMinAPIVersion per-network MacAddress 字段要求的最低 daemon API 版本。 +// 与 SDK client.ContainerCreate 内部的版本门禁保持一致。 +const endpointMacMinAPIVersion = "1.44" + // SanitizeCreateConfig 在创建新容器前修正 inspect 结果中的非标准字段。 // // 必须在停止/删除旧容器之前调用:这样配置不兼容时可以提前失败, // 旧容器仍完好,不会出现「删了旧的、新的没起来」的情况。 // -// containerName 仅用于日志定位。函数原地修改传入的配置对象。 -func SanitizeCreateConfig(containerName string, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig) { +// containerName 仅用于日志定位;apiVersion 为与 daemon 协商后的 API 版本 +// (取自 cli.ClientVersion()),空串表示未知、跳过版本相关的清理。 +// 函数原地修改传入的配置对象。 +func SanitizeCreateConfig(containerName, apiVersion string, config *container.Config, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig) { if config == nil || hostConfig == nil { return } sanitizePorts(containerName, config, hostConfig) sanitizeDevices(containerName, hostConfig) - sanitizeEndpointMac(containerName, hostConfig, networkingConfig) + sanitizeEndpointMac(containerName, apiVersion, hostConfig, networkingConfig) } // sanitizePorts 补齐 ExposedPorts 并剔除端口号为空的非法映射。 @@ -83,24 +92,38 @@ func sanitizeDevices(containerName string, hostConfig *container.HostConfig) { } } -// sanitizeEndpointMac 清理与网络模式冲突的 MAC 地址残留。 -func sanitizeEndpointMac(containerName string, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig) { +// sanitizeEndpointMac 清理与网络模式或 daemon 版本冲突的 MAC 地址残留。 +func sanitizeEndpointMac(containerName, apiVersion string, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig) { if networkingConfig == nil || len(networkingConfig.EndpointsConfig) == 0 { return } - // 仅 host 网络模式需要处理:该模式下不允许指定 MAC,带上会被守护进程拒绝。 - // 其他网络模式下 MAC 是用户的有效配置,必须原样保留。 + + // 判定是否需要清除:满足任一条件即清。 // - // 这里不用 NetworkMode.IsHost():该方法在 Docker SDK 中是平台相关实现, - // 在 Windows 编译目标下对 "host" 会返回 false,行为不一致。直接比较字符串。 - if string(hostConfig.NetworkMode) != "host" { + // 1) host 网络模式:该模式下不允许指定 MAC,带上会被守护进程拒绝。 + // 这里不用 NetworkMode.IsHost():该方法在 Docker SDK 中是平台相关实现, + // 在 Windows 编译目标下对 "host" 会返回 false,行为不一致。直接比较字符串。 + isHostNetwork := string(hostConfig.NetworkMode) == "host" + + // 2) daemon API < 1.44:per-network MacAddress 是 1.44 才引入的字段, + // 低版本 daemon 下 SDK 会在本地直接拒绝请求(不发出),导致 + // 「旧容器已删、新容器建不起来」。此时 MAC 本就无法生效,清掉是安全的。 + // apiVersion 为空表示未知,保守起见不做处理。 + isLegacyAPI := apiVersion != "" && versions.LessThan(apiVersion, endpointMacMinAPIVersion) + + if !isHostNetwork && !isLegacyAPI { return } + + reason := "host 网络模式" + if isLegacyAPI { + reason = "daemon API 版本 " + apiVersion + " 低于 " + endpointMacMinAPIVersion + ",不支持 per-network MAC" + } for netName, endpoint := range networkingConfig.EndpointsConfig { if endpoint == nil || endpoint.MacAddress == "" { continue } endpoint.MacAddress = "" - logx.Infof("容器 %s: host 网络模式下清除网络 %s 的 MAC 地址残留", containerName, netName) + logx.Infof("容器 %s: %s,已清除网络 %s 的 MAC 地址", containerName, reason, netName) } } diff --git a/internal/utiles/sanitizecontainerconfig_integration_test.go b/internal/utiles/sanitizecontainerconfig_integration_test.go new file mode 100644 index 00000000..dcd14091 --- /dev/null +++ b/internal/utiles/sanitizecontainerconfig_integration_test.go @@ -0,0 +1,138 @@ +package utiles + +import ( + "testing" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/go-connections/nat" +) + +// TestSanitizeCreateConfig_集成测试_API143环境完整流程 模拟 API 1.43 环境下的完整更新流程。 +// 验证所有可能导致创建失败的配置项都被正确清理。 +func TestSanitizeCreateConfig_集成测试_API143环境完整流程(t *testing.T) { + // 模拟从 inspect 获取的旧容器配置(包含所有潜在问题) + config := &container.Config{ + Image: "nginx:latest", + // 问题1: ExposedPorts 为 nil(群晖常见) + ExposedPorts: nil, + } + + hostConfig := &container.HostConfig{ + NetworkMode: "bridge", // 非 host 网络模式 + // 问题2: 端口映射存在,但有空端口号 + PortBindings: nat.PortMap{ + "80/tcp": {{HostPort: "8080"}}, + "/tcp": {{HostPort: "9090"}}, // 空端口号,会导致创建失败 + }, + Resources: container.Resources{ + // 问题3: 设备权限为空 + Devices: []container.DeviceMapping{ + { + PathOnHost: "/dev/dri", + PathInContainer: "/dev/dri", + CgroupPermissions: "", // 空权限,会导致创建失败 + }, + }, + }, + } + + // 问题4: bridge 网络下有 MAC 地址(API 1.43 不支持) + networkingConfig := &network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{ + "bridge": { + MacAddress: "02:42:ac:11:00:02", // API 1.43 不支持,会导致创建失败 + }, + }, + } + + // 执行修正(模拟 API 1.43 环境) + SanitizeCreateConfig("test-container", "1.43", config, hostConfig, networkingConfig) + + // 验证1: ExposedPorts 已补齐 + if config.ExposedPorts == nil { + t.Fatal("ExposedPorts 应被初始化") + } + if _, ok := config.ExposedPorts["80/tcp"]; !ok { + t.Errorf("应依据 PortBindings 补齐 80/tcp") + } + + // 验证2: 空端口号映射已剔除 + if _, ok := hostConfig.PortBindings["/tcp"]; ok { + t.Error("空端口号映射 '/tcp' 应被剔除") + } + if _, ok := hostConfig.PortBindings["80/tcp"]; !ok { + t.Error("有效端口映射 '80/tcp' 应保留") + } + + // 验证3: 设备权限已补齐 + if hostConfig.Resources.Devices[0].CgroupPermissions != "rwm" { + t.Errorf("设备权限应补为 'rwm',实际: %s", hostConfig.Resources.Devices[0].CgroupPermissions) + } + + // 验证4: API 1.43 环境下 MAC 地址已清除 + if networkingConfig.EndpointsConfig["bridge"].MacAddress != "" { + t.Errorf("API 1.43 环境下 bridge 网络的 MAC 应被清除,实际: %s", + networkingConfig.EndpointsConfig["bridge"].MacAddress) + } + + t.Logf("✅ API 1.43 环境集成测试通过:所有配置问题已修正") +} + +// TestSanitizeCreateConfig_集成测试_API144环境完整流程 模拟 API 1.44 环境下的完整更新流程。 +// 验证在新版 API 下,per-network MAC 地址被正确保留。 +func TestSanitizeCreateConfig_集成测试_API144环境完整流程(t *testing.T) { + // 模拟从 inspect 获取的旧容器配置 + config := &container.Config{ + Image: "nginx:latest", + ExposedPorts: nil, + } + + hostConfig := &container.HostConfig{ + NetworkMode: "bridge", + PortBindings: nat.PortMap{ + "80/tcp": {{HostPort: "8080"}}, + "/tcp": {{HostPort: "9090"}}, + }, + Resources: container.Resources{ + Devices: []container.DeviceMapping{ + { + PathOnHost: "/dev/dri", + PathInContainer: "/dev/dri", + CgroupPermissions: "", + }, + }, + }, + } + + // bridge 网络下有 MAC 地址(API 1.44 支持) + networkingConfig := &network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{ + "bridge": { + MacAddress: "02:42:ac:11:00:02", // API 1.44 支持,应保留 + }, + }, + } + + // 执行修正(模拟 API 1.44 环境) + SanitizeCreateConfig("test-container", "1.44", config, hostConfig, networkingConfig) + + // 验证1-3: 基础配置问题仍然修正 + if config.ExposedPorts == nil { + t.Fatal("ExposedPorts 应被初始化") + } + if _, ok := hostConfig.PortBindings["/tcp"]; ok { + t.Error("空端口号映射 '/tcp' 应被剔除") + } + if hostConfig.Resources.Devices[0].CgroupPermissions != "rwm" { + t.Errorf("设备权限应补为 'rwm'") + } + + // 验证4: API 1.44 环境下 MAC 地址应保留 + if networkingConfig.EndpointsConfig["bridge"].MacAddress != "02:42:ac:11:00:02" { + t.Errorf("API 1.44 环境下 bridge 网络的 MAC 应保留,实际: %s", + networkingConfig.EndpointsConfig["bridge"].MacAddress) + } + + t.Logf("✅ API 1.44 环境集成测试通过:MAC 地址正确保留,其他问题已修正") +} diff --git a/internal/utiles/sanitizecontainerconfig_test.go b/internal/utiles/sanitizecontainerconfig_test.go index 75c84951..b268edf7 100644 --- a/internal/utiles/sanitizecontainerconfig_test.go +++ b/internal/utiles/sanitizecontainerconfig_test.go @@ -17,7 +17,7 @@ func TestSanitizeCreateConfig_补齐缺失的暴露端口(t *testing.T) { }, } - SanitizeCreateConfig("test", config, hostConfig, nil) + SanitizeCreateConfig("test", "1.44", config, hostConfig, nil) if config.ExposedPorts == nil { t.Fatal("ExposedPorts 应被初始化") @@ -39,7 +39,7 @@ func TestSanitizeCreateConfig_剔除空端口映射(t *testing.T) { }, } - SanitizeCreateConfig("test", config, hostConfig, nil) + SanitizeCreateConfig("test", "1.44", config, hostConfig, nil) if _, ok := hostConfig.PortBindings["/tcp"]; ok { t.Error("空端口号的映射应被剔除") @@ -64,7 +64,7 @@ func TestSanitizeCreateConfig_补齐设备权限(t *testing.T) { }, } - SanitizeCreateConfig("test", config, hostConfig, nil) + SanitizeCreateConfig("test", "1.44", config, hostConfig, nil) if hostConfig.Devices[0].CgroupPermissions != "rwm" { t.Errorf("空权限应补为 rwm,实际: %q", hostConfig.Devices[0].CgroupPermissions) @@ -84,7 +84,7 @@ func TestSanitizeCreateConfig_清理host模式下的MAC残留(t *testing.T) { }, } - SanitizeCreateConfig("test", config, hostConfig, networkingConfig) + SanitizeCreateConfig("test", "1.44", config, hostConfig, networkingConfig) if got := networkingConfig.EndpointsConfig["host"].MacAddress; got != "" { t.Errorf("host 模式下 MAC 应被清空,实际: %q", got) @@ -101,16 +101,50 @@ func TestSanitizeCreateConfig_保留bridge模式下的MAC(t *testing.T) { }, } - SanitizeCreateConfig("test", config, hostConfig, networkingConfig) + SanitizeCreateConfig("test", "1.44", config, hostConfig, networkingConfig) if got := networkingConfig.EndpointsConfig["bridge"].MacAddress; got != "02:42:ac:11:00:02" { t.Errorf("bridge 模式下 MAC 应保留,实际: %q", got) } } +// daemon API 低于 1.44 时不支持 per-network MAC,必须清除,否则 SDK 直接拒绝创建请求。 +func TestSanitizeCreateConfig_低版本API清理bridge模式下的MAC(t *testing.T) { + config := &container.Config{} + hostConfig := &container.HostConfig{NetworkMode: "bridge"} + networkingConfig := &network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{ + "bridge": {MacAddress: "02:42:ac:11:00:02"}, + }, + } + + SanitizeCreateConfig("test", "1.43", config, hostConfig, networkingConfig) + + if got := networkingConfig.EndpointsConfig["bridge"].MacAddress; got != "" { + t.Errorf("API 1.43 下 MAC 应被清空,实际: %q", got) + } +} + +// 版本未知(空串)时保守处理:不动用户的有效配置。 +func TestSanitizeCreateConfig_版本未知时保留MAC(t *testing.T) { + config := &container.Config{} + hostConfig := &container.HostConfig{NetworkMode: "bridge"} + networkingConfig := &network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{ + "bridge": {MacAddress: "02:42:ac:11:00:02"}, + }, + } + + SanitizeCreateConfig("test", "", config, hostConfig, networkingConfig) + + if got := networkingConfig.EndpointsConfig["bridge"].MacAddress; got != "02:42:ac:11:00:02" { + t.Errorf("版本未知时 MAC 应保留,实际: %q", got) + } +} + // 配置缺失时不应 panic。 func TestSanitizeCreateConfig_空配置不panic(t *testing.T) { - SanitizeCreateConfig("test", nil, nil, nil) - SanitizeCreateConfig("test", &container.Config{}, nil, nil) - SanitizeCreateConfig("test", &container.Config{}, &container.HostConfig{}, nil) + SanitizeCreateConfig("test", "1.44", nil, nil, nil) + SanitizeCreateConfig("test", "1.44", &container.Config{}, nil, nil) + SanitizeCreateConfig("test", "1.44", &container.Config{}, &container.HostConfig{}, nil) } diff --git a/internal/utiles/updatecontainer.go b/internal/utiles/updatecontainer.go index 9e77a86e..aa6ebb61 100644 --- a/internal/utiles/updatecontainer.go +++ b/internal/utiles/updatecontainer.go @@ -212,7 +212,7 @@ func UpdateContainerOnHost(ctx context.Context, serviceContext *svc.ServiceConte // 修正非标准守护进程(典型为群晖 DSM)返回的配置,避免删除旧容器后创建失败。 // 必须放在停止/删除旧容器之前,保证配置有问题时旧容器仍然完好。 - SanitizeCreateConfig(name, config, hostConfig, networkingConfig) + SanitizeCreateConfig(name, cli.ClientVersion(), config, hostConfig, networkingConfig) oldTaskProgress.Percentage = 40 oldTaskProgress.Message = "正在停止旧容器" From c76080a67bf345ec2792677a5b75a99cfe42ccc2 Mon Sep 17 00:00:00 2001 From: l429609201 <429609201@qq.com> Date: Thu, 27 Aug 2026 17:00:12 +0800 Subject: [PATCH 02/34] =?UTF-8?q?```=20feat(container):=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E9=95=9C=E5=83=8F=E5=90=8D=E7=A7=B0=E5=A4=84=E7=90=86?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BF=AE=E5=A4=8D=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=90=8E=E6=98=BE=E7=A4=BASHA256=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当同一镜像部署多个容器时,第一个容器更新后会拉取新镜像并重新打tag, 导致其他容器仍在使用的旧镜像变成untagged状态,此时v.Image会变成空字符串。 现在优先使用containerInspect.Config.Image(创建时的镜像名)作为CreateImage, 该字段不会受后续tag变化影响,保持用户友好的镜像名称显示。 同时移除DelOldContainer环境变量控制,改为默认删除旧容器。 fix(bot): 修复面板中镜像名称显示问题,优先使用CreateImage字段 refactor(containerops): 统一镜像更新逻辑,使用CreateImage替代Image字段 docs(version): 更新版本号至v1.0.3 ``` --- .../logic/container/containerslistlogic.go | 23 ++++++++++++++----- internal/logic/container/updatelogic.go | 4 ++-- internal/module/bot/panel_exec.go | 7 ++++-- internal/module/bot/panel_ops.go | 9 ++++++-- internal/module/containerops/service.go | 4 ++-- internal/types/containerInfo.go | 3 +++ internal/utiles/getcontainerlist.go | 12 ++++++++-- version | 2 +- 8 files changed, 47 insertions(+), 17 deletions(-) diff --git a/internal/logic/container/containerslistlogic.go b/internal/logic/container/containerslistlogic.go index 0ac37340..d96e480b 100644 --- a/internal/logic/container/containerslistlogic.go +++ b/internal/logic/container/containerslistlogic.go @@ -77,12 +77,6 @@ func (l *ContainersListLogic) ContainersList() (resp *types.Resp, err error) { containerInfo.Name = "get container name error" l.Error("get container name error" + v.ID) } - if v.Image != "" { - containerInfo.UsingImage = v.Image - } else { - containerInfo.UsingImage = v.ImageID - l.Error("image dont have name" + v.ID) - } // 按容器所属主机查询详情,保证远程容器也能拿到 inspect 信息 containerInspect, err := utiles.GetContainerInspectFromHost(l.svcCtx, v.HostID, v.ID) if err != nil { @@ -92,6 +86,23 @@ func (l *ContainersListLogic) ContainersList() (resp *types.Resp, err error) { if containerInspect.Config != nil { containerInfo.CreateImage = containerInspect.Config.Image } + + // 镜像名优先级: + // 1. Config.Image(创建时的镜像名,最稳定,不受后续 tag 变化影响) + // 2. Image(当前运行镜像的 tag,可能因镜像更新而变成空) + // 3. ImageID(SHA256 digest,最后降级选项) + // + // 背景:同一镜像部署多个容器时,第一个容器更新后会拉取新镜像并重新打 tag, + // 导致其他容器仍在使用的旧镜像变成 untagged 状态,此时 v.Image 会变成空字符串。 + // 使用 Config.Image 可以避免显示 SHA256 digest,保持用户友好的镜像名称。 + if containerInfo.CreateImage != "" { + containerInfo.UsingImage = containerInfo.CreateImage + } else if v.Image != "" { + containerInfo.UsingImage = v.Image + } else { + containerInfo.UsingImage = v.ImageID + l.Error("image dont have name, fallback to ImageID: " + v.ID) + } t := time.Unix(v.Created, 0) containerInfo.CreateTime = t.Format("2006-01-02 15:04:05") containerInfo.RunningTime = v.Status diff --git a/internal/logic/container/updatelogic.go b/internal/logic/container/updatelogic.go index 111bd755..837b4611 100644 --- a/internal/logic/container/updatelogic.go +++ b/internal/logic/container/updatelogic.go @@ -9,7 +9,6 @@ import ( "github.com/l429609201/dockerCopilot/internal/types" "github.com/l429609201/dockerCopilot/internal/utiles" "github.com/zeromicro/go-zero/core/logx" - "os" ) type UpdateLogic struct { @@ -30,7 +29,8 @@ func (l *UpdateLogic) Update(req *types.ContainerUpdateReq) (resp *types.Resp, e resp = &types.Resp{} taskID := uuid.New().String() imageNameAndTag := req.ImageNameAndTag - delOldContainer := os.Getenv("DelOldContainer") != "false" + // 默认删除旧容器(不再使用环境变量控制) + delOldContainer := true // 整体超时时间来自配置,默认 1800 秒 timeoutSec := l.svcCtx.Config.Task.PullTimeoutSec if timeoutSec <= 0 { diff --git a/internal/module/bot/panel_exec.go b/internal/module/bot/panel_exec.go index e0b44b46..4e837d25 100644 --- a/internal/module/bot/panel_exec.go +++ b/internal/module/bot/panel_exec.go @@ -21,8 +21,11 @@ func (b *Bot) sendTagSwitch(chatID int64, id, name, hostID string, messageID int return } hs := "|" + b.svcCtx.DockerManager.HostCode(hostID) - // 解析当前镜像名(去掉 tag) - curImage := c.Image + // 解析当前镜像名(去掉 tag),优先使用 CreateImage + curImage := c.CreateImage + if curImage == "" { + curImage = c.Image // 降级使用 Image 字段 + } repo := curImage if idx := strings.LastIndex(curImage, ":"); idx >= 0 && !strings.Contains(curImage[idx:], "/") { repo = curImage[:idx] diff --git a/internal/module/bot/panel_ops.go b/internal/module/bot/panel_ops.go index 3bb37025..c83f8c03 100644 --- a/internal/module/bot/panel_ops.go +++ b/internal/module/bot/panel_ops.go @@ -18,15 +18,20 @@ func (b *Bot) doUpdate(chatID int64, id, name, hostID string, messageID int64) { b.reply(chatID, "❌ 容器不存在或已被删除") return } + // 优先使用 CreateImage(创建时的镜像名),避免 Image 字段在镜像更新后变空或变成 SHA256 + imageToUse := c.CreateImage + if imageToUse == "" { + imageToUse = c.Image // 降级使用 Image 字段 + } // 沿用容器当前镜像进行更新(拉取同名 tag 的最新镜像并重建),按主机路由 - taskID, err := containerops.NewForHost(b.svcCtx, hostID).Update(c.ID, name, c.Image) + taskID, err := containerops.NewForHost(b.svcCtx, hostID).Update(c.ID, name, imageToUse) if err != nil { b.reply(chatID, fmt.Sprintf("❌ 提交更新失败:%s", err.Error())) return } // 启动进度监听 goroutine,持续编辑消息显示进度 - go b.watchUpdateProgress(chatID, messageID, taskID, name, c.Image) + go b.watchUpdateProgress(chatID, messageID, taskID, name, imageToUse) } // sendContainerLogs 推送容器最近日志(最后 50 行,限制长度避免超 TG 消息上限)。 diff --git a/internal/module/containerops/service.go b/internal/module/containerops/service.go index 2db9a947..50653434 100644 --- a/internal/module/containerops/service.go +++ b/internal/module/containerops/service.go @@ -3,7 +3,6 @@ package containerops import ( "context" "fmt" - "os" "strings" "time" @@ -163,7 +162,8 @@ func (s *Service) Rename(id, newName string) error { // 复用与 HTTP 层一致的任务管理器与自更新逻辑,返回提交的 taskID。 func (s *Service) Update(id, name, imageNameAndTag string) (string, error) { taskID := uuid.New().String() - delOldContainer := os.Getenv("DelOldContainer") != "false" + // 默认删除旧容器(不再使用环境变量控制) + delOldContainer := true timeoutSec := s.svcCtx.Config.Task.PullTimeoutSec if timeoutSec <= 0 { timeoutSec = 1800 diff --git a/internal/types/containerInfo.go b/internal/types/containerInfo.go index 906733c4..66ad8c42 100644 --- a/internal/types/containerInfo.go +++ b/internal/types/containerInfo.go @@ -11,4 +11,7 @@ type Container struct { // 空表示本地主机,兼容历史行为。 HostID string `json:"hostId,omitempty"` HostName string `json:"hostName,omitempty"` + // CreateImage 创建容器时使用的镜像名(来自 Config.Image),不受后续 tag 变化影响。 + // 优先使用此字段进行更新操作,避免 Image 字段在镜像更新后变成空字符串或 SHA256 的问题。 + CreateImage string `json:"createImage,omitempty"` } diff --git a/internal/utiles/getcontainerlist.go b/internal/utiles/getcontainerlist.go index 622863a6..0dd23d36 100644 --- a/internal/utiles/getcontainerlist.go +++ b/internal/utiles/getcontainerlist.go @@ -33,11 +33,19 @@ func GetContainerListFromHost(ctx *svc.ServiceContext, hostID string) ([]MyType. } containerList := make([]MyType.Container, 0, len(dockerContainerList)) for _, dockerContainerInfo := range dockerContainerList { - containerList = append(containerList, MyType.Container{ + c := MyType.Container{ Container: dockerContainerInfo, HostID: host.ID, HostName: host.Name, - }) + } + // 填充 CreateImage:从 inspect 获取 Config.Image(创建时的镜像名) + // 这样即使镜像 tag 更新后 Image 字段变空,CreateImage 仍保持稳定 + if inspectData, err := cli.ContainerInspect(context.Background(), dockerContainerInfo.ID); err == nil { + if inspectData.Config != nil && inspectData.Config.Image != "" { + c.CreateImage = inspectData.Config.Image + } + } + containerList = append(containerList, c) } return containerList, nil } diff --git a/version b/version index 570c7965..e946d6bb 100644 --- a/version +++ b/version @@ -1 +1 @@ -v1.0.2 +v1.0.3 From 0f38740cd8b74535c15797996e941ed92acbe670 Mon Sep 17 00:00:00 2001 From: l429609201 <429609201@qq.com> Date: Thu, 27 Aug 2026 17:52:37 +0800 Subject: [PATCH 03/34] =?UTF-8?q?```=20feat(bot):=20=E4=BC=98=E5=85=88?= =?UTF-8?q?=E4=BD=BF=E7=94=A8CreateImage=E5=AD=97=E6=AE=B5=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E9=95=9C=E5=83=8F=E6=9B=B4=E6=96=B0=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当容器的Image字段在更新后可能变为空或SHA256值时, 优先使用CreateImage字段来获取正确的镜像信息, 并在CreateImage为空时降级使用Image字段作为备选方案。 这修复了在镜像更新后通知中显示错误镜像名称的问题。 ``` --- internal/module/bot/handler.go | 7 ++++++- internal/module/scheduler/notifier.go | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/internal/module/bot/handler.go b/internal/module/bot/handler.go index b3de9890..364e99a8 100644 --- a/internal/module/bot/handler.go +++ b/internal/module/bot/handler.go @@ -2397,10 +2397,15 @@ func (b *Bot) resendUpdateNotification(chatID int64, page int, messageID int64) if name == "" { continue } + // 优先使用 CreateImage,避免镜像更新后 Image 字段变空或变成 SHA256 + imageToUse := c.CreateImage + if imageToUse == "" { + imageToUse = c.Image // 降级使用 Image 字段 + } updateContainers = append(updateContainers, UpdateContainer{ ID: c.ID, Name: name, - Image: c.Image, + Image: imageToUse, }) } diff --git a/internal/module/scheduler/notifier.go b/internal/module/scheduler/notifier.go index 78111d3b..4eab5ddc 100644 --- a/internal/module/scheduler/notifier.go +++ b/internal/module/scheduler/notifier.go @@ -102,7 +102,12 @@ func (s *Scheduler) runUpdateCheck() { if _, ok := muted[name]; ok { continue // 已屏蔽 } - pending = append(pending, notify.UpdateItem{ID: c.ID, Name: name, Image: c.Image}) + // 优先使用 CreateImage,避免镜像更新后 Image 字段变空或变成 SHA256 + imageToUse := c.CreateImage + if imageToUse == "" { + imageToUse = c.Image // 降级使用 Image 字段 + } + pending = append(pending, notify.UpdateItem{ID: c.ID, Name: name, Image: imageToUse}) } if len(pending) == 0 { From c2bb66c08285c9890a599c677c660e78bcf1975f Mon Sep 17 00:00:00 2001 From: l429609201 <429609201@qq.com> Date: Thu, 27 Aug 2026 18:45:42 +0800 Subject: [PATCH 04/34] =?UTF-8?q?```=20feat(bot):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=B1=8F=E8=94=BD=E5=AE=B9=E5=99=A8=E6=9B=B4=E6=96=B0=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 NotifyUpdateWithMutedInfo 方法,支持推送已屏蔽但有更新的容器通知 - 修改 NotifyUpdateWithKeyboard 注释,明确为推送未屏蔽容器的更新通知 - 扩展更新通知键盘,添加"查看被屏蔽的"按钮和回调处理 - 实现 UpdateNotifierWithMuted 接口,区分未屏蔽和已屏蔽的更新容器 - 重构更新通知逻辑,支持在摘要中显示屏蔽容器数量并提供单独查看入口 - 修改分页详情功能,支持切换显示已屏蔽或未屏蔽的容器列表 ``` --- internal/module/bot/bot.go | 21 ++++++- internal/module/bot/handler.go | 90 ++++++++++++++++++++------- internal/module/bot/panel_mute.go | 2 +- internal/module/notify/notifier.go | 10 +++ internal/module/scheduler/notifier.go | 27 +++++--- 5 files changed, 116 insertions(+), 34 deletions(-) diff --git a/internal/module/bot/bot.go b/internal/module/bot/bot.go index 7b524c91..580a8bc4 100644 --- a/internal/module/bot/bot.go +++ b/internal/module/bot/bot.go @@ -118,7 +118,7 @@ func (b *Bot) Notify(title string, text string) { } } -// NotifyUpdateWithKeyboard 推送带交互式键盘的更新通知(每个容器一行操作按钮)。 +// NotifyUpdateWithKeyboard 推送带交互式键盘的容器更新通知(仅未屏蔽)。 // containers 为需要更新的容器列表;参数类型为 notify.UpdateItem,使本方法满足 // notify.UpdateNotifier 接口,让 scheduler 的周期检测能命中带键盘的推送而非纯文本。 func (b *Bot) NotifyUpdateWithKeyboard(containers []UpdateContainer) { @@ -131,7 +131,24 @@ func (b *Bot) NotifyUpdateWithKeyboard(containers []UpdateContainer) { } for _, chatID := range cfg.AllowedChatIDs { - b.sendUpdateNotificationToChat(chatID, containers) + b.sendUpdateNotificationToChat(chatID, containers, nil) + } +} + +// NotifyUpdateWithMutedInfo 推送带屏蔽信息的更新通知(扩展版)。 +// active: 未屏蔽且有更新的容器;muted: 已屏蔽但有更新的容器。 +// 实现 notify.UpdateNotifierWithMuted 接口,让 scheduler 能传递屏蔽信息。 +func (b *Bot) NotifyUpdateWithMutedInfo(active, muted []UpdateContainer) { + b.mu.Lock() + client := b.client + cfg := b.cfg + b.mu.Unlock() + if client == nil || !cfg.Enabled || !cfg.NotifyUpdate { + return + } + + for _, chatID := range cfg.AllowedChatIDs { + b.sendUpdateNotificationToChat(chatID, active, muted) } } diff --git a/internal/module/bot/handler.go b/internal/module/bot/handler.go index 364e99a8..797b47b3 100644 --- a/internal/module/bot/handler.go +++ b/internal/module/bot/handler.go @@ -344,10 +344,16 @@ func (b *Bot) handleCallback(chatID int64, cb *telegram.CallbackQuery) { b.replyUpdateCenter(chatID, messageID, page) return } - // 周期更新提醒「查看并管理」:updnotify| —— 编辑摘要消息为分页详情 + // 周期更新提醒「查看并管理」:updnotify| —— 编辑摘要消息为分页详情(仅未屏蔽) if parts[0] == "updnotify" && len(parts) == 2 { page, _ := strconv.Atoi(parts[1]) - b.resendUpdateNotification(chatID, page, messageID) + b.resendUpdateNotification(chatID, page, messageID, false) + return + } + // 周期更新提醒「查看被屏蔽的」:updmuted| —— 显示已屏蔽但有更新的容器 + if parts[0] == "updmuted" && len(parts) == 2 { + page, _ := strconv.Atoi(parts[1]) + b.resendUpdateNotification(chatID, page, messageID, true) return } // 定时更新完成结果交互:rres||[|...] @@ -462,7 +468,7 @@ func (b *Bot) handleCallback(chatID int64, cb *telegram.CallbackQuery) { // 翻页:notify|page| if len(parts) == 3 { page, _ := strconv.Atoi(parts[2]) - b.resendUpdateNotification(chatID, page, messageID) + b.resendUpdateNotification(chatID, page, messageID, false) // 默认显示未屏蔽 } } return @@ -2060,27 +2066,44 @@ func (b *Bot) executePruneImages(chatID int64, mode string, messageID int64) { // sendUpdateNotificationToChat 周期检测的更新推送入口:只发精简摘要 + 一个「查看并管理」按钮, // 避免一次性铺开大量容器按钮刷屏。点按钮后(回调 updnotify|0)再进入分页详情。 // 仅在有可更新容器时才会被调用(调用方 notifier 已保证 len>0 且已排除屏蔽容器)。 -func (b *Bot) sendUpdateNotificationToChat(chatID int64, containers []UpdateContainer) { - if len(containers) == 0 { +// muted 为已屏蔽但有更新的容器列表,非空时额外提供"查看被屏蔽的"按钮。 +func (b *Bot) sendUpdateNotificationToChat(chatID int64, containers []UpdateContainer, muted []UpdateContainer) { + if len(containers) == 0 && len(muted) == 0 { return } var text strings.Builder text.WriteString("🔔 容器更新提醒\n\n") - text.WriteString(fmt.Sprintf("检测到 %d 个容器有可用更新:\n\n", len(containers))) - // 摘要只列容器名(最多展示 15 个,超出以省略提示),不含按钮,保持消息简洁 - const maxPreview = 15 - for i, c := range containers { - if i >= maxPreview { - text.WriteString(fmt.Sprintf("… 还有 %d 个\n", len(containers)-maxPreview)) - break + if len(containers) > 0 { + text.WriteString(fmt.Sprintf("检测到 %d 个容器有可用更新:\n\n", len(containers))) + // 摘要只列容器名(最多展示 15 个,超出以省略提示),不含按钮,保持消息简洁 + const maxPreview = 15 + for i, c := range containers { + if i >= maxPreview { + text.WriteString(fmt.Sprintf("… 还有 %d 个\n", len(containers)-maxPreview)) + break + } + text.WriteString(fmt.Sprintf("🔺 %s\n", escapeHTML(c.Name))) } - text.WriteString(fmt.Sprintf("🔺 %s\n", escapeHTML(c.Name))) + text.WriteString("\n点击下方按钮查看详情并逐个更新。") + } else { + text.WriteString("当前没有未屏蔽的容器需要更新。") } - text.WriteString("\n点击下方按钮查看详情并逐个更新 / 屏蔽。") - // 单个「查看并管理」按钮,点击进入分页详情(编辑本条消息) - kb := &telegram.InlineKeyboardMarkup{InlineKeyboard: [][]telegram.InlineKeyboardButton{{ - {Text: "📋 查看并管理", CallbackData: "updnotify|0"}, - }}} + + // 构建按钮:主按钮始终是"查看并管理"(显示未屏蔽的),如果有屏蔽容器则加第二个按钮 + var buttons [][]telegram.InlineKeyboardButton + if len(containers) > 0 { + buttons = append(buttons, []telegram.InlineKeyboardButton{ + {Text: "📋 查看并管理", CallbackData: "updnotify|0"}, + }) + } + if len(muted) > 0 { + text.WriteString(fmt.Sprintf("\n\n💤 另有 %d 个已屏蔽容器有更新", len(muted))) + buttons = append(buttons, []telegram.InlineKeyboardButton{ + {Text: "👁 查看被屏蔽的", CallbackData: "updmuted|0"}, + }) + } + + kb := &telegram.InlineKeyboardMarkup{InlineKeyboard: buttons} b.replyKeyboard(chatID, text.String(), kb) } @@ -2372,7 +2395,8 @@ func (b *Bot) setUpdateInterval(chatID int64, interval string, messageID int64) } // resendUpdateNotification 重新获取更新列表并发送通知(用于翻页)。 -func (b *Bot) resendUpdateNotification(chatID int64, page int, messageID int64) { +// mutedOnly: true 时只显示已屏蔽的容器,false 时只显示未屏蔽的容器。 +func (b *Bot) resendUpdateNotification(chatID int64, page int, messageID int64, mutedOnly bool) { // 获取所有主机的有更新容器:与周期检测摘要(scheduler 的 pending)保持同一数据源, // 避免"摘要用 GetAllContainers(含远程主机)、详情用 GetContainerList(仅本地)"导致两者内容对不上。 containers, err := utiles.GetAllContainers(b.svcCtx) @@ -2382,9 +2406,14 @@ func (b *Bot) resendUpdateNotification(chatID int64, page int, messageID int64) } containers = utiles.CheckImageUpdate(b.svcCtx, containers) - // 筛选有更新的容器 - // 注意:此处**不再过滤已屏蔽容器**,屏蔽状态改由详情页按钮就地显示(🔔/🔕)并支持切换。 - // 这样用户屏蔽某容器后仍能在列表看到它、随时取消屏蔽,避免屏蔽即消失导致无法撤销。 + // 获取屏蔽列表 + cfg := b.svcCtx.AppConfig.Get() + mutedSet := make(map[string]struct{}, len(cfg.Telegram.MutedContainers)) + for _, m := range cfg.Telegram.MutedContainers { + mutedSet[m] = struct{}{} + } + + // 筛选有更新的容器,按 mutedOnly 参数过滤 var updateContainers []UpdateContainer for _, c := range containers { if !c.Update { @@ -2397,6 +2426,16 @@ func (b *Bot) resendUpdateNotification(chatID int64, page int, messageID int64) if name == "" { continue } + + // 根据 mutedOnly 决定是否包含此容器 + _, isMuted := mutedSet[name] + if mutedOnly && !isMuted { + continue // 只显示屏蔽的,跳过未屏蔽的 + } + if !mutedOnly && isMuted { + continue // 只显示未屏蔽的,跳过屏蔽的 + } + // 优先使用 CreateImage,避免镜像更新后 Image 字段变空或变成 SHA256 imageToUse := c.CreateImage if imageToUse == "" { @@ -2410,7 +2449,12 @@ func (b *Bot) resendUpdateNotification(chatID int64, page int, messageID int64) } if len(updateContainers) == 0 { - text := "✅ 当前没有需要更新的容器" + var text string + if mutedOnly { + text = "✅ 当前没有已屏蔽的容器需要更新" + } else { + text = "✅ 当前没有未屏蔽的容器需要更新" + } if messageID > 0 { b.editMessage(chatID, messageID, text) } else { diff --git a/internal/module/bot/panel_mute.go b/internal/module/bot/panel_mute.go index 60cb4053..3379a071 100644 --- a/internal/module/bot/panel_mute.go +++ b/internal/module/bot/panel_mute.go @@ -147,5 +147,5 @@ func (b *Bot) toggleMute(chatID int64, name string, page int, messageID int64) { // 让被屏蔽的容器按钮就地变为 🔕、可再次点击恢复。 func (b *Bot) toggleMuteInPlace(chatID int64, name string, page int, messageID int64) { b.setMuteState(name) - b.resendUpdateNotification(chatID, page, messageID) + b.resendUpdateNotification(chatID, page, messageID, false) // 始终回到未屏蔽列表 } diff --git a/internal/module/notify/notifier.go b/internal/module/notify/notifier.go index fa144e7c..c5d1b3b2 100644 --- a/internal/module/notify/notifier.go +++ b/internal/module/notify/notifier.go @@ -26,6 +26,16 @@ type UpdateNotifier interface { NotifyUpdateWithKeyboard(items []UpdateItem) } +// UpdateNotifierWithMuted 是"带屏蔽信息的更新通知"能力接口(扩展版)。 +// 在 UpdateNotifier 基础上,额外传递已屏蔽但有更新的容器列表, +// 供通知渠道提供"查看被屏蔽的"入口。 +type UpdateNotifierWithMuted interface { + // NotifyUpdateWithMutedInfo 推送更新通知,区分未屏蔽和已屏蔽的容器。 + // active: 未屏蔽且有更新的容器(主要展示) + // muted: 已屏蔽但有更新的容器(提供查看入口) + NotifyUpdateWithMutedInfo(active, muted []UpdateItem) +} + // RuleResultNotifier 是"带交互式键盘的定时更新完成通知"能力接口。 // 通知渠道(如 Telegram Bot)实现它后,完成消息正文只展示统计+已更新列表, // 跳过/失败改由内联按钮按需查看,并支持一键重试全部失败; diff --git a/internal/module/scheduler/notifier.go b/internal/module/scheduler/notifier.go index 4eab5ddc..eba7a731 100644 --- a/internal/module/scheduler/notifier.go +++ b/internal/module/scheduler/notifier.go @@ -89,8 +89,9 @@ func (s *Scheduler) runUpdateCheck() { muted[n] = struct{}{} } - // 收集"有更新且未屏蔽"的容器 + // 收集"有更新且未屏蔽"的容器(恢复屏蔽过滤,与详情页分离) var pending []notify.UpdateItem + var mutedPending []notify.UpdateItem // 已屏蔽但有更新的容器 for _, c := range containers { if !c.Update { continue @@ -99,32 +100,42 @@ func (s *Scheduler) runUpdateCheck() { if name == "" { continue } - if _, ok := muted[name]; ok { - continue // 已屏蔽 - } // 优先使用 CreateImage,避免镜像更新后 Image 字段变空或变成 SHA256 imageToUse := c.CreateImage if imageToUse == "" { imageToUse = c.Image // 降级使用 Image 字段 } - pending = append(pending, notify.UpdateItem{ID: c.ID, Name: name, Image: imageToUse}) + item := notify.UpdateItem{ID: c.ID, Name: name, Image: imageToUse} + if _, ok := muted[name]; ok { + mutedPending = append(mutedPending, item) // 已屏蔽 + } else { + pending = append(pending, item) // 未屏蔽 + } } - if len(pending) == 0 { + if len(pending) == 0 && len(mutedPending) == 0 { return } - // 优先调用带交互式键盘的通知(Telegram Bot 实现了 notify.UpdateNotifier) + // 优先调用带交互式键盘的通知(Telegram Bot 实现了 notify.UpdateNotifierWithMuted) + if kbNotifier, ok := s.notifier.(notify.UpdateNotifierWithMuted); ok { + kbNotifier.NotifyUpdateWithMutedInfo(pending, mutedPending) + return + } + // 降级:调用普通的带键盘通知(仅传未屏蔽的) if kbNotifier, ok := s.notifier.(notify.UpdateNotifier); ok { kbNotifier.NotifyUpdateWithKeyboard(pending) return } - // 回退到普通文本通知 + // 回退到普通文本通知(仅通知未屏蔽的) var msg strings.Builder msg.WriteString(fmt.Sprintf("检测到 %d 个容器有可用更新:\n\n", len(pending))) for _, c := range pending { msg.WriteString(fmt.Sprintf("🔺 %s\n %s\n", c.Name, shortImage(c.Image))) } + if len(mutedPending) > 0 { + msg.WriteString(fmt.Sprintf("\n(另有 %d 个已屏蔽容器有更新)", len(mutedPending))) + } msg.WriteString("\n💡 可在面板或发送 /update_all 进行更新") if s.notifier != nil { s.notifier.Notify("🔔 容器更新提醒", msg.String()) From 43ecf5266859651425743e7e7aed4860f6d32c32 Mon Sep 17 00:00:00 2001 From: l429609201 <429609201@qq.com> Date: Thu, 27 Aug 2026 19:17:46 +0800 Subject: [PATCH 05/34] =?UTF-8?q?```=20feat(compose):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20Docker=20CLI=20=E6=94=AF=E6=8C=81=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E6=8C=89=E9=92=AE=E5=9B=BD=E9=99=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 Dockerfile 中安装 docker-cli 和 docker-cli-compose 插件以支持 docker compose 命令执行 - 将前端界面的操作按钮标题从英文改为中文(启动、停止、重启、拉取) - 增强错误处理逻辑,当 Docker 命令未找到时提供更清晰的错误提示和解决 方案说明 ``` --- docker/Dockerfile | 3 ++- frontend-react/src/components/Compose.jsx | 8 ++++---- internal/module/compose/runner.go | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index a4582ba1..65751651 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -52,7 +52,8 @@ COPY --from=backend /out/dockerCopilot ./dockerCopilot COPY etc/. ./etc COPY start.sh ./start.sh -RUN apk add --no-cache tzdata \ +# 安装时区数据、Docker CLI 和 Compose 插件(用于执行 docker compose 命令) +RUN apk add --no-cache tzdata docker-cli docker-cli-compose \ && chmod +x start.sh \ && chmod +x dockerCopilot diff --git a/frontend-react/src/components/Compose.jsx b/frontend-react/src/components/Compose.jsx index 03111a91..b984d7a4 100644 --- a/frontend-react/src/components/Compose.jsx +++ b/frontend-react/src/components/Compose.jsx @@ -86,10 +86,10 @@ export function Compose() {
{p.composeFile}
- doAction(p, 'up')} icon={Play} title="up" color="emerald" /> - doAction(p, 'down')} icon={Square} title="down" color="red" /> - doAction(p, 'restart')} icon={RotateCw} title="restart" color="blue" /> - doAction(p, 'pull')} icon={Download} title="pull" color="gray" /> + doAction(p, 'up')} icon={Play} title="启动" color="emerald" /> + doAction(p, 'down')} icon={Square} title="停止" color="red" /> + doAction(p, 'restart')} icon={RotateCw} title="重启" color="blue" /> + doAction(p, 'pull')} icon={Download} title="拉取" color="gray" /> : @@ -324,7 +324,7 @@ export function ContainerEditModal({ container, onClose, onSuccess }) { setNewFolderName(e.target.value)} + placeholder="请输入文件夹名称" + className="input w-full mb-4" + autoFocus + /> +
+ + +
+
+ + )} + + {/* 新建 Compose 文件对话框 */} + {showCreateFile && ( +
+
+

新建 Compose 文件

+
+ + setNewFileName(e.target.value)} + placeholder="docker-compose.yml" + className="input w-full" + autoFocus + /> +

推荐使用 docker-compose.yml 或 compose.yaml

+
+
+ + +
+
+
+ )} + + + {/* 文件编辑器弹窗 */} + {editingFile && ( +
+
+ {/* 编辑器头部 */} +
+

+ + 编辑文件:{editingFile.name} +

+ +
+ + {/* 文件内容编辑区 */} +
+ {loadingFile ? ( +
+ 加载中... +
+ ) : ( + <> +