@@ -121,18 +173,25 @@ function TaskGroup({ title, count, children }) {
}
function TaskRow({ task, onRemove, onCancel }) {
- const { percentage = 0, message, detailMsg, isDone, failed, canceled, title, layers = [] } = task
+ const { percentage = 0, message, detailMsg, isDone, failed, canceled, title, layers = [], updatableImages = [] } = task
// 分层子进度展开状态(有分层数据时才可展开)
const [expanded, setExpanded] = useState(false)
+ // 可更新镜像列表展开状态(「检查镜像更新」任务完成且有可更新项时可展开)
+ const [updExpanded, setUpdExpanded] = useState(false)
const hasLayers = Array.isArray(layers) && layers.length > 0
+ const hasUpdatable = Array.isArray(updatableImages) && updatableImages.length > 0
+ // 展开/折叠按钮:分层任务用 expanded,可更新列表任务用 updExpanded(两者互斥,不会同时出现)
+ const canExpand = hasLayers || hasUpdatable
+ const isExpanded = hasLayers ? expanded : updExpanded
+ const toggleExpand = () => (hasLayers ? setExpanded(v => !v) : setUpdExpanded(v => !v))
return (
- {/* 展开/折叠按钮:仅有分层数据时显示 */}
- {hasLayers && (
-
{!isDone && {Math.round(percentage)}%}
@@ -187,6 +252,18 @@ function TaskRow({ task, onRemove, onCancel }) {
))}
)}
+
+ {/* 可更新镜像列表:展开后逐行显示 镜像名:tag */}
+ {hasUpdatable && updExpanded && (
+
+ {updatableImages.map((img, idx) => (
+
+
+
{img.imageName}:{img.imageTag}
+
+ ))}
+
+ )}
)
}
diff --git a/frontend-react/src/config/imageLogos.js b/frontend-react/src/config/imageLogos.js
index ac814090..625db64f 100644
--- a/frontend-react/src/config/imageLogos.js
+++ b/frontend-react/src/config/imageLogos.js
@@ -1,184 +1,117 @@
-// 内置常用镜像logo配置
-// 格式: { "镜像名称": "logo 文件路径" }
-// 支持镜像名称匹配,如 "nginx" 会匹配 "nginx:latest", "nginx:alpine" 等
+// 图标匹配纯函数模块(无副作用、无内部状态)。
+//
+// 设计要点(重构后):
+// - 不再在模块内部异步加载/缓存图标配置,彻底消除"双数据源 + 首屏时序空窗"问题。
+// - 图标数据由调用方(react-query 的 ['icons'] 查询)以「IconItem 数组」形式传入。
+// - IconItem 结构与后端严格一致:
+// { target: string, targetType: 'container'|'image', iconUrl: string, priority: number }
-// 导入图片资源
-import MediaSaberLogo from '../assets/logos/media-saber.png';
-import MoviepilotLogo from '../assets/logos/moviepilot.png';
-import DockerCopilotLogo from '../assets/logos/docker-copilot.png';
-import MTPhotos from '../assets/logos/mt-photos.png';
-import ITToolsLogo from '../assets/logos/it-tools.webp';
-import SubStoreLogo from '../assets/logos/sub-store.webp';
-import JellyfinLogo from '../assets/logos/jellyfin.png';
-import RedisLogo from '../assets/logos/redis.png';
-import PostgresLogo from '../assets/logos/postgres.png';
-import SunPanelLogo from '../assets/logos/sun-panel.png';
-import QinglongLogo from '../assets/logos/qinglong.svg';
-import TransmissionLogo from '../assets/logos/transmission.png';
-import QBittorrentLogo from '../assets/logos/qbittorrent.webp';
-import FnDeskLogo from '../assets/logos/fndesk.png';
-import FNTVLogo from '../assets/logos/fntv.png';
-import CookiecloudLogo from '../assets/logos/cookiecloud.png';
-import CodeServerLogo from '../assets/logos/code-server.png';
-import IYUULogo from '../assets/logos/iyuu.png';
-import LuckyLogo from '../assets/logos/lucky.png';
-import EmbyserverLogo from '../assets/logos/embyserver.png';
-import AudiobookshelfLogo from '../assets/logos/audiobookshelf.png';
-import MySQLLogo from '../assets/logos/mysql.png';
-import OneApiLogo from '../assets/logos/one-api.png';
-import QDLogo from '../assets/logos/qd.png';
-import OneHubogo from '../assets/logos/one-hub.png';
-import ByteMuseLogo from '../assets/logos/byte-muse.jpg';
-import NextChatLogo from '../assets/logos/next-chat.png';
-import MdcNgLogo from '../assets/logos/mdc-ng.png';
-import RichDogLogo from '../assets/logos/rich-dog.svg';
-import MsTmdbLogo from '../assets/logos/ms_tmdb.png';
+// 兼容传入非数组(防御式):统一归一化为数组。
+function normalizeIcons(icons) {
+ if (Array.isArray(icons)) return icons
+ return []
+}
-export const builtInImageLogos = {
- "xylplm/media-saber": MediaSaberLogo,
- "xylplm/bm-simulate-xunlei-api-to-media-saber": MediaSaberLogo,
- "jxxghp/moviepilot-v2": MoviepilotLogo,
- "0nlylty/dockercopilot": DockerCopilotLogo,
- "mtphotos/mt-photos": MTPhotos,
- "kqstone/mt-photos-insightface-unofficial": MTPhotos,
- "mtphotos/mt-photos-ai": MTPhotos,
- "corentinth/it-tools": ITToolsLogo,
- "xream/sub-store": SubStoreLogo,
- "nyanmisaka/jellyfin": JellyfinLogo,
- "redis": RedisLogo,
- "postgres": PostgresLogo,
- "hslr/sun-panel": SunPanelLogo,
- "whyour/qinglong": QinglongLogo,
- "linuxserver/transmission": TransmissionLogo,
- "linuxserver/qbittorrent": QBittorrentLogo,
- "imgzcq/fndesk": FnDeskLogo,
- "qiaokes/fntv-record-view": FNTVLogo,
- "easychen/cookiecloud": CookiecloudLogo,
- "codercom/code-server": CodeServerLogo,
- "iyuucn/iyuuplus": IYUULogo,
- "iyuucn/iyuuplus-dev-nodb": IYUULogo,
- "gdy666/lucky": LuckyLogo,
- "amilys/embyserver": EmbyserverLogo,
- "audiobookshelf": AudiobookshelfLogo,
- "mysql": MySQLLogo,
- "qdtoday/qd": QDLogo,
- "songquanpeng/one-api": OneApiLogo,
- "martialbe/one-api": OneHubogo,
- "envyafish/byte-muse":ByteMuseLogo,
- "yidadaa/chatgpt-next-web":NextChatLogo,
- "mdcng/mdc":MdcNgLogo,
- "zhaoyangguang/rebatedog":RichDogLogo,
- "gatecross/ms_tmdb":MsTmdbLogo,
- "ms_tmdb":MsTmdbLogo,
-};
+// getImageLogo 按镜像名/容器名从图标数组中匹配 iconUrl。
+// 优先级:容器名精确 > 镜像名精确 > 简化名精确 > 大小写不敏感 > 模糊(子串)。
+// @param {string} imageName 镜像名(可含 tag)
+// @param {IconItem[]} icons 图标配置数组(来自后端 /api/icons)
+// @param {string|null} containerName 容器名(可选,优先匹配)
+// @returns {string|null} 匹配到的 iconUrl,无则 null
+export const getImageLogo = (imageName, icons = [], containerName = null) => {
+ const items = normalizeIcons(icons)
+ if (!imageName && !containerName) return null
-// 获取镜像的logo
-// 优先级: 内置logo > 用户自定义 > 默认图标
-export const getImageLogo = (imageName, customLogos = {}) => {
- // 先检查内置logo(优先级最高)
- const baseImageName = imageName.split(':')[0]; // 去掉tag部分
-
- // 优先匹配完整镜像名(包含 registry/namespace)
- if (builtInImageLogos[baseImageName]) {
- return builtInImageLogos[baseImageName];
- }
-
- // 尝试匹配最后一段镜像名(去掉 registry/namespace)
- const simpleName = baseImageName.split('/').pop();
- if (builtInImageLogos[simpleName]) {
- return builtInImageLogos[simpleName];
+ // 1. 容器名精确匹配(容器级优先)
+ if (containerName) {
+ const hit = items.find(
+ (it) => it.targetType === 'container' && it.target === containerName
+ )
+ if (hit) return hit.iconUrl
}
- // 如果仍未匹配,使用关键字(子串)匹配
- for (const [key, url] of Object.entries(builtInImageLogos)) {
- if (!key) continue;
- try {
- if (baseImageName.includes(key) || simpleName.includes(key)) {
- return url;
- }
- } catch (e) {
- // 防御性代码:忽略任何异常并继续
- }
- }
+ if (!imageName) return null
- // 再检查用户自定义的logo
- if (customLogos[imageName]) {
- return customLogos[imageName];
- }
- if (customLogos[baseImageName]) {
- return customLogos[baseImageName];
- }
- if (customLogos[simpleName]) {
- return customLogos[simpleName];
- }
+ const baseImageName = imageName.split(':')[0] // 去掉 tag
+ const simpleName = baseImageName.split('/').pop() // 去掉 registry/namespace
- // 尝试自定义图标的模糊匹配
- for (const [key, url] of Object.entries(customLogos)) {
- if (!key) continue;
- try {
- // 检查key是否是imageName的前缀(处理tag不同的情况)
- if (baseImageName === key || baseImageName.startsWith(key + ':') || baseImageName.startsWith(key + '/')) {
- return url;
- }
- // 反向检查:如果自定义图标配置的是 nginx:latest,但当前是 nginx
- if (key.split(':')[0] === baseImageName) {
- return url;
- }
- } catch (e) {
- // 忽略异常
- }
- }
+ // 存量兼容:历史上手动路径存过带 tag 的 target(如 gitea/gitea:latest),
+ // 这里对 target 同样去 tag 后再比对,无需手动清理 icons.json 即可匹配上。
+ const imgItems = items.filter((it) => it.targetType === 'image')
+ const targetBase = (it) => (it.target || '').split(':')[0] // target 去 tag
- // 没有找到logo,返回null
- return null;
-};
+ // 2. 镜像名精确匹配(完整名,target 去 tag)
+ const exact = imgItems.find((it) => targetBase(it) === baseImageName)
+ if (exact) return exact.iconUrl
-// 获取所有支持的镜像名称列表
-export const getSupportedImageNames = () => {
- return Object.keys(builtInImageLogos);
-};
+ // 3. 简化名精确匹配(target 去 tag 后取末段)
+ const simple = imgItems.find((it) => targetBase(it).split('/').pop() === simpleName)
+ if (simple) return simple.iconUrl
-// 检查镜像是否有内置logo
-export const hasBuiltInLogo = (imageName) => {
- const baseImageName = imageName.split(':')[0];
- if (builtInImageLogos[baseImageName]) return true;
- const simpleName = baseImageName.split('/').pop();
- if (builtInImageLogos[simpleName]) return true;
+ // 4. 大小写不敏感匹配
+ const lowerBase = baseImageName.toLowerCase()
+ const lowerSimple = simpleName.toLowerCase()
+ const ci = imgItems.find((it) => targetBase(it).toLowerCase() === lowerBase)
+ if (ci) return ci.iconUrl
+ const ciSimple = imgItems.find(
+ (it) => targetBase(it).split('/').pop().toLowerCase() === lowerSimple
+ )
+ if (ciSimple) return ciSimple.iconUrl
- // 关键字(子串)匹配
- for (const key of Object.keys(builtInImageLogos)) {
- if (!key) continue;
+ // 5. 模糊匹配(子串):修正方向——用「配置的 target」去包含「当前镜像名」,
+ // 覆盖 target 比镜像名更长/更完整的场景;同时保留反向兜底。
+ const fuzzy = imgItems.find((it) => {
try {
- if (baseImageName.includes(key) || simpleName.includes(key)) return true;
- } catch (e) {
- // 忽略并继续
+ const t = targetBase(it).toLowerCase()
+ if (!t) return false
+ const tSimple = t.split('/').pop()
+ return (
+ t.includes(lowerBase) ||
+ lowerBase.includes(t) ||
+ tSimple === lowerSimple
+ )
+ } catch {
+ return false
}
- }
- return false;
-};
+ })
+ if (fuzzy) return fuzzy.iconUrl
+
+ return null
+}
+
+// getSupportedImageNames 返回所有镜像级图标的 target 列表。
+export const getSupportedImageNames = (icons = []) =>
+ normalizeIcons(icons)
+ .filter((it) => it.targetType === 'image')
+ .map((it) => it.target)
+
+// hasBuiltInLogo 判断某镜像/容器是否已有图标配置。
+export const hasBuiltInLogo = (imageName, icons = [], containerName = null) =>
+ getImageLogo(imageName, icons, containerName) != null
/**
- * 统一解析容器图标 URL(卡片/列表/详情共用,保证优先级一致)。
- * 优先级:容器自定义 iconUrl > 已持久化的 logo(内置/自定义/抓取后落盘)> 实时抓取的 favicon。
+ * resolveContainerIcon 统一解析容器图标 URL(卡片/列表/详情共用,保证优先级一致)。
+ *
+ * 优先级:容器自定义 iconUrl > 容器名匹配 > 镜像名匹配 > 实时抓取的 favicon。
* 说明:持久化结果必须压过实时抓取,否则每次刷新 useFaviconMap 探测回来的地址
- * 会覆盖已固定的图标——多端口容器就表现为「刷新就跳」。
- * 实时 favicon 退化为兜底,仅在该镜像还没有任何图标时生效。
- * getImageLogo 第二参 customLogos 内部已包含"内置 + 自定义 + 模糊匹配"逻辑。
- * @param {object} container 容器对象(需含 id / iconUrl / usingImage)
+ * 会覆盖已固定的图标——多端口容器就表现为「刷新就跳」。实时 favicon 退化为兜底,
+ * 仅在该镜像还没有任何持久化图标时生效。
+ *
+ * @param {object} container 容器对象(需含 id / name / iconUrl / usingImage)
* @param {object} faviconMap 由 useFaviconMap 生成的 {容器id: url} 映射
- * @param {object} customIcons 用户自定义图标配置 {镜像名: url}
- * @returns {string|null} 解析出的图标地址,无则返回 null
+ * @param {IconItem[]} icons 图标配置数组(来自后端 /api/icons)
+ * @returns {string|null} 解析出的图标地址,无则 null
*/
-export const resolveContainerIcon = (container, faviconMap = {}, customIcons = {}) => {
- if (!container) return null;
+export const resolveContainerIcon = (container, faviconMap = {}, icons = []) => {
+ if (!container) return null
// 1. 容器自身已设置的自定义图标优先级最高
- if (container.iconUrl) return container.iconUrl;
- // 2. 内置logo / 用户自定义logo / 抓取后已持久化的图标(getImageLogo 内部已做模糊匹配)
- if (container.usingImage) {
- const logo = getImageLogo(container.usingImage, customIcons || {});
- if (logo) return logo;
+ if (container.iconUrl) return container.iconUrl
+ // 2. 图标配置匹配(容器名优先 > 镜像名)
+ if (container.usingImage || container.name) {
+ const logo = getImageLogo(container.usingImage, icons, container.name)
+ if (logo) return logo
}
// 3. 兜底:本次会话实时抓取到的 favicon(尚未持久化时才会走到这里)
- if (faviconMap && faviconMap[container.id]) return faviconMap[container.id];
- return null;
-};
\ No newline at end of file
+ if (faviconMap && faviconMap[container.id]) return faviconMap[container.id]
+ return null
+}
diff --git a/frontend-react/src/hooks/useFavicon.js b/frontend-react/src/hooks/useFavicon.js
index ba28b70f..e3b98060 100644
--- a/frontend-react/src/hooks/useFavicon.js
+++ b/frontend-react/src/hooks/useFavicon.js
@@ -42,68 +42,6 @@ function writeCache(cache) {
try { localStorage.setItem(CACHE_KEY, JSON.stringify(cache)) } catch { /* 忽略配额错误 */ }
}
-// useFavicon:根据容器暴露端口,探测其站点 favicon。
-// 优先读缓存;未命中时用当前访问 host + 端口,调后端代理抓取。
-// 抓取成功后自动持久化到服务器 /data/images/ 目录。
-// 返回 { faviconUrl, loading }。仅对运行中且有端口的容器尝试。
-export function useFavicon(container) {
- const [faviconUrl, setFaviconUrl] = useState('')
- const [loading, setLoading] = useState(false)
-
- useEffect(() => {
- if (!container?.id) return
- const running = (container.status || '').toLowerCase() === 'running'
- // host 网络模式无端口映射,改用容器暴露端口(即宿主机端口)
- const ports = pickProbePorts(container)
- if (!running || ports.length === 0) return
-
- // 读缓存
- const cache = readCache()
- const hit = cache[container.id]
- if (hit && hit.url && Date.now() - hit.ts < TTL) {
- setFaviconUrl(hit.url)
- return
- }
-
- let cancelled = false
-
- const tryPorts = async () => {
- setLoading(true)
- // 远程容器用其所属主机 IP,本地用当前访问 hostname
- const host = await getHostIP(container.hostId)
- if (cancelled) return
- // 优先尝试常见 Web 端口,其次遍历全部
- const ordered = [...ports].sort((a, b) => scorePort(b) - scorePort(a))
- for (const port of ordered) {
- if (cancelled) return
- try {
- const r = await faviconAPI.resolve(`http://${host}:${port}`)
- const url = r.data?.data?.url
- if (url) {
- if (cancelled) return
- setFaviconUrl(url)
- const next = readCache()
- next[container.id] = { url, ts: Date.now() }
- writeCache(next)
- setLoading(false)
-
- // 成功抓取后,自动持久化到服务器 /data/images/ 目录
- // 后台异步执行,不阻塞 UI
- persistIconToServer(container, url, `http://${host}:${port}`)
-
- return
- }
- } catch { /* 忽略单端口失败,继续下一个 */ }
- }
- if (!cancelled) setLoading(false)
- }
- tryPorts()
- return () => { cancelled = true }
- }, [container?.id, container?.status, (container?.ports || []).join(',')])
-
- return { faviconUrl, loading }
-}
-
// pickProbePorts 选择用于探测 favicon 的端口列表。
// 优先用宿主机映射端口(ports);若为空且是 host 网络模式,则用容器暴露端口(exposedPorts)。
function pickProbePorts(container) {
@@ -124,19 +62,20 @@ function scorePort(port) {
// useFaviconMap:对一批容器批量解析 favicon,返回 { [containerId]: iconUrl }。
// 供列表渲染时按容器 id 取图标,避免在 .map 循环里调用 hook。
-// customIcons 传入已持久化的图标映射:已有图标的镜像不再重复探测,
+// icons 为后端 /api/icons 的图标配置数组:已有图标的镜像不再重复探测,
// 避免多端口容器每次刷新探到不同端口导致图标跳变,同时省掉无谓的网络请求。
-export function useFaviconMap(containers, customIcons = {}) {
+export function useFaviconMap(containers, icons = []) {
const [map, setMap] = useState({})
useEffect(() => {
- const list = (containers || []).filter(
- (c) =>
- (c.status || '').toLowerCase() === 'running' &&
- pickProbePorts(c).length > 0 &&
- // 已有持久化图标(含内置 logo 与模糊匹配)的镜像直接跳过,只对"没图标"的才抓取
- !(c.usingImage && getImageLogo(c.usingImage, customIcons || {}))
- )
+ const list = (containers || []).filter((c) => {
+ const isRunning = (c.status || '').toLowerCase() === 'running'
+ const hasPorts = pickProbePorts(c).length > 0
+ // 已有持久化图标(含容器名/镜像名匹配)的容器直接跳过,只对"没图标"的才抓取
+ const hasLogo = !!getImageLogo(c.usingImage, icons, c.name)
+ return isRunning && hasPorts && !hasLogo
+ })
+
if (list.length === 0) return
let cancelled = false
@@ -149,6 +88,24 @@ export function useFaviconMap(containers, customIcons = {}) {
const hit = cache[c.id]
if (hit && hit.url && Date.now() - hit.ts < TTL) {
result[c.id] = hit.url
+ // 解耦"显示缓存"与"后端持久化":命中 localStorage 仅代表本机显示过,
+ // 不代表后端 icons.json 已写入(首次持久化可能失败/被并发覆盖)。
+ // 能走到这里说明该容器不在过滤时的 hasLogo 命中集(icons 里没有),
+ // 故补写一次持久化,确保图标最终落库。
+ // 修复死角:旧缓存无 src 字段时,用容器实时端口重建一个访问地址再补写,
+ // 而非直接跳过(否则该容器会永远进抓取队列却永不落库)。
+ let src = hit.src
+ if (!src) {
+ const host = await getHostIP(c.hostId)
+ if (cancelled) return
+ const ordered = [...pickProbePorts(c)].sort((a, b) => scorePort(b) - scorePort(a))
+ if (ordered.length > 0) {
+ src = `http://${host}:${ordered[0]}`
+ cache[c.id] = { ...hit, src } // 回填 src,下次直接可用
+ cacheDirty = true
+ }
+ }
+ if (src) persistIconToServer(c, src)
continue
}
// 远程容器用其所属主机 IP,本地用当前访问 hostname
@@ -158,15 +115,18 @@ export function useFaviconMap(containers, customIcons = {}) {
for (const port of ordered) {
if (cancelled) return
try {
- const r = await faviconAPI.resolve(`http://${host}:${port}`)
+ const src = `http://${host}:${port}`
+ const r = await faviconAPI.resolve(src)
const url = r.data?.data?.url
if (url) {
result[c.id] = url
- cache[c.id] = { url, ts: Date.now() }
+ // 缓存额外记录容器访问地址 src:命中缓存补写持久化时,
+ // 后端 fetchIcon 需要容器地址去 resolve+落盘,仅有 favicon url 不够。
+ cache[c.id] = { url, src, ts: Date.now() }
cacheDirty = true
// 批量抓取时也自动持久化到服务器
- persistIconToServer(c, url, `http://${host}:${port}`)
+ persistIconToServer(c, src)
break
}
@@ -179,28 +139,27 @@ export function useFaviconMap(containers, customIcons = {}) {
}
run()
return () => { cancelled = true }
- // customIcons 变化后重新评估:新持久化的图标会让对应容器从待抓取列表中移除
- }, [(containers || []).map((c) => c.id).join(','), Object.keys(customIcons || {}).join(',')])
+ // icons 变化后重新评估:新持久化的图标会让对应容器从待抓取列表中移除
+ }, [(containers || []).map((c) => c.id).join(','), (icons || []).length])
return map
}
// persistIconToServer 将抓取到的 favicon 持久化到服务器 /data/images/ 目录。
// 后台异步执行,失败时静默忽略(不影响前端显示)。
-async function persistIconToServer(container, iconUrl, containerUrl) {
+async function persistIconToServer(container, containerUrl) {
try {
- // 提取镜像名称(去掉 tag)
- const imageName = container.image.split(':')[0]
+ // 容器对象的镜像字段为 usingImage,去掉 tag 作为绑定 key
+ const imageName = (container.usingImage || '').split(':')[0]
+ if (!imageName) return
- // 调用后端接口下载并保存
await imageAPI.fetchIcon({
- imageName: imageName,
- url: containerUrl
+ imageName,
+ url: containerUrl,
+ targetType: 'image',
})
-
- console.log(`✅ 图标已持久化: ${imageName} -> ${iconUrl}`)
} catch (error) {
- // 静默失败,不影响用户体验
+ // 静默失败,不影响用户体验(仅调试级日志)
console.debug(`图标持久化失败 (${container.name}):`, error.message)
}
}
diff --git a/frontend-react/src/hooks/useTasks.jsx b/frontend-react/src/hooks/useTasks.jsx
index 3a0acb03..43533195 100644
--- a/frontend-react/src/hooks/useTasks.jsx
+++ b/frontend-react/src/hooks/useTasks.jsx
@@ -54,6 +54,8 @@ export function TaskProvider({ children }) {
endedAt: d.endedAt || 0,
// 镜像分层进度(仅拉取类任务有值),供任务中心展开显示
layers: Array.isArray(d.layers) ? d.layers : [],
+ // 可更新镜像清单(仅「检查镜像更新」任务完成时有值),供任务中心展开显示
+ updatableImages: Array.isArray(d.updatableImages) ? d.updatableImages : [],
}))
for (const t of mapped) {
if (t.isDone && prevDoneRef.current[t.id] === false) {
diff --git a/frontend-react/src/index.css b/frontend-react/src/index.css
index 5f80492e..365a9fad 100644
--- a/frontend-react/src/index.css
+++ b/frontend-react/src/index.css
@@ -27,6 +27,13 @@
@apply w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
}
+ /* 手机端顶部安全区域类(避免刘海/状态栏遮挡) */
+ .pt-safe {
+ @supports (padding: max(0px)) {
+ padding-top: max(1rem, env(safe-area-inset-top));
+ }
+ }
+
/* 手机端底部安全区域类 */
.safe-area-inset-bottom {
@supports (padding: max(0px)) {
diff --git a/internal/config/paths.go b/internal/config/paths.go
new file mode 100644
index 00000000..d341c75e
--- /dev/null
+++ b/internal/config/paths.go
@@ -0,0 +1,23 @@
+package config
+
+// 图标 / 静态资源相关的持久化路径集中定义,作为全局单一事实来源(Single Source of Truth)。
+// 说明:历史代码里同一目录曾出现相对路径("data/images")与绝对路径("/data/images")混用,
+// 导致"上传写到 /app/data/images、静态服务读 /data/images"两边对不上。
+// 这里统一用绝对路径,所有 handler 一律引用本文件常量,杜绝再次分叉。
+const (
+ // DataDir 持久化根目录(Docker VOLUME 挂载点)。
+ DataDir = "/data"
+
+ // ImagesDir 图标图片统一存放目录:手动上传与自动抓取的 favicon 都落到这里,
+ // 对应静态路由 /images/
。
+ ImagesDir = "/data/images"
+
+ // ConfigDir 配置目录。
+ ConfigDir = "/data/config"
+
+ // IconsConfigPath 图标绑定配置文件(IconItem 数组格式)。
+ IconsConfigPath = "/data/config/icons.json"
+
+ // LegacyImageDir 历史图标目录,仅为兼容旧数据保留静态访问,新逻辑不再写入。
+ LegacyImageDir = "/data/config/image"
+)
diff --git a/internal/handler/compose/composehandler.go b/internal/handler/compose/composehandler.go
index da1956cf..76fa51b7 100644
--- a/internal/handler/compose/composehandler.go
+++ b/internal/handler/compose/composehandler.go
@@ -133,3 +133,59 @@ func SaveConfigHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
writeResp(w, r, resp, err)
}
}
+
+// CreateFolderHandler 在指定目录下创建文件夹。
+func CreateFolderHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ComposeCreateFolderReq
+ if err := httpx.Parse(r, &req); err != nil {
+ httpx.ErrorCtx(r.Context(), w, err)
+ return
+ }
+ l := compose.NewComposeLogic(r.Context(), svcCtx)
+ resp, err := l.CreateFolder(&req)
+ writeResp(w, r, resp, err)
+ }
+}
+
+// CreateComposeFileHandler 在指定目录下创建 Compose 配置文件。
+func CreateComposeFileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ComposeCreateFileReq
+ if err := httpx.Parse(r, &req); err != nil {
+ httpx.ErrorCtx(r.Context(), w, err)
+ return
+ }
+ l := compose.NewComposeLogic(r.Context(), svcCtx)
+ resp, err := l.CreateComposeFile(&req)
+ writeResp(w, r, resp, err)
+ }
+}
+
+// ReadFileByPathHandler 读取指定路径的文件内容(用于文件管理器)。
+func ReadFileByPathHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ComposeReadFileReq
+ if err := httpx.Parse(r, &req); err != nil {
+ httpx.ErrorCtx(r.Context(), w, err)
+ return
+ }
+ l := compose.NewComposeLogic(r.Context(), svcCtx)
+ resp, err := l.ReadFileByPath(&req)
+ writeResp(w, r, resp, err)
+ }
+}
+
+// SaveFileByPathHandler 保存文件到指定路径(用于文件管理器)。
+func SaveFileByPathHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var req types.ComposeSaveFileReq
+ if err := httpx.Parse(r, &req); err != nil {
+ httpx.ErrorCtx(r.Context(), w, err)
+ return
+ }
+ l := compose.NewComposeLogic(r.Context(), svcCtx)
+ resp, err := l.SaveFileByPath(&req)
+ writeResp(w, r, resp, err)
+ }
+}
diff --git a/internal/handler/icons/fetchiconhandler.go b/internal/handler/icons/fetchiconhandler.go
index 0fb409a5..31816394 100644
--- a/internal/handler/icons/fetchiconhandler.go
+++ b/internal/handler/icons/fetchiconhandler.go
@@ -19,16 +19,17 @@ import (
"github.com/zeromicro/go-zero/rest/httpx"
)
-// persistImagesDir 持久化图标存放目录(对应静态路由 /images/)。
-const persistImagesDir = "/data/images"
+// 持久化图标存放目录复用 imageUploadDir(= config.ImagesDir,见 paths.go),
+// 与手动上传、静态路由 /images/ 保持同一目录,避免路径分叉。
// fetchIconReq 自动抓取并持久化图标的请求体。
type fetchIconReq struct {
- ImageName string `json:"imageName"`
- URL string `json:"url"` // 容器访问地址,如 http://192.168.1.2:8080
+ ImageName string `json:"imageName"` // 镜像名或容器名
+ URL string `json:"url"` // 容器访问地址,如 http://192.168.1.2:8080
+ TargetType string `json:"targetType"` // "container" 或 "image",默认 "image"
}
-// FetchIconHandler 抓取站点 favicon、下载图片持久化到 /data/images,并绑定到镜像名。
+// FetchIconHandler 抓取站点 favicon、下载图片持久化到 /data/images,并绑定到镜像名或容器名。
// 与仅存外链不同:图片本体落盘,外链失效或跨设备也能显示。
func FetchIconHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -41,6 +42,17 @@ func FetchIconHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
writeUploadError(w, http.StatusBadRequest, err.Error())
return
}
+
+ // 获取目标类型,默认为 "image"
+ targetType := req.TargetType
+ if targetType == "" {
+ targetType = "image"
+ }
+ if targetType != "container" && targetType != "image" {
+ writeUploadError(w, http.StatusBadRequest, "targetType must be 'container' or 'image'")
+ return
+ }
+
// 1. 解析出可用 favicon 外链
iconURL, err := faviconLogic.Resolve(r.Context(), req.URL)
if err != nil {
@@ -53,17 +65,29 @@ func FetchIconHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
writeUploadError(w, http.StatusOK, "下载图标失败: "+err.Error())
return
}
- // 3. 写入 imageLogos.js 映射
- if _, e := os.Stat(imageLogosPath); os.IsNotExist(e) {
- _ = os.MkdirAll(imageUploadDir, 0o755)
- _ = os.WriteFile(imageLogosPath, []byte("export const customImageLogos = {\n}\n"), 0644)
+
+ // 3. 写入图标配置(新格式)
+ priority := 2 // 镜像级
+ if targetType == "container" {
+ priority = 1 // 容器级
}
- if err := writeImageLogoValue(imageLogosPath, req.ImageName, localPath); err != nil {
+
+ if err := addOrUpdateIcon(req.ImageName, targetType, localPath, priority); err != nil {
writeUploadError(w, http.StatusInternalServerError, "写入配置失败: "+err.Error())
return
}
- logx.Infof("图标持久化成功: %s -> %s (源 %s)", req.ImageName, localPath, iconURL)
- httpx.OkJsonCtx(r.Context(), w, types.Resp{Code: 200, Msg: "Success", Data: localPath})
+
+ logx.Infof("图标持久化成功: %s (%s) -> %s (源 %s)", req.ImageName, targetType, localPath, iconURL)
+ httpx.OkJsonCtx(r.Context(), w, types.Resp{
+ Code: 200,
+ Msg: "Success",
+ Data: map[string]interface{}{
+ "target": req.ImageName,
+ "targetType": targetType,
+ "iconUrl": localPath,
+ "sourceUrl": iconURL,
+ },
+ })
}
}
@@ -90,10 +114,10 @@ func downloadAndPersist(iconURL string) (string, error) {
ext := pickIconExt(iconURL, resp.Header.Get("Content-Type"))
sum := sha1.Sum([]byte(iconURL))
filename := fmt.Sprintf("%x%s", sum[:8], ext)
- if err := os.MkdirAll(persistImagesDir, 0o755); err != nil {
+ if err := os.MkdirAll(imageUploadDir, 0o755); err != nil {
return "", err
}
- if err := os.WriteFile(filepath.Join(persistImagesDir, filename), data, 0644); err != nil {
+ if err := os.WriteFile(filepath.Join(imageUploadDir, filename), data, 0644); err != nil {
return "", err
}
return "/images/" + filename, nil
diff --git a/internal/handler/icons/obtainhandler.go b/internal/handler/icons/obtainhandler.go
index 359287e4..e09c3020 100644
--- a/internal/handler/icons/obtainhandler.go
+++ b/internal/handler/icons/obtainhandler.go
@@ -1,10 +1,11 @@
package icons
import (
+ "encoding/json"
"fmt"
"net/http"
"os"
- "regexp"
+ "strings"
"github.com/l429609201/dockerCopilot/internal/svc"
"github.com/l429609201/dockerCopilot/internal/types"
@@ -12,58 +13,169 @@ import (
"github.com/zeromicro/go-zero/rest/httpx"
)
+// IconItem 图标配置项(新格式)
+type IconItem struct {
+ Target string `json:"target"` // 目标名称(容器名或镜像名)
+ TargetType string `json:"targetType"` // container | image
+ IconURL string `json:"iconUrl"` // 图标URL
+ Priority int `json:"priority"` // 优先级(容器级=1,镜像级=2)
+}
+
+// readIconsConfig 读取图标配置文件
+func readIconsConfig() ([]IconItem, error) {
+ data, err := os.ReadFile(iconsConfigPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return []IconItem{}, nil
+ }
+ return nil, err
+ }
+
+ var icons []IconItem
+ if err := json.Unmarshal(data, &icons); err != nil {
+ return nil, err
+ }
+ return icons, nil
+}
+
+// writeIconsConfig 写入图标配置文件
+func writeIconsConfig(icons []IconItem) error {
+ _ = os.MkdirAll("/data/config", 0755)
+ data, err := json.MarshalIndent(icons, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(iconsConfigPath, data, 0644)
+}
+
+// ensureBuiltInIcons 确保内置图标配置存在(DockerCopilot 自身的图标)。
+// 全程持 iconsFileMu,与 addOrUpdateIcon 互斥,避免与并发抓取写入相互覆盖。
+func ensureBuiltInIcons() error {
+ iconsFileMu.Lock()
+ defer iconsFileMu.Unlock()
+
+ icons, err := readIconsConfig()
+ if err != nil {
+ return err
+ }
+
+ // 检查是否已存在 DockerCopilot 图标配置
+ hasDockerCopilot := false
+ for _, item := range icons {
+ if item.TargetType == "image" &&
+ (item.Target == "dockercopilot" || item.Target == "ghcr.io/l429609201/dockercopilot") {
+ hasDockerCopilot = true
+ break
+ }
+ }
+
+ // 如果不存在,添加内置配置
+ if !hasDockerCopilot {
+ builtInIcons := []IconItem{
+ {
+ Target: "dockercopilot",
+ TargetType: "image",
+ IconURL: "/favicon.png",
+ Priority: 2,
+ },
+ {
+ Target: "ghcr.io/l429609201/dockercopilot",
+ TargetType: "image",
+ IconURL: "/favicon.png",
+ Priority: 2,
+ },
+ }
+ icons = append(icons, builtInIcons...)
+ return writeIconsConfig(icons)
+ }
+
+ return nil
+}
+
+// addOrUpdateIcon 添加或更新图标配置。
+// 全程持 iconsFileMu,保证并发抓取时 read-modify-write 不互相覆盖。
+func addOrUpdateIcon(target, targetType, iconURL string, priority int) error {
+ // 镜像级 key 统一去 tag:与前端匹配逻辑(getImageLogo 按去 tag 比对)对齐,
+ // 双保险防止再次写入 gitea/gitea:latest 这类带 tag 的 key 造成匹配错位。
+ // 容器级 target 是容器名,不含 tag,保持原样。
+ if targetType == "image" {
+ if idx := strings.IndexByte(target, ':'); idx >= 0 {
+ target = target[:idx]
+ }
+ }
+
+ iconsFileMu.Lock()
+ defer iconsFileMu.Unlock()
+
+ icons, err := readIconsConfig()
+ if err != nil {
+ return err
+ }
+
+ // 查找是否已存在
+ found := false
+ for i := range icons {
+ if icons[i].Target == target && icons[i].TargetType == targetType {
+ // 更新现有配置
+ icons[i].IconURL = iconURL
+ icons[i].Priority = priority
+ found = true
+ break
+ }
+ }
+
+ // 如果不存在,则添加
+ if !found {
+ icons = append(icons, IconItem{
+ Target: target,
+ TargetType: targetType,
+ IconURL: iconURL,
+ Priority: priority,
+ })
+ }
+
+ return writeIconsConfig(icons)
+}
+
func ObtainHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- jsPath := "/data/config/imageLogos.js"
- logx.Infof("Reading icons from: %s", jsPath)
+ logx.Info("获取图标配置")
+
+ // 确保内置图标配置存在(首次调用时自动添加)
+ if err := ensureBuiltInIcons(); err != nil {
+ logx.Errorf("初始化内置图标配置失败: %v", err)
+ }
- contentBytes, err := os.ReadFile(jsPath)
+ // 读取新格式配置文件(icons.json)
+ data, err := os.ReadFile(iconsConfigPath)
if err != nil {
if os.IsNotExist(err) {
- logx.Info("Config file does not exist, returning empty.")
+ logx.Info("配置文件不存在,返回空数组")
httpx.OkJsonCtx(r.Context(), w, types.Resp{
Code: 200,
Msg: "Success",
- Data: "{}",
+ Data: []IconItem{},
})
return
}
- logx.Errorf("Error reading config: %v", err)
+ logx.Errorf("读取配置文件失败: %v", err)
httpx.ErrorCtx(r.Context(), w, fmt.Errorf("failed to read config: %v", err))
return
}
- content := string(contentBytes)
- // fmt.Printf("Config content: %s\n", content) // Uncomment for deep debug
-
- // 改进的正则表达式:匹配 "key": "value",允许一定的格式变化
- // 使用反引号表示原始字符串。
- re := regexp.MustCompile(`"([^"]+)"\s*:\s*"([^"]+)"`)
- matches := re.FindAllStringSubmatch(content, -1)
-
- icons := make(map[string]string)
- for _, match := range matches {
- if len(match) == 3 {
- key := match[1]
- val := match[2]
- icons[key] = val
- // fmt.Printf("Found icon: %s -> %s\n", key, val)
- }
+ var icons []IconItem
+ if err := json.Unmarshal(data, &icons); err != nil {
+ logx.Errorf("解析配置文件失败: %v", err)
+ httpx.ErrorCtx(r.Context(), w, fmt.Errorf("failed to parse config: %v", err))
+ return
}
- logx.Infof("Total icons found: %d", len(icons))
+ logx.Infof("成功加载图标配置,共 %d 项", len(icons))
- response := struct {
- Code int `json:"code"`
- Msg string `json:"msg"`
- Data map[string]string `json:"data"`
- }{
+ httpx.OkJsonCtx(r.Context(), w, types.Resp{
Code: 200,
Msg: "Success",
Data: icons,
- }
-
- httpx.OkJsonCtx(r.Context(), w, response)
+ })
}
-
}
diff --git a/internal/handler/icons/paths.go b/internal/handler/icons/paths.go
index 644761fc..ae6ce7fa 100644
--- a/internal/handler/icons/paths.go
+++ b/internal/handler/icons/paths.go
@@ -1,6 +1,19 @@
package icons
+import (
+ "sync"
+
+ "github.com/l429609201/dockerCopilot/internal/config"
+)
+
+// 图标包内路径统一引用全局常量(internal/config/paths.go),避免相对/绝对路径分叉。
var (
- imageUploadDir = "/data/config/image"
- imageLogosPath = "/data/config/imageLogos.js"
+ imageUploadDir = config.ImagesDir // 图片统一落盘目录(上传+抓取)
+ iconsConfigPath = config.IconsConfigPath // 图标配置文件(IconItem 数组格式)
)
+
+// iconsFileMu 保护 icons.json 的读改写(read-modify-write)。
+// 批量自动抓取时前端会并发 POST /api/icons/fetch,多个请求同时
+// 「读旧 json → 追加自己 → 写回」会互相覆盖,导致大部分写入丢失。
+// 所有对 icons.json 的读写都必须在此锁保护下进行。
+var iconsFileMu sync.Mutex
diff --git a/internal/handler/icons/seticonurlhandler.go b/internal/handler/icons/seticonurlhandler.go
index e22f7d7b..95a939c0 100644
--- a/internal/handler/icons/seticonurlhandler.go
+++ b/internal/handler/icons/seticonurlhandler.go
@@ -4,7 +4,6 @@ import (
"encoding/json"
"net/http"
"net/url"
- "os"
"strings"
"github.com/l429609201/dockerCopilot/internal/svc"
@@ -14,11 +13,12 @@ import (
// setIconURLReq 通过 URL 绑定图标的请求体。
type setIconURLReq struct {
- ImageName string `json:"imageName"`
- URL string `json:"url"`
+ ImageName string `json:"imageName"` // 镜像名或容器名
+ URL string `json:"url"` // 图标 URL
+ TargetType string `json:"targetType"` // "container" 或 "image",默认 "image"
}
-// SetIconURLHandler 直接用一个图标 URL 绑定到指定镜像名。
+// SetIconURLHandler 直接用一个图标 URL 绑定到指定镜像名或容器名。
// 相比上传图片,URL 方式更快,且在生产环境(本地静态目录不可访问时)更可靠。
func SetIconURLHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -35,23 +35,43 @@ func SetIconURLHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
writeUploadError(w, http.StatusBadRequest, err.Error())
return
}
- if _, err := os.Stat(imageLogosPath); os.IsNotExist(err) {
- _ = os.MkdirAll(imageUploadDir, 0o755)
- _ = os.WriteFile(imageLogosPath, []byte("export const customImageLogos = {\n}\n"), 0644)
+
+ // 获取目标类型,默认为 "image"
+ targetType := req.TargetType
+ if targetType == "" {
+ targetType = "image"
+ }
+ if targetType != "container" && targetType != "image" {
+ writeUploadError(w, http.StatusBadRequest, "targetType must be 'container' or 'image'")
+ return
+ }
+
+ // 确定优先级
+ priority := 2 // 镜像级
+ if targetType == "container" {
+ priority = 1 // 容器级
}
- if err := writeImageLogoValue(imageLogosPath, req.ImageName, req.URL); err != nil {
+
+ // 添加或更新图标配置
+ if err := addOrUpdateIcon(req.ImageName, targetType, req.URL, priority); err != nil {
writeUploadError(w, http.StatusInternalServerError, "写入配置失败: "+err.Error())
return
}
+
httpx.OkJsonCtx(r.Context(), w, types.Resp{
Code: 200,
Msg: "Success",
- Data: req.URL,
+ Data: map[string]interface{}{
+ "target": req.ImageName,
+ "targetType": targetType,
+ "iconUrl": req.URL,
+ "priority": priority,
+ },
})
}
}
-// validateIconURL 校验图标 URL:必须是 http/https 绝对地址,且不含引号(防注入 js 文件)。
+// validateIconURL 校验图标 URL:必须是 http/https 绝对地址或本地路径,且不含引号。
func validateIconURL(u string) error {
if u == "" {
return errIcon("url is required")
@@ -59,12 +79,19 @@ func validateIconURL(u string) error {
if strings.ContainsAny(u, "\"'\n\r") {
return errIcon("url 包含非法字符")
}
+
+ // 允许本地路径(以 / 开头)
+ if strings.HasPrefix(u, "/") {
+ return nil
+ }
+
+ // 校验外部 URL
parsed, err := url.Parse(u)
if err != nil {
return errIcon("url 格式错误")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
- return errIcon("url 必须以 http:// 或 https:// 开头")
+ return errIcon("url 必须以 http:// 或 https:// 开头,或者是以 / 开头的本地路径")
}
if parsed.Host == "" {
return errIcon("url 缺少主机名")
diff --git a/internal/handler/icons/uploadhandler.go b/internal/handler/icons/uploadhandler.go
index 1c66c16c..ca226794 100644
--- a/internal/handler/icons/uploadhandler.go
+++ b/internal/handler/icons/uploadhandler.go
@@ -24,6 +24,14 @@ var allowedImageTypes = map[string]string{
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
+ ".ico": "image/x-icon",
+ ".svg": "image/svg+xml",
+}
+
+// UploadRequest 上传图标请求
+type UploadRequest struct {
+ ImageName string `json:"imageName"` // 镜像名或容器名
+ TargetType string `json:"targetType"` // "container" 或 "image",默认 "image"
}
func UploadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
@@ -35,7 +43,7 @@ func UploadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return
}
- // 2. 获取文件和 Key
+ // 2. 获取文件和参数
file, handler, err := r.FormFile("file")
if err != nil {
writeUploadError(w, http.StatusBadRequest, "failed to get file")
@@ -49,8 +57,18 @@ func UploadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return
}
- // 3. 确保目录存在 (防御性编程)。统一持久化到 /data/images
- dataPath := persistImagesDir
+ // 获取目标类型,默认为 "image"
+ targetType := r.FormValue("targetType")
+ if targetType == "" {
+ targetType = "image"
+ }
+ if targetType != "container" && targetType != "image" {
+ writeUploadError(w, http.StatusBadRequest, "targetType must be 'container' or 'image'")
+ return
+ }
+
+ // 3. 确保目录存在(统一使用绝对路径常量,与抓取/静态服务保持一致)
+ dataPath := imageUploadDir
if err := os.MkdirAll(dataPath, 0o755); err != nil {
writeUploadError(w, http.StatusInternalServerError, "failed to prepare upload dir")
return
@@ -76,9 +94,14 @@ func UploadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return
}
- // 5. 更新 imageLogos.js
- jsPath := imageLogosPath
- if err := updateImageLogosJS(jsPath, imageNameKey, filename); err != nil {
+ // 5. 更新图标配置(新格式)
+ iconURL := fmt.Sprintf("/images/%s", filename)
+ priority := 2 // 镜像级
+ if targetType == "container" {
+ priority = 1 // 容器级
+ }
+
+ if err := addOrUpdateIcon(imageNameKey, targetType, iconURL, priority); err != nil {
_ = os.Remove(dstPath)
writeUploadError(w, http.StatusInternalServerError, "failed to update config")
return
@@ -87,7 +110,10 @@ func UploadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
httpx.OkJsonCtx(r.Context(), w, types.Resp{
Code: 200,
Msg: "Success",
- Data: filename,
+ Data: map[string]interface{}{
+ "filename": filename,
+ "iconUrl": iconURL,
+ },
})
}
}
@@ -115,11 +141,12 @@ func generateStoredFilename(file multipart.File, handler *multipart.FileHeader)
ext := strings.ToLower(filepath.Ext(handler.Filename))
expectedType, ok := allowedImageTypes[ext]
if !ok {
- return "", fmt.Errorf("only png, jpg, jpeg, webp and gif files are allowed")
+ return "", fmt.Errorf("only png, jpg, jpeg, webp, gif, ico, svg files are allowed")
}
detectedType := http.DetectContentType(header[:n])
- if detectedType != expectedType {
+ // SVG 和 ICO 文件的 MIME 类型检测不准确,跳过检测
+ if ext != ".svg" && ext != ".ico" && detectedType != expectedType {
return "", fmt.Errorf("uploaded file content does not match its extension")
}
@@ -133,47 +160,3 @@ func writeUploadError(w http.ResponseWriter, statusCode int, msg string) {
Data: map[string]interface{}{},
})
}
-
-func updateImageLogosJS(filePath, imageName, filename string) error {
- // 上传文件场景:统一存 /data/images,前端通过 /images/ 静态路由访问
- containerPath := fmt.Sprintf("/images/%s", filename)
- return writeImageLogoValue(filePath, imageName, containerPath)
-}
-
-// writeImageLogoValue 将 imageName -> value 映射写入 imageLogos.js。
-// value 可以是本地图标路径,也可以是外部图标 URL(http/https)。
-func writeImageLogoValue(filePath, imageName, value string) error {
- // 读取文件
- contentBytes, err := os.ReadFile(filePath)
- if err != nil {
- return err
- }
- content := string(contentBytes)
-
- containerPath := value
-
- if strings.Contains(content, fmt.Sprintf(`"%s"`, imageName)) {
- // 更新现有行
- re := regexp.MustCompile(fmt.Sprintf(`"%s"\s*:\s*".*"`, regexp.QuoteMeta(imageName)))
- content = re.ReplaceAllString(content, fmt.Sprintf(`"%s": "%s"`, imageName, containerPath))
- } else {
- // 插入新行
- // 查找 `export const customImageLogos = {`
- startIdx := strings.Index(content, "export const customImageLogos = {")
- if startIdx == -1 {
- return fmt.Errorf("invalid config format")
- }
- // 尝试查找右大括号。这里假设它是最后一个右大括号逻辑或者是文件末尾。
- // 一个简单的启发式方法:插入到最后一个 `}` 或 `};` 之前。
- lastBraceIdx := strings.LastIndex(content, "}")
- if lastBraceIdx == -1 || lastBraceIdx < startIdx {
- return fmt.Errorf("invalid config format, no closing brace")
- }
-
- newLine := fmt.Sprintf(` "%s": "%s",`, imageName, containerPath)
- // 插入到最后一个大括号之前
- content = content[:lastBraceIdx] + newLine + "\n" + content[lastBraceIdx:]
- }
-
- return os.WriteFile(filePath, []byte(content), 0644)
-}
diff --git a/internal/handler/icons/uploadhandler_test.go b/internal/handler/icons/uploadhandler_test.go
index eba16e41..e6131d5e 100644
--- a/internal/handler/icons/uploadhandler_test.go
+++ b/internal/handler/icons/uploadhandler_test.go
@@ -15,31 +15,15 @@ import (
func TestUploadHandlerRejectsNonImageFiles(t *testing.T) {
tempDir := t.TempDir()
- jsPath := filepath.Join(tempDir, "imageLogos.js")
imageDir := filepath.Join(tempDir, "image")
testFilename := "codex-upload-vuln.json"
+ // 将上传目录重定向到临时目录,避免污染真实 /data/images
originalImageUploadDir := imageUploadDir
- originalImageLogosPath := imageLogosPath
imageUploadDir = imageDir
- imageLogosPath = jsPath
t.Cleanup(func() {
imageUploadDir = originalImageUploadDir
- imageLogosPath = originalImageLogosPath
- })
-
- originalContent, readErr := os.ReadFile(jsPath)
- hadOriginal := readErr == nil
- if err := os.WriteFile(jsPath, []byte("// test\nexport const customImageLogos = {\n};\n"), 0o644); err != nil {
- t.Fatalf("failed to seed imageLogos.js: %v", err)
- }
- t.Cleanup(func() {
_ = os.Remove(filepath.Join(imageDir, testFilename))
- if hadOriginal {
- _ = os.WriteFile(jsPath, originalContent, 0o644)
- } else {
- _ = os.Remove(jsPath)
- }
})
body := &bytes.Buffer{}
diff --git a/internal/handler/routes.go b/internal/handler/routes.go
index a1b51cbb..a040432c 100644
--- a/internal/handler/routes.go
+++ b/internal/handler/routes.go
@@ -276,11 +276,35 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Handler: compose.SaveConfigHandler(serverCtx),
},
{
- // 浏览 DC 自身文件系统目录(目录选择器用,只读,仅返回子目录)
+ // 浏览 DC 自身文件系统目录(目录选择器用,只读,返回子目录和文件列表)
Method: http.MethodGet,
Path: "/compose/browse",
Handler: compose.BrowseHandler(serverCtx),
},
+ {
+ // 在指定目录下创建文件夹
+ Method: http.MethodPost,
+ Path: "/compose/folder",
+ Handler: compose.CreateFolderHandler(serverCtx),
+ },
+ {
+ // 在指定目录下创建 Compose 配置文件
+ Method: http.MethodPost,
+ Path: "/compose/file",
+ Handler: compose.CreateComposeFileHandler(serverCtx),
+ },
+ {
+ // 读取任意文件内容(文件管理器用)
+ Method: http.MethodGet,
+ Path: "/files",
+ Handler: compose.ReadFileByPathHandler(serverCtx),
+ },
+ {
+ // 保存文件内容(文件管理器用)
+ Method: http.MethodPut,
+ Path: "/files",
+ Handler: compose.SaveFileByPathHandler(serverCtx),
+ },
{
Method: http.MethodPost,
Path: "/compose/validate",
diff --git a/internal/logic/compose/actionlogic.go b/internal/logic/compose/actionlogic.go
index 4e4fdcbb..e1a2a2ff 100644
--- a/internal/logic/compose/actionlogic.go
+++ b/internal/logic/compose/actionlogic.go
@@ -58,7 +58,8 @@ func (l *ComposeLogic) Action(req *types.ComposeActionReq) (resp *types.Resp, er
Percentage: 10, Message: "正在执行 " + action, DetailMsg: "正在执行 docker compose " + action,
TaskType: svc.TaskTypeComposeAction, ResourceID: req.ID,
})
- result := composeMod.RunAction(taskCtx, resolvedDir, composeFile, action, timeoutSec)
+ // 使用带进度更新的版本,实时推送日志到前端
+ result := composeMod.RunActionWithProgress(taskCtx, resolvedDir, composeFile, action, timeoutSec, l.svcCtx, taskID)
progress := svc.TaskProgress{
TaskID: taskID, Name: "Compose " + action + " " + projectName,
Percentage: 100, IsDone: true, TaskType: svc.TaskTypeComposeAction, ResourceID: req.ID,
diff --git a/internal/logic/compose/composelogic.go b/internal/logic/compose/composelogic.go
index 882244ce..9f9e1254 100644
--- a/internal/logic/compose/composelogic.go
+++ b/internal/logic/compose/composelogic.go
@@ -246,16 +246,20 @@ func (l *ComposeLogic) Browse(req *types.ComposeBrowseReq) (resp *types.Resp, er
return bad(resp, "读取目录失败:"+readErr.Error()), nil
}
- // 仅收集子目录,忽略隐藏目录(以 . 开头)
- dirs := make([]map[string]interface{}, 0, len(entries))
+ // 收集子目录和文件(忽略隐藏项)
+ dirs := make([]string, 0)
+ files := make([]string, 0)
for _, e := range entries {
- if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
+ name := e.Name()
+ // 忽略隐藏文件/目录(以 . 开头)
+ if strings.HasPrefix(name, ".") {
continue
}
- dirs = append(dirs, map[string]interface{}{
- "name": e.Name(),
- "path": filepath.Join(target, e.Name()),
- })
+ if e.IsDir() {
+ dirs = append(dirs, name)
+ } else {
+ files = append(files, name)
+ }
}
// 父目录:已在根目录时 parent 为空,前端据此隐藏"上一级"
@@ -268,6 +272,7 @@ func (l *ComposeLogic) Browse(req *types.ComposeBrowseReq) (resp *types.Resp, er
"path": target,
"parent": parent,
"dirs": dirs,
+ "files": files,
}
return resp, nil
}
@@ -279,3 +284,150 @@ func bad(resp *types.Resp, msg string) *types.Resp {
resp.Data = map[string]interface{}{}
return resp
}
+
+// CreateFolder 在指定目录下创建文件夹。
+func (l *ComposeLogic) CreateFolder(req *types.ComposeCreateFolderReq) (resp *types.Resp, err error) {
+ resp = &types.Resp{Code: 200, Msg: "success"}
+
+ // 清理路径,防止路径穿越
+ parentPath := filepath.Clean(req.ParentPath)
+ folderName := filepath.Clean(req.FolderName)
+
+ // 校验:父路径必须是绝对路径
+ if !filepath.IsAbs(parentPath) {
+ return bad(resp, "父路径必须为绝对路径"), nil
+ }
+
+ // 校验:文件夹名不能包含路径分隔符
+ if strings.Contains(folderName, string(os.PathSeparator)) {
+ return bad(resp, "文件夹名称不能包含路径分隔符"), nil
+ }
+
+ // 拼接完整路径
+ fullPath := filepath.Join(parentPath, folderName)
+
+ // 检查是否已存在
+ if _, err := os.Stat(fullPath); err == nil {
+ return bad(resp, "文件夹已存在"), nil
+ }
+
+ // 创建文件夹
+ if err := os.MkdirAll(fullPath, 0755); err != nil {
+ return bad(resp, "创建文件夹失败:"+err.Error()), nil
+ }
+
+ logx.Infof("已创建文件夹: %s", fullPath)
+ resp.Data = map[string]interface{}{"path": fullPath}
+ return resp, nil
+}
+
+// CreateComposeFile 在指定目录下创建 Compose 配置文件(带模板内容)。
+func (l *ComposeLogic) CreateComposeFile(req *types.ComposeCreateFileReq) (resp *types.Resp, err error) {
+ resp = &types.Resp{Code: 200, Msg: "success"}
+
+ // 清理路径
+ parentPath := filepath.Clean(req.ParentPath)
+ fileName := filepath.Clean(req.FileName)
+
+ // 校验:父路径必须是绝对路径
+ if !filepath.IsAbs(parentPath) {
+ return bad(resp, "父路径必须为绝对路径"), nil
+ }
+
+ // 校验:文件名不能包含路径分隔符
+ if strings.Contains(fileName, string(os.PathSeparator)) {
+ return bad(resp, "文件名不能包含路径分隔符"), nil
+ }
+
+ // 拼接完整路径
+ fullPath := filepath.Join(parentPath, fileName)
+
+ // 检查是否已存在
+ if _, err := os.Stat(fullPath); err == nil {
+ return bad(resp, "文件已存在"), nil
+ }
+
+ // 默认模板内容
+ templateContent := `version: '3.8'
+
+services:
+ app:
+ image: nginx:latest
+ container_name: my-app
+ ports:
+ - "8080:80"
+ volumes:
+ - ./data:/usr/share/nginx/html
+ restart: unless-stopped
+ networks:
+ - default
+
+networks:
+ default:
+ driver: bridge
+`
+
+ // 写入文件
+ if err := os.WriteFile(fullPath, []byte(templateContent), 0644); err != nil {
+ return bad(resp, "创建文件失败:"+err.Error()), nil
+ }
+
+ logx.Infof("已创建 Compose 文件: %s", fullPath)
+ resp.Data = map[string]interface{}{"path": fullPath}
+ return resp, nil
+}
+
+// ReadFileByPath 读取指定路径的文件内容(文件管理器用)。
+func (l *ComposeLogic) ReadFileByPath(req *types.ComposeReadFileReq) (resp *types.Resp, err error) {
+ resp = &types.Resp{Code: 200, Msg: "success"}
+
+ // 清理路径
+ filePath := filepath.Clean(req.Path)
+
+ // 校验:必须是绝对路径
+ if !filepath.IsAbs(filePath) {
+ return bad(resp, "文件路径必须为绝对路径"), nil
+ }
+
+ // 检查文件是否存在
+ info, err := os.Stat(filePath)
+ if err != nil {
+ return bad(resp, "文件不存在:"+err.Error()), nil
+ }
+ if info.IsDir() {
+ return bad(resp, "该路径是目录,不是文件"), nil
+ }
+
+ // 读取文件内容
+ content, err := os.ReadFile(filePath)
+ if err != nil {
+ return bad(resp, "读取文件失败:"+err.Error()), nil
+ }
+
+ resp.Data = map[string]interface{}{
+ "path": filePath,
+ "content": string(content),
+ }
+ return resp, nil
+}
+
+// SaveFileByPath 保存文件内容到指定路径(文件管理器用)。
+func (l *ComposeLogic) SaveFileByPath(req *types.ComposeSaveFileReq) (resp *types.Resp, err error) {
+ resp = &types.Resp{Code: 200, Msg: "success"}
+
+ // 清理路径
+ filePath := filepath.Clean(req.Path)
+
+ // 校验:必须是绝对路径
+ if !filepath.IsAbs(filePath) {
+ return bad(resp, "文件路径必须为绝对路径"), nil
+ }
+
+ // 写入文件
+ if err := os.WriteFile(filePath, []byte(req.Content), 0644); err != nil {
+ return bad(resp, "保存文件失败:"+err.Error()), nil
+ }
+
+ logx.Infof("已保存文件: %s", filePath)
+ return resp, nil
+}
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..ec74ae8f 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"
+ // 从请求中读取是否删除旧容器的参数(前端传递 "true" 或 "false")
+ delOldContainer := req.DelOldContainer != "false"
// 整体超时时间来自配置,默认 1800 秒
timeoutSec := l.svcCtx.Config.Task.PullTimeoutSec
if timeoutSec <= 0 {
diff --git a/internal/logic/image/checkupdatelogic.go b/internal/logic/image/checkupdatelogic.go
index ee04b92c..269d94d3 100644
--- a/internal/logic/image/checkupdatelogic.go
+++ b/internal/logic/image/checkupdatelogic.go
@@ -115,16 +115,55 @@ func (l *CheckUpdateLogic) runCheck(taskID string, images []types.Image) {
return
}
+ // 从内存检查结果中收集本轮「可更新」的镜像清单(多主机同名镜像去重),
+ // 随完成态一并下发,供任务中心展开显示,无需前端再刷列表逐个比对。
+ updatable := l.collectUpdatableImages(images)
+ detail := fmt.Sprintf("已检测 %d 个镜像,%d 个可更新", len(images), len(updatable))
+ if len(updatable) == 0 {
+ detail = fmt.Sprintf("已检测 %d 个镜像,均为最新", len(images))
+ }
+
l.finishCheck(taskID, svc.TaskProgress{
- TaskID: taskID,
- Percentage: 100,
- Name: "检查镜像更新",
- Message: "检测完成",
- DetailMsg: fmt.Sprintf("已检测 %d 个镜像,可刷新列表查看结果", len(images)),
- TaskType: svc.TaskTypeImageCheck,
+ TaskID: taskID,
+ Percentage: 100,
+ Name: "检查镜像更新",
+ Message: "检测完成",
+ DetailMsg: detail,
+ TaskType: svc.TaskTypeImageCheck,
+ UpdatableImages: updatable,
})
}
+// collectUpdatableImages 遍历本轮检测的镜像,按 image.ID 查内存检查结果,
+// 收集 NeedUpdate=true 的镜像为可更新清单。多主机同名同 tag 镜像去重,
+// 只关心「哪些镜像可更新」,不区分所属主机。
+//
+// 关键:仅统计 InUsed=true(有容器正在使用)的镜像,与容器列表「有更新」角标
+// (getcontainerlist.go 按容器 ImageID 判断)保持同一口径。否则会把无任何容器
+// 使用、仅 registry 有新版本的镜像也算进来,导致任务中心可更新数与角标对不上。
+func (l *CheckUpdateLogic) collectUpdatableImages(images []types.Image) []svc.UpdatableImage {
+ snapshot := l.svcCtx.HubImageInfo.Snapshot()
+ seen := make(map[string]struct{})
+ result := make([]svc.UpdatableImage, 0)
+ for _, img := range images {
+ // 仅纳入被容器使用的镜像,对齐容器角标口径
+ if !img.InUsed {
+ continue
+ }
+ r, ok := snapshot[img.ID]
+ if !ok || !r.NeedUpdate {
+ continue
+ }
+ key := img.ImageName + ":" + img.ImageTag
+ if _, dup := seen[key]; dup {
+ continue
+ }
+ seen[key] = struct{}{}
+ result = append(result, svc.UpdatableImage{ImageName: img.ImageName, ImageTag: img.ImageTag})
+ }
+ return result
+}
+
// finishCheck 统一收尾,避免各分支重复写 IsDone。
func (l *CheckUpdateLogic) finishCheck(taskID string, p svc.TaskProgress) {
p.IsDone = true
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 b3de9890..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,15 +2426,35 @@ 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 == "" {
+ imageToUse = c.Image // 降级使用 Image 字段
+ }
updateContainers = append(updateContainers, UpdateContainer{
ID: c.ID,
Name: name,
- Image: c.Image,
+ Image: imageToUse,
})
}
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_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_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/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/checkupdate.go b/internal/module/checkupdate.go
index 2545e4bc..a819bf0a 100644
--- a/internal/module/checkupdate.go
+++ b/internal/module/checkupdate.go
@@ -15,9 +15,27 @@ import (
"time"
)
+// CheckStatus 单个镜像的更新检查状态。
+type CheckStatus string
+
+const (
+ // StatusLatest 已是最新(本地 digest 与远端一致)。
+ StatusLatest CheckStatus = "latest"
+ // StatusNeedUpdate 有更新(本地 digest 与远端不一致)。
+ StatusNeedUpdate CheckStatus = "needUpdate"
+ // StatusUnknown 检测未完成/失败(token 失败、网络错误、无 RepoDigests 等)。
+ // 关键:失败必须显式标记为未知,而不是留空——留空会被前端当成"最新",
+ // 造成"检测失败却显示 0 个待更新"的假象。
+ StatusUnknown CheckStatus = "unknown"
+)
+
// ImageCheckList 检查更新处理后的镜像列表
type ImageCheckList struct {
NeedUpdate bool
+ // Status 明确区分 最新/有更新/未知,供前端与日志分级展示。
+ Status CheckStatus
+ // Reason 未知/失败时的原因(如 auth failed、no repoDigests),便于排查。
+ Reason string
}
// ImageUpdateData 保存镜像更新检查结果。
@@ -33,6 +51,10 @@ type ImageUpdateData struct {
// 同一轮内多个镜像可能指向同一 URL(多主机同镜像),跨轮则在 TTL 内复用,
// 避免对 registry 的重复 HEAD 请求(Docker Hub 有匿名速率限制)。
digestCache map[string]digestCacheEntry
+ // tokenCache 缓存 registry 拉取 token,key 为 registry host + repository path。
+ // 每个镜像每轮原本都要走 challenge + token 两次往返(auth.docker.io 经常慢),
+ // 同一仓库在 TTL 内复用同一 token 可显著减少往返、加快整轮检查。
+ tokenCache map[string]tokenCacheEntry
}
// digestCacheEntry 单条 digest 缓存。只缓存成功结果,失败不缓存以便下轮立即重试。
@@ -41,12 +63,21 @@ type digestCacheEntry struct {
cachedAt time.Time
}
+// tokenCacheEntry 单条 token 缓存。只缓存成功获取的非空 token。
+type tokenCacheEntry struct {
+ token string
+ cachedAt time.Time
+}
+
const ContentDigestHeader = "Docker-Content-Digest"
const (
// digestCacheTTL 远端 digest 缓存有效期。取值需明显小于最小检查周期,
// 保证「用户手动点检查更新」能拿到较新结果,同时挡住同一轮内的重复请求。
digestCacheTTL = 5 * time.Minute
+ // tokenCacheTTL registry token 缓存有效期。registry 签发的 token 通常有效期
+ // 数分钟(Docker Hub 约 5 分钟),这里取略小值,保证复用期间 token 不会过期失效。
+ tokenCacheTTL = 4 * time.Minute
// checkConcurrency 单轮检查的并发度。并行发起 registry 请求,
// 上限避免大量镜像时打爆 registry 速率限制或本地连接数。
checkConcurrency = 8
@@ -58,6 +89,7 @@ func NewImageCheck() *ImageUpdateData {
return &ImageUpdateData{
Data: map[string]ImageCheckList{},
digestCache: map[string]digestCacheEntry{},
+ tokenCache: map[string]tokenCacheEntry{},
}
}
@@ -91,6 +123,36 @@ func (i *ImageUpdateData) storeDigestCache(url, digest string) {
i.digestCache[url] = digestCacheEntry{digest: digest, cachedAt: now}
}
+// lookupTokenCache 读取未过期的 token 缓存。
+func (i *ImageUpdateData) lookupTokenCache(key string) (string, bool) {
+ i.mu.RLock()
+ defer i.mu.RUnlock()
+ e, ok := i.tokenCache[key]
+ if !ok || time.Since(e.cachedAt) > tokenCacheTTL {
+ return "", false
+ }
+ return e.token, true
+}
+
+// storeTokenCache 写入 token 缓存,只缓存非空 token,并顺带清理过期条目。
+func (i *ImageUpdateData) storeTokenCache(key, token string) {
+ if token == "" {
+ return
+ }
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ if i.tokenCache == nil {
+ i.tokenCache = map[string]tokenCacheEntry{}
+ }
+ now := time.Now()
+ for k, v := range i.tokenCache {
+ if now.Sub(v.cachedAt) > tokenCacheTTL {
+ delete(i.tokenCache, k)
+ }
+ }
+ i.tokenCache[key] = tokenCacheEntry{token: token, cachedAt: now}
+}
+
// NeedUpdate 以并发安全的方式读取指定镜像ID是否需要更新。
func (i *ImageUpdateData) NeedUpdate(imageID string) (bool, bool) {
i.mu.RLock()
@@ -238,7 +300,15 @@ func (i *ImageUpdateData) CheckUpdateWithProgress(imageList []types.Image, onPro
func (i *ImageUpdateData) checkSingleImage(image types.Image) {
imageRef := image.ImageName + ":" + image.ImageTag
- // ImageName/ImageTag 解析失败的镜像(悬空镜像等)无法构造 manifest URL,直接跳过
+ // markUnknown 统一记录"检测未完成/失败"结果。
+ // 关键修复:以前这些分支直接 return 不写结果,map 里没有该 imageID,
+ // 前端 NeedUpdate 读到 (false,false) 会当成"最新",导致失败被吞成 0 个待更新。
+ // 现在显式写入 StatusUnknown,让失败可见、可区分。
+ markUnknown := func(reason string) {
+ i.setResult(image.ID, ImageCheckList{NeedUpdate: false, Status: StatusUnknown, Reason: reason})
+ }
+
+ // ImageName/ImageTag 解析失败的镜像(悬空镜像等)无法构造 manifest URL,直接跳过(不标未知,本就无意义)
if image.ImageName == "None" || image.ImageTag == "None" {
logx.Debugf("跳过无有效 tag 的镜像: %s (ID: %s)", imageRef, image.ID)
return
@@ -247,16 +317,26 @@ func (i *ImageUpdateData) checkSingleImage(image types.Image) {
digestURL, err := BuildManifestURL(image)
if err != nil {
logx.Errorf("构造 manifest URL 失败 [%s]: %v", imageRef, err)
+ markUnknown("构造 manifest URL 失败: " + err.Error())
return
}
// 优先命中缓存,省掉 token 获取 + manifest HEAD 两次网络往返
remoteDigest, cached := i.lookupDigestCache(digestURL)
if !cached {
- // 部分 registry 无需 token 即可读 manifest,取不到不算失败,继续尝试匿名请求
- token, errToken := GetToken(image, "")
- if errToken != nil {
- logx.Debugf("获取 token 失败或无需 token [%s]: %v", imageRef, errToken)
+ // token 缓存 key 用「registry host + repository path」(不含 tag),
+ // 同一仓库不同 tag 的 pull token 通用,可跨镜像/跨轮复用。
+ tokenKey := tokenCacheKey(image)
+ token, tokenCached := i.lookupTokenCache(tokenKey)
+ if !tokenCached {
+ // 部分 registry 无需 token 即可读 manifest,取不到不算失败,继续尝试匿名请求
+ var errToken error
+ token, errToken = GetToken(image, "")
+ if errToken != nil {
+ logx.Debugf("获取 token 失败或无需 token [%s]: %v", imageRef, errToken)
+ }
+ // 只缓存成功获取的非空 token(storeTokenCache 内部已对空值忽略)
+ i.storeTokenCache(tokenKey, token)
}
remoteDigest, err = GetDigest(digestURL, token)
if err != nil {
@@ -268,6 +348,7 @@ func (i *ImageUpdateData) checkSingleImage(image types.Image) {
} else {
logx.Errorf("获取远端 digest 失败 [%s] url=%s: %v", imageRef, digestURL, err)
}
+ markUnknown("获取远端 digest 失败: " + err.Error())
return
}
i.storeDigestCache(digestURL, remoteDigest)
@@ -276,10 +357,12 @@ func (i *ImageUpdateData) checkSingleImage(image types.Image) {
if len(image.RepoDigests) == 0 {
// 本地构建、未推送过的镜像没有 RepoDigests,无法比对,属正常情况
logx.Debugf("本地无 repoDigest,跳过比对 [%s]", imageRef)
+ markUnknown("本地无 repoDigest,无法比对")
return
}
if remoteDigest == "" {
logx.Errorf("远端返回的 digest 为空 [%s]", imageRef)
+ markUnknown("远端返回的 digest 为空")
return
}
@@ -296,16 +379,30 @@ func (i *ImageUpdateData) checkSingleImage(image types.Image) {
break
}
}
+ status := StatusLatest
if needUpdate {
// 有更新是需要用户关注的结论,保持 Info
logx.Infof("镜像有更新 [%s] remoteDigest=%s localRepoDigests=%v",
imageRef, remoteDigest, image.RepoDigests)
+ status = StatusNeedUpdate
} else {
// 已是最新占绝大多数,降为 Debug,避免每轮刷屏
logx.Debugf("镜像已是最新 [%s]", imageRef)
}
// 并发安全写入,禁止直接操作 map
- i.setResult(image.ID, ImageCheckList{NeedUpdate: needUpdate})
+ i.setResult(image.ID, ImageCheckList{NeedUpdate: needUpdate, Status: status})
+}
+
+// tokenCacheKey 生成 token 缓存 key:registry host + repository path(不含 tag)。
+// 同一仓库不同 tag 的 pull token 通用,用此 key 可最大化复用。
+// 解析失败时回退用 ImageName,保证不同镜像不会误共享 token。
+func tokenCacheKey(image types.Image) string {
+ normalizedRef, err := ref.ParseNormalizedNamed(image.ImageName)
+ if err != nil {
+ return image.ImageName
+ }
+ host, _ := GetRegistryAddress(normalizedRef.Name())
+ return host + "/" + ref.Path(normalizedRef)
}
func BuildManifestURL(image types.Image) (string, error) {
@@ -361,10 +458,15 @@ func GetDigest(url string, token string) (string, error) {
if token != "" {
req.Header.Add("Authorization", token)
}
- req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v2+json")
+ // Accept 头顺序决定 registry 返回哪一层的 Docker-Content-Digest。
+ // 多架构镜像本地 RepoDigests 存的是「manifest list / OCI index」的 digest,
+ // 因此必须把 list/index 排在单架构 manifest 之前,否则 Docker Hub 会返回
+ // 单架构 manifest 的 digest,与本地索引 digest 永远不相等,导致误判。
+ req.Header.Add("Accept", "application/vnd.oci.image.index.v1+json")
req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.list.v2+json")
+ req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v2+json")
+ req.Header.Add("Accept", "application/vnd.oci.image.manifest.v1+json")
req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v1+json")
- req.Header.Add("Accept", "application/vnd.oci.image.index.v1+json")
res, err := digestHTTPClient.Do(req)
if err != nil {
diff --git a/internal/module/compose/runner.go b/internal/module/compose/runner.go
index b5d85ae8..f768b81e 100644
--- a/internal/module/compose/runner.go
+++ b/internal/module/compose/runner.go
@@ -1,12 +1,16 @@
package compose
import (
+ "bufio"
"bytes"
"context"
"fmt"
+ "io"
"os/exec"
"strings"
"time"
+
+ "github.com/l429609201/dockerCopilot/internal/svc"
)
// ActionResult 承载一次 compose 命令的执行结果。
@@ -34,7 +38,14 @@ func IsSupportedAction(action string) bool {
// RunAction 在指定项目目录执行 docker compose 子命令。
// 通过白名单限定子命令,使用 context 控制超时,合并 stdout/stderr 输出。
+// 如果提供了 svcCtx 和 taskID,会实时更新任务进度(流式输出日志)。
func RunAction(ctx context.Context, projectDir, composeFile, action string, timeoutSec int) ActionResult {
+ return RunActionWithProgress(ctx, projectDir, composeFile, action, timeoutSec, nil, "")
+}
+
+// RunActionWithProgress 执行 compose 命令并实时更新任务进度。
+// svcCtx 和 taskID 用于实时推送日志到前端;为 nil 时降级为 RunAction 行为。
+func RunActionWithProgress(ctx context.Context, projectDir, composeFile, action string, timeoutSec int, svcCtx *svc.ServiceContext, taskID string) ActionResult {
start := time.Now()
args, ok := supportedActions[action]
if !ok {
@@ -51,13 +62,77 @@ func RunAction(ctx context.Context, projectDir, composeFile, action string, time
fullArgs = append(fullArgs, args...)
cmd := exec.CommandContext(cmdCtx, "docker", fullArgs...)
cmd.Dir = projectDir
- var buf bytes.Buffer
- cmd.Stdout = &buf
- cmd.Stderr = &buf
- err := cmd.Run()
+ // 创建管道捕获 stdout 和 stderr
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return ActionResult{Success: false, Output: fmt.Sprintf("创建输出管道失败: %s", err.Error()), Duration: time.Since(start).Milliseconds()}
+ }
+ stderr, err := cmd.StderrPipe()
+ if err != nil {
+ return ActionResult{Success: false, Output: fmt.Sprintf("创建错误管道失败: %s", err.Error()), Duration: time.Since(start).Milliseconds()}
+ }
+
+ // 启动命令
+ if err := cmd.Start(); err != nil {
+ // 特殊处理:docker 命令未找到的情况,给出更清晰的提示
+ if strings.Contains(err.Error(), "executable file not found") {
+ return ActionResult{
+ Success: false,
+ Output: fmt.Sprintf("❌ Docker 命令未找到\n\n"+
+ "可能的原因:\n"+
+ "1. Docker 未安装或未加入 PATH 环境变量\n"+
+ "2. 容器内运行需要:\n"+
+ " - 挂载 Docker socket: -v /var/run/docker.sock:/var/run/docker.sock\n"+
+ " - 容器内安装 Docker CLI\n\n"+
+ "原始错误:%s", err.Error()),
+ Duration: time.Since(start).Milliseconds(),
+ }
+ }
+ return ActionResult{Success: false, Output: fmt.Sprintf("启动命令失败: %s", err.Error()), Duration: time.Since(start).Milliseconds()}
+ }
+
+ // 实时读取并更新进度
+ var outputBuf bytes.Buffer
+ outputChan := make(chan string, 100)
+ done := make(chan struct{})
+
+ // 合并 stdout 和 stderr 的输出
+ go streamOutput(stdout, outputChan)
+ go streamOutput(stderr, outputChan)
+
+ // 实时更新任务进度
+ go func() {
+ for line := range outputChan {
+ outputBuf.WriteString(line)
+ outputBuf.WriteString("\n")
+
+ // 如果提供了 svcCtx 和 taskID,实时更新进度
+ if svcCtx != nil && taskID != "" {
+ progress, exists := svcCtx.GetProgress(taskID)
+ if exists {
+ // 保留完整输出,限制长度避免过大
+ fullOutput := outputBuf.String()
+ if len(fullOutput) > 8000 {
+ // 只保留最后 8000 字符
+ fullOutput = "...(前面内容已截断)\n" + fullOutput[len(fullOutput)-8000:]
+ }
+ progress.DetailMsg = fullOutput
+ svcCtx.UpdateProgress(taskID, progress)
+ }
+ }
+ }
+ close(done)
+ }()
+
+ // 等待命令完成
+ err = cmd.Wait()
+ close(outputChan)
+ <-done
+
duration := time.Since(start).Milliseconds()
- output := strings.TrimSpace(buf.String())
+ output := strings.TrimSpace(outputBuf.String())
+
if cmdCtx.Err() == context.DeadlineExceeded {
return ActionResult{Success: false, Output: fmt.Sprintf("命令超时(%ds)\n%s", timeoutSec, output), Duration: duration}
}
@@ -66,3 +141,11 @@ func RunAction(ctx context.Context, projectDir, composeFile, action string, time
}
return ActionResult{Success: true, Output: output, Duration: duration}
}
+
+// streamOutput 从 reader 读取输出并发送到 channel
+func streamOutput(reader io.Reader, output chan<- string) {
+ scanner := bufio.NewScanner(reader)
+ for scanner.Scan() {
+ output <- scanner.Text()
+ }
+}
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/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/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 78111d3b..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,27 +100,42 @@ func (s *Scheduler) runUpdateCheck() {
if name == "" {
continue
}
+ // 优先使用 CreateImage,避免镜像更新后 Image 字段变空或变成 SHA256
+ imageToUse := c.CreateImage
+ if imageToUse == "" {
+ imageToUse = c.Image // 降级使用 Image 字段
+ }
+ item := notify.UpdateItem{ID: c.ID, Name: name, Image: imageToUse}
if _, ok := muted[name]; ok {
- continue // 已屏蔽
+ mutedPending = append(mutedPending, item) // 已屏蔽
+ } else {
+ pending = append(pending, item) // 未屏蔽
}
- pending = append(pending, notify.UpdateItem{ID: c.ID, Name: name, Image: c.Image})
}
- 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())
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/svc/servicecontext.go b/internal/svc/servicecontext.go
index bc37bcfc..b8c91046 100644
--- a/internal/svc/servicecontext.go
+++ b/internal/svc/servicecontext.go
@@ -87,6 +87,15 @@ type TaskProgress struct {
// Layers 镜像拉取时各分层(layer)的实时进度,供前端任务中心展开显示。
// 仅拉取类任务有值,其它任务为空(omitempty 向后兼容)。
Layers []LayerProgress `json:"layers,omitempty"`
+ // UpdatableImages 「检查镜像更新」任务完成时,可更新的镜像清单,供任务中心展开显示。
+ // 仅镜像检查任务有值,其它任务为空(omitempty 向后兼容)。
+ UpdatableImages []UpdatableImage `json:"updatableImages,omitempty"`
+}
+
+// UpdatableImage 一条可更新的镜像记录,供任务中心「可更新列表」展开显示。
+type UpdatableImage struct {
+ ImageName string `json:"imageName"` // 镜像名(不含 tag)
+ ImageTag string `json:"imageTag"` // 镜像标签
}
// LayerProgress 单个镜像分层(layer)的拉取进度。
diff --git a/internal/types/composeTypes.go b/internal/types/composeTypes.go
index 722e2f1c..9def5ef9 100644
--- a/internal/types/composeTypes.go
+++ b/internal/types/composeTypes.go
@@ -57,3 +57,26 @@ type ComposeConfigReq struct {
CommandTimeoutSec int `json:"commandTimeoutSec,optional"`
AllowHighRisk bool `json:"allowHighRisk,optional"`
}
+
+// ComposeCreateFolderReq 在指定目录下创建文件夹。
+type ComposeCreateFolderReq struct {
+ ParentPath string `json:"parentPath"` // 父目录路径(绝对路径)
+ FolderName string `json:"folderName"` // 文件夹名称
+}
+
+// ComposeCreateFileReq 在指定目录下创建 Compose 配置文件。
+type ComposeCreateFileReq struct {
+ ParentPath string `json:"parentPath"` // 父目录路径(绝对路径)
+ FileName string `json:"fileName"` // 文件名(如 docker-compose.yml)
+}
+
+// ComposeReadFileReq 读取指定路径的文件内容。
+type ComposeReadFileReq struct {
+ Path string `form:"path"` // 文件完整路径(绝对路径)
+}
+
+// ComposeSaveFileReq 保存文件内容到指定路径。
+type ComposeSaveFileReq struct {
+ Path string `json:"path"` // 文件完整路径(绝对路径)
+ Content string `json:"content"` // 文件内容
+}
diff --git a/internal/types/containerInfo.go b/internal/types/containerInfo.go
index 906733c4..fbbdaa2d 100644
--- a/internal/types/containerInfo.go
+++ b/internal/types/containerInfo.go
@@ -6,9 +6,15 @@ import (
type Container struct {
docker.Container
- Update bool `json:"Update"`
+ // Update 表示该容器所用镜像是否有更新。JSON 字段名为 haveUpdate,与前端一致
+ // (前端 containerState / 卡片 / 列表均读取 c.haveUpdate)。历史上后端曾输出
+ // "Update",与前端 "haveUpdate" 不匹配,导致"有更新"统计恒为 0,此处对齐修复。
+ Update bool `json:"haveUpdate"`
// HostID / HostName 标记该容器所属的 Docker 主机(多 Docker 管理)。
// 空表示本地主机,兼容历史行为。
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/types/types.go b/internal/types/types.go
index 459947f9..e54d2c71 100644
--- a/internal/types/types.go
+++ b/internal/types/types.go
@@ -16,6 +16,7 @@ type ContainerUpdateReq struct {
IdReq
ImageNameAndTag string `form:"imageNameAndTag"`
ContainerName string `form:"containerName"`
+ DelOldContainer string `form:"delOldContainer,default=true"` // "true" 或 "false",默认删除旧容器
}
// CreateContainerReq 从零创建新容器的请求(Portainer 风格)。
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/internal/utiles/helper.go b/internal/utiles/helper.go
index 2ee66dc0..86a20387 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
@@ -141,7 +141,53 @@ func doHelperUpdate(ctx context.Context, cli *client.Client, targetID, targetNam
return fmt.Errorf("启动新容器失败: %w", err)
}
- logx.Infof("[helper] ✅ DC 自我更新成功!新容器 %s 已启动", targetName)
+ // 启动成功后等待 2 秒,检查容器是否仍在运行(排除启动后立即崩溃的情况)
+ logx.Info("[helper] 等待 2 秒以验证新容器是否稳定运行")
+ time.Sleep(2 * time.Second)
+ inspect, err := cli.ContainerInspect(ctx, created.ID)
+ if err != nil {
+ logx.Errorf("[helper] 无法检查新容器状态: %v(已启动但无法确认运行状态)", err)
+ } else if !inspect.State.Running {
+ logx.Errorf("[helper] 新容器启动后立即退出(ExitCode=%d),回滚", inspect.State.ExitCode)
+ // 尝试获取容器日志(前 50 行),帮助诊断问题
+ if logs, logErr := cli.ContainerLogs(ctx, created.ID, container.LogsOptions{
+ ShowStdout: true, ShowStderr: true, Tail: "50",
+ }); logErr == nil {
+ defer logs.Close()
+ buf := make([]byte, 4096)
+ if n, _ := logs.Read(buf); n > 0 {
+ logx.Errorf("[helper] 新容器启动失败日志(前 50 行):\n%s", string(buf[:n]))
+ }
+ }
+ // 回滚:删除崩溃的新容器,重启旧容器
+ _ = cli.ContainerRemove(ctx, created.ID, container.RemoveOptions{Force: true})
+ if !delOld {
+ _ = cli.ContainerRename(ctx, backupName, targetName)
+ _ = cli.ContainerStart(ctx, targetID, container.StartOptions{})
+ logx.Infof("[helper] 已回滚:旧容器 %s 已重启", targetName)
+ }
+ return fmt.Errorf("新容器启动后立即退出: ExitCode=%d", inspect.State.ExitCode)
+ }
+
+ logx.Infof("[helper] ✅ DC 自我更新成功!新容器 %s 已启动并运行正常", targetName)
+
+ // 成功完成后,主动删除 helper 自己(因 selfupdate.go 已改为 AutoRemove=false)
+ // 通过容器名找到自己的 ID
+ helperName := targetName + "-selfupdate-helper"
+ containers, err := cli.ContainerList(ctx, container.ListOptions{All: true})
+ if err == nil {
+ for _, c := range containers {
+ for _, name := range c.Names {
+ // Docker API 返回的容器名带前导斜杠
+ if name == "/"+helperName || name == helperName {
+ logx.Infof("[helper] 自清理:删除 %s (ID=%s)", helperName, c.ID[:12])
+ _ = cli.ContainerRemove(ctx, c.ID, container.RemoveOptions{Force: true})
+ return nil
+ }
+ }
+ }
+ }
+ logx.Infof("[helper] 自清理:未找到自己的容器记录(可能已被外部清理)")
// 历史 -old- 备份的清理已统一移至函数开头的 defer,成功与失败路径都会执行
return nil
diff --git a/internal/utiles/image.go b/internal/utiles/image.go
index 442a3fe3..b1095647 100644
--- a/internal/utiles/image.go
+++ b/internal/utiles/image.go
@@ -69,7 +69,8 @@ func GetAllImagesList(ctx *svc.ServiceContext) ([]MyType.Image, error) {
ctx.AppConfig.EnsureLocalHost()
hosts := ctx.AppConfig.ListDockerHosts()
var all []MyType.Image
- seen := make(map[string]struct{})
+ // seen 记录镜像ID -> 在 all 中的下标,便于去重后回填 InUsed
+ seen := make(map[string]int)
for _, h := range hosts {
if !h.Enabled {
continue
@@ -79,12 +80,18 @@ func GetAllImagesList(ctx *svc.ServiceContext) ([]MyType.Image, error) {
logx.Errorf("聚合镜像列表跳过主机[%s:%s]: %v", h.ID, h.Name, err)
continue
}
- // 按镜像ID去重:不同主机上相同镜像 digest 相同,检查一次即可
+ // 按镜像ID去重:不同主机上相同镜像 digest 相同,检查一次即可。
+ // InUsed 取「任一主机在用即为在用」:同一镜像在 A 主机未用、B 主机有容器在用时,
+ // 去重保留的那条必须反映 B 的在用状态,否则「有容器在用的可更新镜像」统计会漏项,
+ // 与容器列表「有更新」角标对不上。
for _, img := range list {
- if _, ok := seen[img.ID]; ok {
+ if idx, ok := seen[img.ID]; ok {
+ if img.InUsed {
+ all[idx].InUsed = true
+ }
continue
}
- seen[img.ID] = struct{}{}
+ seen[img.ID] = len(all)
all = append(all, img)
}
}
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..0b0e441e 100644
--- a/internal/utiles/sanitizecontainerconfig.go
+++ b/internal/utiles/sanitizecontainerconfig.go
@@ -14,30 +14,42 @@ 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)。
+// 6. NetworkingConfig.EndpointsConfig 包含运行态字段(EndpointID、Gateway、
+// IPAddress、DNSNames 等):官方 daemon 会自动忽略,但非标准 daemon 可能
+// 校验并拒绝启动(报错 "endpoint already exists" 或类似网络冲突错误)。
//
// 处理原则:只做「补齐」与「剔除明显非法值」,不改变用户的有效配置语义。
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 +95,85 @@ func sanitizeDevices(containerName string, hostConfig *container.HostConfig) {
}
}
-// sanitizeEndpointMac 清理与网络模式冲突的 MAC 地址残留。
-func sanitizeEndpointMac(containerName string, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig) {
+// sanitizeEndpointMac 清理网络端点配置中的运行态字段与冲突值。
+//
+// Docker daemon 在容器启动时会自动分配 EndpointID、Gateway、IPAddress 等运行态字段,
+// inspect 返回的 EndpointsConfig 直接包含这些字段。官方 daemon 在 ContainerCreate 时
+// 会自动忽略它们,但非标准 daemon(群晖、定制环境)可能会校验并拒绝启动。
+//
+// 本函数清理策略:
+// 1. 清除所有运行态字段(EndpointID、Gateway、IPAddress、DNSNames 等)
+// 2. 保留用户有效配置(Aliases、Links、DriverOpts、IPAMConfig)
+// 3. host 网络模式或低版本 API 下额外清除 MacAddress
+func sanitizeEndpointMac(containerName, apiVersion string, hostConfig *container.HostConfig, networkingConfig *network.NetworkingConfig) {
if networkingConfig == nil || len(networkingConfig.EndpointsConfig) == 0 {
return
}
- // 仅 host 网络模式需要处理:该模式下不允许指定 MAC,带上会被守护进程拒绝。
- // 其他网络模式下 MAC 是用户的有效配置,必须原样保留。
+
+ // 判定 MAC 地址是否需要清除(满足任一条件即清)
//
- // 这里不用 NetworkMode.IsHost():该方法在 Docker SDK 中是平台相关实现,
- // 在 Windows 编译目标下对 "host" 会返回 false,行为不一致。直接比较字符串。
- if string(hostConfig.NetworkMode) != "host" {
- return
+ // 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)
+ shouldClearMac := isHostNetwork || isLegacyAPI
+
+ macReason := ""
+ if shouldClearMac {
+ if isHostNetwork {
+ macReason = "host 网络模式"
+ } else {
+ macReason = "daemon API 版本 " + apiVersion + " 低于 " + endpointMacMinAPIVersion + ",不支持 per-network MAC"
+ }
}
+
+ // 逐网络清理运行态字段
for netName, endpoint := range networkingConfig.EndpointsConfig {
- if endpoint == nil || endpoint.MacAddress == "" {
+ if endpoint == nil {
continue
}
- endpoint.MacAddress = ""
- logx.Infof("容器 %s: host 网络模式下清除网络 %s 的 MAC 地址残留", containerName, netName)
+ hadOperationalData := false
+
+ // 清除运行态字段(daemon 自动分配,不应由客户端提供)
+ if endpoint.EndpointID != "" {
+ endpoint.EndpointID = ""
+ hadOperationalData = true
+ }
+ if endpoint.Gateway != "" {
+ endpoint.Gateway = ""
+ hadOperationalData = true
+ }
+ if endpoint.IPAddress != "" {
+ endpoint.IPAddress = ""
+ hadOperationalData = true
+ }
+ if endpoint.GlobalIPv6Address != "" {
+ endpoint.GlobalIPv6Address = ""
+ hadOperationalData = true
+ }
+ if endpoint.IPv6Gateway != "" {
+ endpoint.IPv6Gateway = ""
+ hadOperationalData = true
+ }
+ if len(endpoint.DNSNames) > 0 {
+ endpoint.DNSNames = nil
+ hadOperationalData = true
+ }
+
+ // 按条件清除 MAC 地址
+ if shouldClearMac && endpoint.MacAddress != "" {
+ endpoint.MacAddress = ""
+ logx.Infof("容器 %s: %s,已清除网络 %s 的 MAC 地址", containerName, macReason, netName)
+ }
+
+ if hadOperationalData {
+ logx.Infof("容器 %s: 已清除网络 %s 的运行态字段(EndpointID/Gateway/IPAddress/DNSNames 等)", containerName, 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/selfupdate.go b/internal/utiles/selfupdate.go
index 00b4894f..a01322f8 100644
--- a/internal/utiles/selfupdate.go
+++ b/internal/utiles/selfupdate.go
@@ -170,10 +170,16 @@ func StartHelperContainer(svcCtx *svc.ServiceContext, targetID, targetName, newI
Image: newImage,
Env: env,
}
- // 挂载宿主 docker.sock,让辅助容器能操作 Docker
+ // 配置 helper 容器:只需挂载 Docker socket,无需复制主程序的其他配置。
+ // 绑定 /var/run/docker.sock 让 helper 能调用 Docker API 操作本机容器。
+ //
+ // AutoRemove 策略变更(兼容性改进):
+ // 旧:AutoRemove=true,helper 退出即删除,失败时日志立即消失无法排查。
+ // 新:AutoRemove=false,失败时保留容器供用户查看日志(docker logs -selfupdate-helper);
+ // 成功时由 helper 在完成所有操作后主动删除自己(见 helper.go 的 doHelperUpdate 末尾)。
hostCfg := &container.HostConfig{
Binds: []string{"/var/run/docker.sock:/var/run/docker.sock"},
- AutoRemove: true,
+ AutoRemove: false, // 改为手动清理,失败时保留日志
}
name := targetName + "-selfupdate-helper"
created, err := cli.ContainerCreate(ctx, cfg, hostCfg, nil, nil, name)
diff --git a/internal/utiles/updatecontainer.go b/internal/utiles/updatecontainer.go
index 9e77a86e..42083e46 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 = "正在停止旧容器"
@@ -307,6 +307,7 @@ func UpdateContainerOnHost(ctx context.Context, serviceContext *svc.ServiceConte
serviceContext.UpdateProgress(taskID, oldTaskProgress)
// 启动新容器
+ logx.Infof("开始启动新容器: ID=%s, Name=%s, Image=%s", newContainerID, name, imageNameAndTag)
err = cli.ContainerStart(context.Background(), newContainerID, container.StartOptions{})
if err != nil {
// 【修复】启动失败时回滚:删除失败的新容器 + 恢复旧容器
@@ -327,6 +328,29 @@ func UpdateContainerOnHost(ctx context.Context, serviceContext *svc.ServiceConte
return err
}
+ // 【增强】启动后检查容器状态,确认是否真正运行
+ logx.Infof("新容器已调用启动命令,正在检查运行状态...")
+ time.Sleep(2 * time.Second) // 等待 2 秒让容器启动
+ newContainerInspect, inspectErr := cli.ContainerInspect(context.Background(), newContainerID)
+ if inspectErr != nil {
+ logx.Errorf("检查新容器状态失败: %v", inspectErr)
+ } else {
+ logx.Infof("新容器状态: Running=%v, Status=%s, ExitCode=%d",
+ newContainerInspect.State.Running,
+ newContainerInspect.State.Status,
+ newContainerInspect.State.ExitCode)
+
+ // 如果容器已退出,记录退出原因
+ if !newContainerInspect.State.Running {
+ exitMsg := fmt.Sprintf("容器启动后立即退出 (ExitCode: %d)", newContainerInspect.State.ExitCode)
+ if newContainerInspect.State.Error != "" {
+ exitMsg += fmt.Sprintf(", 错误: %s", newContainerInspect.State.Error)
+ }
+ logx.Errorf("⚠️ %s", exitMsg)
+ oldTaskProgress.DetailMsg = exitMsg
+ }
+ }
+
// 【增强】补充收集新镜像信息(仅当拉取后未成功收集时)
if oldTaskProgress.NewImageDigest == "" {
newImageInfo, _, err := cli.ImageInspectWithRaw(context.Background(), imageNameAndTag)
diff --git a/start.sh b/start.sh
index 828a37d8..e57d8494 100644
--- a/start.sh
+++ b/start.sh
@@ -1,5 +1,5 @@
#!/bin/sh
-cd "${WORKDIR}" || exit
+cd "${WORKDIR:-/app}" || exit 1
# 判断当前目录下是否存在名为 dockerCopilot-new 的二进制文件
if [ -f "./dockerCopilot-new" ]; then
# 如果存在,则用它覆盖 dockerCopilot
diff --git a/version b/version
index 570c7965..e946d6bb 100644
--- a/version
+++ b/version
@@ -1 +1 @@
-v1.0.2
+v1.0.3