diff --git a/frontend-react/src/api/client.js b/frontend-react/src/api/client.js index b5415169..4a1dda2a 100644 --- a/frontend-react/src/api/client.js +++ b/frontend-react/src/api/client.js @@ -141,8 +141,30 @@ export const containerAPI = { removeContainer: (id, force = false, removeVolumes = false, hostId) => apiClient.delete(`/api/container/${id}?force=${force}&removeVolumes=${removeVolumes}${hostQ(hostId, '&')}`), inspectContainer: (id, hostId) => apiClient.get(`/api/container/${id}/inspect${hostQ(hostId)}`), + // 日志请求单独放宽超时到 60s:日志量大时后端从日志文件末尾回扫定位 tail 行 + 解复用流较慢, + // 全局 10s 会在“读取头部”阶段就超时。此处按请求覆盖 timeout,不影响其他快接口。 + // 说明:此一次性接口现仅用于「下载完整日志」,页面查看已改走下方 SSE 流式接口。 getContainerLogs: (id, { tail = 200, timestamps = false, since = '' } = {}, hostId) => - apiClient.get(`/api/container/${id}/logs?tail=${tail}×tamps=${timestamps}&since=${encodeURIComponent(since)}${hostQ(hostId, '&')}`), + apiClient.get(`/api/container/${id}/logs?tail=${tail}×tamps=${timestamps}&since=${encodeURIComponent(since)}${hostQ(hostId, '&')}`, { timeout: 60000 }), + // 构造 SSE 流式日志的完整 URL(EventSource 无法带 Authorization 头,token 走 query)。 + // 边读边推:首行秒级到达;search 交给后端 grep,follow 为实时跟随(-f)。 + buildLogsStreamURL: (id, { tail = 200, timestamps = false, since = '', follow = false, search = '' } = {}, hostId) => { + const token = localStorage.getItem('docker_copilot_token') || '' + const base = + (typeof window !== 'undefined' && window.__API_BASE_URL) || + localStorage.getItem('api_base_url') || + (typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.host}` : '') + const p = new URLSearchParams({ + tail: String(tail), + timestamps: String(timestamps), + follow: String(follow), + token, + }) + if (since) p.set('since', since) + if (search) p.set('search', search) + if (hostId) p.set('hostId', hostId) + return `${base}/api/container/${id}/logs/stream?${p.toString()}` + }, execContainer: (id, cmd, workDir = '', user = '', hostId) => apiClient.post(`/api/container/${id}/exec`, { cmd, workDir, user, hostId }), topContainer: (id, hostId) => apiClient.get(`/api/container/${id}/top${hostQ(hostId)}`), @@ -299,6 +321,8 @@ export const dockerHostAPI = { remove: (id) => apiClient.delete(`/api/docker/hosts/${id}`), // 测试指定主机连通性 ping: (id) => apiClient.post(`/api/docker/hosts/${id}/ping`), + // 获取指定主机的 Docker 详细信息(docker info + version) + info: (id) => apiClient.get(`/api/docker/hosts/${id}/info`), } // GitHub API - 用于检查前端更新 diff --git a/frontend-react/src/components/ContainerListRow.jsx b/frontend-react/src/components/ContainerListRow.jsx index 27e32158..db6312ab 100644 --- a/frontend-react/src/components/ContainerListRow.jsx +++ b/frontend-react/src/components/ContainerListRow.jsx @@ -32,7 +32,11 @@ export function ContainerListRow({ } }} className={cn( - "relative overflow-hidden flex items-center gap-3 px-3 py-2.5 bg-white dark:bg-gray-800 border rounded-xl cursor-pointer transition-all hover:shadow-sm", + // 注意:这里不能加 overflow-hidden,否则会裁剪小屏三点下拉菜单(absolute top-full 向下溢出行边界)。 + // 整行更新进度条的圆角裁剪由其自身容器(下方 rounded-xl overflow-hidden)负责,无需行级 overflow。 + // menuOpen 时提升行层级(z-30),确保展开的菜单浮于相邻列表行之上。 + "relative flex items-center gap-3 px-3 py-2.5 bg-white dark:bg-gray-800 border rounded-xl cursor-pointer transition-all hover:shadow-sm", + menuOpen && "z-30", selected ? "border-primary-400 dark:border-primary-600 ring-1 ring-primary-300" : "border-gray-200 dark:border-gray-700" )} > @@ -202,10 +206,10 @@ export function ContainerListRow({ {menuOpen && ( <> - {/* 遮罩:点击关闭菜单 */} -
setMenuOpen(false)} /> - {/* 下拉菜单 */} -
+ {/* 遮罩:全屏铺满、点击关闭菜单。z-40 高于被提升的行(z-30),保证任意点击都能命中遮罩 */} +
{ e.stopPropagation(); setMenuOpen(false) }} /> + {/* 下拉菜单:z-50 浮于遮罩之上,为最上层 */} +
{ onOpen(container); setMenuOpen(false) }} icon={Info} text="详情" />
{running ? ( diff --git a/frontend-react/src/components/ContainerOps.jsx b/frontend-react/src/components/ContainerOps.jsx index 076c712b..ed3da271 100644 --- a/frontend-react/src/components/ContainerOps.jsx +++ b/frontend-react/src/components/ContainerOps.jsx @@ -59,7 +59,9 @@ export function ContainerLogs({ container, onClose }) {
+ // 日志是等宽宽内容(HTTP 行含长 UA + 三列布局),max-w-3xl(768px) 过窄导致大量截断贴边, + // 放宽到 max-w-6xl(1152px),宽屏下有充足横向空间,窄屏仍由 w-full 自适应。 + : 'w-full max-w-6xl max-h-[90vh] rounded-xl')}>

@@ -91,7 +93,8 @@ export function ContainerConsole({ container, onClose }) {
+ // 终端输出同为等宽宽内容,与日志弹窗保持一致放宽到 max-w-6xl(1152px)。 + : 'w-full max-w-6xl max-h-[90vh] rounded-xl')}>

@@ -225,28 +228,143 @@ const LEVEL_COLOR = { debug: 'text-gray-500', } -// 日志面板:支持行数/时间戳、关键词搜索过滤+高亮、日志下载 +// 单行结构化日志:时间 | 位置 | 内容 三列对齐 +// 正文默认单行截断避免超长 User-Agent 刷屏,点击整行可展开/收起完整内容 +function StructuredLogRow({ obj, kw }) { + const [expanded, setExpanded] = useState(false) + return ( +
setExpanded((v) => !v)} + className="group flex items-baseline gap-x-4 px-4 py-1.5 border-l-2 border-transparent hover:border-sky-500/60 hover:bg-gray-800/50 cursor-pointer transition-colors" + title={expanded ? '点击收起' : '点击展开完整内容'} + > + {/* 时间列:固定宽度 + 等宽数字,保证纵向对齐 */} + + {formatLogTime(obj.time)} + + {/* 位置列:左对齐(尾部溢出才省略)+ 弱化配色,默认极淡、hover 提亮,避免抢占正文视线 */} + + {obj.caller ? highlightLine(obj.caller, kw) : '—'} + + {/* 内容列:默认单行截断,展开后完整换行显示 */} + + {highlightLine(obj.content, kw)} + +
+ ) +} + +// 日志面板:SSE 流式读取(边收边渲染,首行秒级到达),支持行数/时间戳、 +// 后端关键词过滤(grep)、实时跟随(-f)、日志下载。 function LogsPanel({ id, name, hostId }) { const [logs, setLogs] = useState('') const [tail, setTail] = useState(200) const [timestamps, setTimestamps] = useState(false) const [loading, setLoading] = useState(false) - const [search, setSearch] = useState('') // 搜索关键词 - const [pretty, setPretty] = useState(true) // 是否结构化展示(仅对 JSON 日志生效) + const [streaming, setStreaming] = useState(false) // 是否正在接收流(follow 模式下持续为 true) + const [errMsg, setErrMsg] = useState('') // 流错误提示 + const [search, setSearch] = useState('') // 搜索关键词(提交后交后端 grep) + const [appliedSearch, setAppliedSearch] = useState('') // 已应用到后端的关键词(防抖后) + const [follow, setFollow] = useState(true) // 实时跟随(-f),默认开启:打开即持续接收新日志 + const [pretty, setPretty] = useState(true) // 是否结构化展示(仅对 JSON 日志生效) const [autoScroll, setAutoScroll] = useState(true) // 自动滚动到最新一行,默认开启 - const scrollRef = React.useRef(null) // 日志滚动容器 + const [reloadKey, setReloadKey] = useState(0) // 手动刷新触发重连 + const scrollRef = React.useRef(null) // 日志滚动容器 + const esRef = React.useRef(null) // 当前 EventSource 实例 + const bufRef = React.useRef([]) // 行缓冲,批量 flush 到 state,避免高频 setState 卡顿 - const load = useCallback(async () => { + // 建立 SSE 流式连接:tail/timestamps/follow/appliedSearch/主机/reloadKey 变化都重连。 + // 边收边渲染,行先入 bufRef 缓冲,再由定时器批量刷入 state(降低重渲染频率)。 + React.useEffect(() => { + // 关闭旧连接 + if (esRef.current) { esRef.current.close(); esRef.current = null } + bufRef.current = [] + setLogs('') + setErrMsg('') setLoading(true) - try { - const r = await containerAPI.getContainerLogs(id, { tail, timestamps }, hostId) - setLogs(r.data?.data?.logs || '(无日志)') - } catch (e) { - setLogs('读取失败:' + e.message) - } finally { setLoading(false) } - }, [id, tail, timestamps, hostId]) + setStreaming(true) - React.useEffect(() => { load() }, [load]) + const url = containerAPI.buildLogsStreamURL( + id, { tail, timestamps, follow, search: appliedSearch }, hostId, + ) + const es = new EventSource(url) + esRef.current = es + + // 批量 flush:每 120ms 把缓冲行拼接追加到 logs,兼顾实时性与性能 + const flush = () => { + if (bufRef.current.length === 0) return + const chunk = bufRef.current.join('\n') + bufRef.current = [] + setLogs((prev) => (prev ? prev + '\n' + chunk : chunk)) + } + const timer = setInterval(flush, 120) + + es.addEventListener('open', () => { + // 连接建立/自动重连成功:清除“连接中断”类错误,恢复 streaming 标记 + setStreaming(true) + setErrMsg('') + }) + es.addEventListener('log', (ev) => { + bufRef.current.push(ev.data) + setLoading(false) + }) + es.addEventListener('end', () => { + // 后端“读完历史日志”事件(仅非 follow 模式会发):正常收尾并关闭连接 + flush() + setLoading(false) + setStreaming(false) + es.close(); esRef.current = null + }) + // 后端主动下发的业务错误事件(named event: "error" 且带 data):展示错误并关闭 + es.addEventListener('error', (ev) => { + if (ev && ev.data) { + flush() + setLoading(false) + setStreaming(false) + setErrMsg(ev.data) + es.close(); esRef.current = null + } + // 无 data 的情况交给下面的 onerror(连接层错误)统一处理 + }) + // 连接层错误(网络抖动/服务端关闭):EventSource 会自动重连, + // 这里【不能】主动 close,否则会掐死正在重连的连接(表现为“跟随”消失、要重开才看得到)。 + // 仅在 readyState 为 CLOSED(浏览器放弃重连)时才收尾。 + es.onerror = () => { + flush() + setLoading(false) + if (es.readyState === EventSource.CLOSED) { + // 浏览器已放弃重连,标记流结束 + setStreaming(false) + esRef.current = null + } else { + // CONNECTING:正在自动重连,保持连接,仅暂时置为非活跃提示 + setStreaming(false) + } + } + + return () => { clearInterval(timer); flush(); es.close(); esRef.current = null } + }, [id, tail, timestamps, follow, appliedSearch, hostId, reloadKey]) + + // 搜索防抖:输入停止 400ms 后作为关键词提交给后端 grep(触发上面的重连) + React.useEffect(() => { + const t = setTimeout(() => setAppliedSearch(search.trim()), 400) + return () => clearTimeout(t) + }, [search]) + + // 手动刷新:bump reloadKey 触发上面的 effect 重连(复用同一套连接逻辑,避免重复代码) + const load = useCallback(() => { setReloadKey((k) => k + 1) }, []) // 用户手动向上滚动时自动关闭跟随,滚回底部(20px 容差)时重新开启 const onScroll = useCallback(() => { @@ -256,14 +374,8 @@ function LogsPanel({ id, name, hostId }) { setAutoScroll(atBottom) }, []) - // 按关键词过滤出需要展示的行(不区分大小写),空关键词时展示全部 - const shownLines = useMemo(() => { - const lines = logs.split('\n') - const kw = search.trim() - if (!kw) return lines - const lower = kw.toLowerCase() - return lines.filter((l) => l.toLowerCase().includes(lower)) - }, [logs, search]) + // 后端已按 appliedSearch 过滤,这里直接展示全部行;搜索为空时也是全部 + const shownLines = useMemo(() => logs.split('\n'), [logs]) // 逐行解析,并判断整体是否为结构化日志(过半行可解析才启用三列视图) const { rows, structured } = useMemo(() => { @@ -280,24 +392,35 @@ function LogsPanel({ id, name, hostId }) { if (el) el.scrollTop = el.scrollHeight }, [rows, autoScroll, loading]) - // 下载当前完整日志为 .log 文件(不受搜索过滤影响,导出全部内容) - const download = () => { - const blob = new Blob([logs], { type: 'text/plain;charset=utf-8' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - const ts = new Date().toISOString().replace(/[:.]/g, '-') - a.href = url - a.download = `${(name || id).slice(0, 40)}_${ts}.log` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) + // 下载完整日志为 .log:走一次性接口按当前行数拉取完整内容(不受搜索/跟随影响), + // 避免只导出流式已过滤/已渲染的片段。 + const [downloading, setDownloading] = useState(false) + const download = async () => { + setDownloading(true) + try { + const r = await containerAPI.getContainerLogs(id, { tail, timestamps }, hostId) + const text = r.data?.data?.logs || '' + const blob = new Blob([text], { type: 'text/plain;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + const ts = new Date().toISOString().replace(/[:.]/g, '-') + a.href = url + a.download = `${(name || id).slice(0, 40)}_${ts}.log` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + } catch (e) { + setErrMsg('下载失败:' + (e.message || '未知错误')) + } finally { setDownloading(false) } } - const kw = search.trim() + // 高亮用已应用到后端的关键词(appliedSearch),避免输入过程中闪烁 + const kw = appliedSearch return ( -
+ // 补横向 + 底部内边距,与 header 的 p-3 sm:p-5 对齐,避免工具栏和深色日志区直接贴弹窗外框边缘。 +
)} - {/* 搜索框:实时过滤并高亮匹配行 */} + {/* 搜索框:输入后交后端 grep 过滤(防抖 400ms),可扫描远超前端承载的日志量 */}
- setSearch(e.target.value)} placeholder="搜索日志…" + setSearch(e.target.value)} placeholder="搜索日志(后端过滤)…" className="w-full pl-7 pr-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-900" />
+ {/* 实时跟随(-f):开启后持续接收容器新产生的日志 */} + {/* 自动滚动:开启后每次日志更新都跟随到最新一行 */} -
- {kw && ( -
匹配 {shownLines.length} 行
- )} + {/* 状态行:后端过滤中 / 匹配行数 / 跟随中 / 错误 */} +
+ {kw && 已按“{kw}”后端过滤,{shownLines.filter(l => l.trim()).length} 行} + {streaming && follow && ● 实时跟随中} + {errMsg && {errMsg}} +
- {loading - ? '加载中...' - : (kw && shownLines.length === 0) - ? '(无匹配行)' + className="flex-1 min-h-[300px] overflow-auto text-xs font-mono py-2 bg-gray-900 text-gray-100 rounded-lg leading-relaxed"> + {loading && !logs + ?
加载中...
+ : (logs === '' || shownLines.every(l => !l.trim())) + ?
{kw ? '(无匹配行)' : '(无日志)'}
: rows.map(({ raw, obj }, i) => ( (structured && pretty && obj) ? ( - // 三列布局:时间 | 位置 | 内容,行间用细分隔线区分 -
- - {formatLogTime(obj.time)} - - {/* 无 caller 的行(如标准库 log 输出)用占位符保持三列对齐 */} - - {obj.caller ? highlightLine(obj.caller, kw) : '—'} - - - {highlightLine(obj.content, kw)} - -
+ ) : ( -
{highlightLine(raw, kw)}
+
{highlightLine(raw, kw)}
) ))}
@@ -389,7 +510,8 @@ function ExecPanel({ id, fullscreen, hostId }) { const disconnect = () => setConnected(false) return ( -
+ // 与 header 的 p-3 sm:p-5 对齐补内边距,避免配置栏和终端区贴弹窗外框边缘。 +
{/* 连接配置栏 */}
diff --git a/frontend-react/src/components/DockerHosts.jsx b/frontend-react/src/components/DockerHosts.jsx index 319e99ee..d23659e5 100644 --- a/frontend-react/src/components/DockerHosts.jsx +++ b/frontend-react/src/components/DockerHosts.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react' import { dockerHostAPI } from '../api/client.js' -import { Server, Plus, Trash2, RefreshCw, Wifi, WifiOff, Loader2, Save, X, HardDrive, AlertCircle } from 'lucide-react' +import { Server, Plus, Trash2, RefreshCw, Wifi, WifiOff, Loader2, Save, X, HardDrive, AlertCircle, Info } from 'lucide-react' // 多 Docker 管理页面:第一个恒为本地主机(不可删、地址固定),其余为远程 tcp:// 主机。 export function DockerHosts() { @@ -8,6 +8,7 @@ export function DockerHosts() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [editing, setEditing] = useState(null) // 正在编辑/新建的主机对象 + const [detailHost, setDetailHost] = useState(null) // 正在查看详情的主机 const [pingState, setPingState] = useState({}) // { [id]: 'testing'|'ok'|'fail' } const load = useCallback(async () => { @@ -71,7 +72,8 @@ export function DockerHosts() {
{hosts.map((h) => ( setEditing({ ...h })} onDelete={() => remove(h)} onPing={() => testPing(h.id)} /> + onEdit={() => setEditing({ ...h })} onDelete={() => remove(h)} onPing={() => testPing(h.id)} + onInfo={() => setDetailHost(h)} /> ))}
)} @@ -79,6 +81,9 @@ export function DockerHosts() { setEditing(null)} onSaved={async () => { setEditing(null); await load() }} /> )} + {detailHost && ( + setDetailHost(null)} /> + )}
) } @@ -110,7 +115,7 @@ function HostsHeader({ onAdd, onRefresh }) { } // 单个主机行 -function HostRow({ host, pingState, onEdit, onDelete, onPing }) { +function HostRow({ host, pingState, onEdit, onDelete, onPing, onInfo }) { const isLocal = host.local || host.type === 'local' const online = pingState ? pingState === 'ok' : host.online return ( @@ -133,6 +138,10 @@ function HostRow({ host, pingState, onEdit, onDelete, onPing }) { {pingState === 'testing' ? : online ? : } {pingState === 'testing' ? '测试中' : online ? '在线' : '离线'} + +
+
+ {loading ? ( +
+ 加载中... +
+ ) : err ? ( +
+ {err} +
+ ) : data ? ( + + ) : null} +
+
+
+ ) +} + +// 详情正文:按「版本 / 运行时 / 资源 / Registry / 其它」分组展示 +function HostInfoBody({ d }) { + return ( +
+ + + + + {Array.isArray(d.insecureRegistries) && d.insecureRegistries.length > 0 && ( + + )} +
+ ) +} + +// 信息分组:rows 为键值对表格;list 为纯列表(如 Mirrors) +function InfoSection({ title, rows, list, emptyText }) { + return ( +
+

{title}

+ {rows && ( +
+ {rows.filter(([, v]) => v !== undefined && v !== null && v !== '').map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+ )} + {list !== undefined && ( +
+ {Array.isArray(list) && list.length > 0 ? ( +
    + {list.map((item, i) => ( +
  • {item}
  • + ))} +
+ ) : ( +

{emptyText || '无'}

+ )} +
+ )} +
+ ) +} + // 主机新建/编辑弹窗。本地主机仅可改名/备注,地址与类型锁定。 function HostEditModal({ host, onClose, onSaved }) { const isLocal = host.local || host.type === 'local' || host.id === 'local' diff --git a/frontend-react/src/components/RuleEditor.jsx b/frontend-react/src/components/RuleEditor.jsx index 9985c0ea..fe242f8b 100644 --- a/frontend-react/src/components/RuleEditor.jsx +++ b/frontend-react/src/components/RuleEditor.jsx @@ -46,6 +46,8 @@ export function RuleEditor({ rule, registries, onCancel, onSave }) { type: rule.type || 'update', // 镜像清理范围,默认 dangling pruneMode: rule.pruneMode || 'dangling', + // 自动备份最大保留数(0=不限制,按主机计数);默认 0 + maxBackups: rule.maxBackups || 0, // 已选容器名集合(数组),从规则初始化(历史字段,仍用于兼容展示) containerNames: rule.containerNames || [], // 精确到「主机+容器名」的更新目标;历史规则无此字段时由 containerNames 兜底转换(视为本地) @@ -283,11 +285,22 @@ export function RuleEditor({ rule, registries, onCancel, onSave }) { )} - {/* 自动备份:说明 */} + {/* 自动备份:说明 + 最大保留数 */} {form.type === 'backup' && ( -
- 💾 备份将导出所选主机所有容器的配置为 JSON 文件,保存到备份目录,无需选择容器。 -
+ <> +
+ 💾 备份将导出所选主机所有容器的配置为 JSON 文件,保存到备份目录,无需选择容器。 +
+ + set('maxBackups', Math.max(0, Number(e.target.value) || 0))} + className="input w-32" placeholder="0" /> +

+ 备份完成后自动清理旧备份,仅保留最近 N 个(按每个主机分别计数)。 + 填 0 表示不限制。注意:备份文件按日期命名,同一天多次备份会覆盖同一文件,故 N 约等于保留天数。 +

+
+ )} {/* 自动更新:容器选择 + 拉取凭据 */} diff --git a/internal/handler/ops/dockerhosthandler.go b/internal/handler/ops/dockerhosthandler.go index faa08cf4..05e69bbb 100644 --- a/internal/handler/ops/dockerhosthandler.go +++ b/internal/handler/ops/dockerhosthandler.go @@ -75,3 +75,21 @@ func DockerHostPingHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { httpx.OkJsonCtx(r.Context(), w, resp) } } + +// DockerHostInfoHandler 返回指定 Docker 主机的详细信息(docker info + version)。 +func DockerHostInfoHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.DockerHostIDReq + if err := httpx.Parse(r, &req); err != nil { + httpx.ErrorCtx(r.Context(), w, err) + return + } + l := ops.NewDockerHostLogic(r.Context(), svcCtx) + resp, err := l.Info(&req) + if err != nil { + httpx.ErrorCtx(r.Context(), w, err) + return + } + httpx.OkJsonCtx(r.Context(), w, resp) + } +} diff --git a/internal/handler/ops/logsstreamhandler.go b/internal/handler/ops/logsstreamhandler.go new file mode 100644 index 00000000..946001e6 --- /dev/null +++ b/internal/handler/ops/logsstreamhandler.go @@ -0,0 +1,117 @@ +package ops + +import ( + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/l429609201/dockerCopilot/internal/module/containerops" + "github.com/l429609201/dockerCopilot/internal/svc" + "github.com/l429609201/dockerCopilot/internal/types" + "github.com/zeromicro/go-zero/core/logx" + "github.com/zeromicro/go-zero/rest/httpx" +) + +// LogsStreamHandler 通过 SSE 流式推送容器日志:逐行边读边下发,首行秒级到达。 +// 支持后端关键词过滤(search,等效 docker logs | grep)与实时跟随(follow,等效 -f)。 +// EventSource 无法带 Authorization 头,故用 query token 校验(复用 validWSToken)。 +func LogsStreamHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !validWSToken(r, svcCtx.Config.Auth.AccessSecret) { + http.Error(w, "未授权", http.StatusUnauthorized) + return + } + var req types.ContainerLogsStreamReq + if err := httpx.Parse(r, &req); err != nil { + http.Error(w, "参数错误: "+err.Error(), http.StatusBadRequest) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "不支持流式响应", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") // 关闭 Nginx 缓冲,保证实时下发 + + ctx := r.Context() + + // 心跳与日志推送会并发写同一个 ResponseWriter,用互斥锁串行化,避免数据竞争。 + var writeMu sync.Mutex + + // sendEvent 按 SSE 规范下发一条事件。日志行可能含换行, + // 需把内部换行拆成多条 data: 行(SSE 规定多行 data 以 \n 拼接为一条消息)。 + sendEvent := func(event, payload string) { + writeMu.Lock() + defer writeMu.Unlock() + if event != "" { + fmt.Fprintf(w, "event: %s\n", event) + } + for _, ln := range strings.Split(payload, "\n") { + fmt.Fprintf(w, "data: %s\n", ln) + } + fmt.Fprint(w, "\n") + flusher.Flush() + } + + // follow 模式下开心跳:每 15s 发一条 SSE 注释行(": ping"), + // 用途有二:①穿透反向代理/浏览器的空闲连接回收,保活长连接; + // ②在日志静默期也持续有字节下发,避免中间层把连接判定为“已结束”。 + if req.Follow { + go func() { + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + writeMu.Lock() + fmt.Fprint(w, ": ping\n\n") + flusher.Flush() + writeMu.Unlock() + } + } + }() + } + + svc := containerops.NewForHost(svcCtx, req.HostID) + opts := containerops.LogsStreamOptions{ + Tail: req.Tail, + Since: req.Since, + Timestamps: req.Timestamps, + Follow: req.Follow, + Search: req.Search, + } + + logx.Infof("📜 日志流开始 container=%s follow=%v tail=%d search=%q", req.Id, req.Follow, req.Tail, req.Search) + // 每收到一行就以 SSE "log" 事件下发;ctx 取消(客户端断连)时终止。 + var lineCount int + err := svc.LogsStream(ctx, req.Id, opts, func(line string) bool { + select { + case <-ctx.Done(): + return false + default: + } + sendEvent("log", line) + lineCount++ + return true + }) + logx.Infof("📜 日志流结束 container=%s follow=%v 已推送 %d 行 err=%v ctxErr=%v", req.Id, req.Follow, lineCount, err, ctx.Err()) + + if err != nil && ctx.Err() == nil { + // 非客户端主动断开的错误,作为 SSE "error" 事件告知前端 + logx.Errorf("流式日志读取失败 container=%s: %v", req.Id, err) + sendEvent("error", "读取日志失败: "+err.Error()) + return + } + // 非跟随模式:读完全部日志后发送 "end" 事件,前端据此停止 loading 并关闭连接 + if !req.Follow && ctx.Err() == nil { + sendEvent("end", "") + } + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index a040432c..5fcb3f64 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -389,6 +389,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/docker/hosts/:id/ping", Handler: ops.DockerHostPingHandler(serverCtx), }, + { + // 获取指定 Docker 主机的详细信息(docker info + version) + Method: http.MethodGet, + Path: "/docker/hosts/:id/info", + Handler: ops.DockerHostInfoHandler(serverCtx), + }, }, rest.WithJwt(serverCtx.Config.Auth.AccessSecret), rest.WithPrefix("/api"), @@ -559,6 +565,12 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { Path: "/container/stats/stream", Handler: ops.StatsStreamHandler(serverCtx), }, + { + // 容器日志 SSE 流式推送(边读边下发,支持后端 grep 过滤与实时跟随 -f,用 query token) + Method: http.MethodGet, + Path: "/container/:id/logs/stream", + Handler: ops.LogsStreamHandler(serverCtx), + }, { // 全部后台任务列表 SSE 推送(供任务中心实时展示所有任务) Method: http.MethodGet, diff --git a/internal/logic/ops/dockerhostlogic.go b/internal/logic/ops/dockerhostlogic.go index 2dcbe6fe..839cf8c2 100644 --- a/internal/logic/ops/dockerhostlogic.go +++ b/internal/logic/ops/dockerhostlogic.go @@ -3,7 +3,10 @@ package ops import ( "context" "strings" + "time" + dockertypes "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/system" "github.com/google/uuid" "github.com/l429609201/dockerCopilot/internal/module/appconfig" "github.com/l429609201/dockerCopilot/internal/svc" @@ -206,3 +209,121 @@ func (l *DockerHostLogic) Ping(req *types.DockerHostIDReq) (*types.Resp, error) } return &types.Resp{Code: 200, Msg: "success", Data: map[string]interface{}{"online": true}}, nil } + +// hostInfoView Docker 主机详细信息对外视图,字段按前端展示分组。 +type hostInfoView struct { + Online bool `json:"online"` + // 版本 + DockerVersion string `json:"dockerVersion"` + APIVersion string `json:"apiVersion"` + GoVersion string `json:"goVersion"` + GitCommit string `json:"gitCommit"` + OsType string `json:"osType"` + Architecture string `json:"architecture"` + KernelVersion string `json:"kernelVersion"` + // 运行时 + OperatingSystem string `json:"operatingSystem"` + Containers int `json:"containers"` + ContainersRunning int `json:"containersRunning"` + ContainersPaused int `json:"containersPaused"` + ContainersStopped int `json:"containersStopped"` + Images int `json:"images"` + ContainerdCommit string `json:"containerdCommit"` + RuncCommit string `json:"runcCommit"` + CgroupVersion string `json:"cgroupVersion"` + CgroupDriver string `json:"cgroupDriver"` + // 资源 + NCPU int `json:"ncpu"` + MemTotal int64 `json:"memTotal"` + DockerRootDir string `json:"dockerRootDir"` + StorageDriver string `json:"storageDriver"` + // Registry(运维页面明文展示镜像加速器) + Mirrors []string `json:"mirrors"` + InsecureRegistries []string `json:"insecureRegistries"` + // 其它 + HostName string `json:"hostName"` + DockerName string `json:"dockerName"` + SwarmActive bool `json:"swarmActive"` + DefaultRuntime string `json:"defaultRuntime"` +} + +// buildHostInfoView 把 docker info + version 组装成前端视图。 +// version 可能为零值(获取失败),此时版本类字段用 info.ServerVersion 兜底。 +func buildHostInfoView(_ appconfig.DockerHost, info system.Info, ver dockertypes.Version) hostInfoView { + v := hostInfoView{ + Online: true, + DockerVersion: info.ServerVersion, + APIVersion: ver.APIVersion, + GoVersion: ver.GoVersion, + GitCommit: ver.GitCommit, + OsType: info.OSType, + Architecture: info.Architecture, + KernelVersion: info.KernelVersion, + OperatingSystem: info.OperatingSystem, + Containers: info.Containers, + ContainersRunning: info.ContainersRunning, + ContainersPaused: info.ContainersPaused, + ContainersStopped: info.ContainersStopped, + Images: info.Images, + ContainerdCommit: info.ContainerdCommit.ID, + RuncCommit: info.RuncCommit.ID, + CgroupVersion: info.CgroupVersion, + CgroupDriver: info.CgroupDriver, + NCPU: info.NCPU, + MemTotal: info.MemTotal, + DockerRootDir: info.DockerRootDir, + StorageDriver: info.Driver, + HostName: info.Name, + DockerName: info.Name, + SwarmActive: info.Swarm.LocalNodeState == "active", + DefaultRuntime: info.DefaultRuntime, + } + // version 成功时优先用其 Version(与 info.ServerVersion 一致,但更权威) + if ver.Version != "" { + v.DockerVersion = ver.Version + } + // 镜像加速器 / 非安全 registry:明文展示(运维页面) + if info.RegistryConfig != nil { + v.Mirrors = info.RegistryConfig.Mirrors + for _, cidr := range info.RegistryConfig.InsecureRegistryCIDRs { + if cidr != nil { + v.InsecureRegistries = append(v.InsecureRegistries, cidr.String()) + } + } + } + return v +} + +// Info 返回指定主机的 Docker 详细信息(docker info + docker version 组合)。 +// 供多 Docker 页面「详情」弹窗展示版本、运行时、资源、镜像加速器等。 +// 主机离线或无连接时返回可读的错误原因,不视为接口失败(前端据此提示"无法连接")。 +func (l *DockerHostLogic) Info(req *types.DockerHostIDReq) (*types.Resp, error) { + host, ok := l.svcCtx.AppConfig.FindDockerHost(req.ID) + if !ok { + return &types.Resp{Code: 400, Msg: "主机不存在", Data: map[string]interface{}{}}, nil + } + cli, ok := l.svcCtx.DockerManager.GetClient(req.ID) + if !ok || cli == nil { + return &types.Resp{Code: 200, Msg: "success", Data: map[string]interface{}{ + "online": false, "reason": "主机无可用连接(未启用或连接失败)", + }}, nil + } + + ctx, cancel := context.WithTimeout(l.ctx, 8*time.Second) + defer cancel() + + info, err := cli.Info(ctx) + if err != nil { + return &types.Resp{Code: 200, Msg: "success", Data: map[string]interface{}{ + "online": false, "reason": "获取信息失败:" + err.Error(), + }}, nil + } + // version 获取失败不阻断:info 里也有 ServerVersion 兜底 + ver, verErr := cli.ServerVersion(ctx) + if verErr != nil { + logx.Errorf("获取主机[%s]版本失败: %v", req.ID, verErr) + } + + detail := buildHostInfoView(host, info, ver) + return &types.Resp{Code: 200, Msg: "success", Data: detail}, nil +} diff --git a/internal/logic/schedule/schedulelogic.go b/internal/logic/schedule/schedulelogic.go index 5d816725..4056702c 100644 --- a/internal/logic/schedule/schedulelogic.go +++ b/internal/logic/schedule/schedulelogic.go @@ -55,6 +55,7 @@ func (l *ScheduleLogic) Save(req *types.ScheduledRuleReq) (resp *types.Resp, err Name: req.Name, Type: req.Type, PruneMode: req.PruneMode, + MaxBackups: req.MaxBackups, Enabled: req.Enabled, Cron: req.Cron, ContainerNames: req.ContainerNames, diff --git a/internal/module/appconfig/types.go b/internal/module/appconfig/types.go index 576c134c..7b5a4bb2 100644 --- a/internal/module/appconfig/types.go +++ b/internal/module/appconfig/types.go @@ -108,6 +108,10 @@ type ScheduledUpdateRule struct { Cron string `json:"cron"` // PruneMode 镜像清理范围(仅 prune 类型使用):dangling(无tag) / unused(未使用)。 PruneMode string `json:"pruneMode,omitempty"` + // MaxBackups 自动备份保留的最大文件数(仅 backup 类型使用)。 + // 备份完成后,同一主机的 backup-*.json 超出该数量时按修改时间删除最旧的; + // 0 或负数表示不限制(保留全部)。按主机分别计数,互不挤占。 + MaxBackups int `json:"maxBackups,omitempty"` // ContainerNames 需要纳入本规则的容器名列表(历史字段,视为本地主机的容器,用于向后兼容)。 ContainerNames []string `json:"containerNames"` // ContainerTargets 精确到「主机+容器名」的更新目标(多 Docker 管理)。 diff --git a/internal/module/bot/handler.go b/internal/module/bot/handler.go index 797b47b3..bc3231cb 100644 --- a/internal/module/bot/handler.go +++ b/internal/module/bot/handler.go @@ -315,9 +315,8 @@ func (b *Bot) handleCallback(chatID int64, cb *telegram.CallbackQuery) { } // Compose 危险动作确认:cmpaconf|| if parts[0] == "cmpaconf" && len(parts) == 3 { - // 重新扫描找到项目并执行 - scanPaths := b.svcCtx.Config.Compose.ScanPaths - maxDepth := b.svcCtx.Config.Compose.MaxDepth + // 重新扫描找到项目并执行(走 AppConfig 优先的配置) + scanPaths, maxDepth := b.composeScanConfig() scanner := compose.NewScanner(scanPaths, maxDepth) projects := scanner.Scan() var target *compose.Project @@ -1162,12 +1161,29 @@ func (b *Bot) executeBatchUpdate(chatID int64, messageID int64) { // composePageSize Compose 项目列表每页展示条数(每项占一个按钮)。 const composePageSize = 8 +// composeScanConfig 读取生效的 Compose 扫描配置:优先动态配置(AppConfig),为空回退静态 yaml。 +// 与 Web 端 ComposeLogic.scanPaths()/maxDepth() 保持一致,避免 Bot 只读静态 yaml 导致 +// 用户在前端保存后 Bot 仍报“未配置扫描路径”。 +func (b *Bot) composeScanConfig() (scanPaths []string, maxDepth int) { + dyn := b.svcCtx.AppConfig.Get().Compose + if len(dyn.ScanPaths) > 0 { + scanPaths = dyn.ScanPaths + } else { + scanPaths = b.svcCtx.Config.Compose.ScanPaths + } + if dyn.MaxDepth > 0 { + maxDepth = dyn.MaxDepth + } else { + maxDepth = b.svcCtx.Config.Compose.MaxDepth + } + return scanPaths, maxDepth +} + // listComposeProjects 分页列出扫描到的 Compose 项目,每个项目一个按钮进入管理面板。 // page 从 0 开始;messageID > 0 时编辑原消息(翻页),否则发送新消息。 func (b *Bot) listComposeProjects(chatID int64, messageID int64, page int) { - // 从配置获取扫描路径和深度 - scanPaths := b.svcCtx.Config.Compose.ScanPaths - maxDepth := b.svcCtx.Config.Compose.MaxDepth + // 从配置获取扫描路径和深度(走 AppConfig 优先的配置) + scanPaths, maxDepth := b.composeScanConfig() // 返回主菜单按钮:错误提示与空列表也带上,避免用户卡在无按钮的独立消息里 backHomeKb := &telegram.InlineKeyboardMarkup{ InlineKeyboard: [][]telegram.InlineKeyboardButton{{ @@ -1225,9 +1241,8 @@ func (b *Bot) listComposeProjects(chatID int64, messageID int64, page int) { // showComposeProjectPanel 展示单个 Compose 项目的操作面板,提供 up/down/restart/pull/stop/start 按钮。 // messageID > 0 时编辑原消息,否则发送新消息。 func (b *Bot) showComposeProjectPanel(chatID int64, projectID string, messageID int64) { - // 重新扫描找到该项目 - scanPaths := b.svcCtx.Config.Compose.ScanPaths - maxDepth := b.svcCtx.Config.Compose.MaxDepth + // 重新扫描找到该项目(走 AppConfig 优先的配置) + scanPaths, maxDepth := b.composeScanConfig() scanner := compose.NewScanner(scanPaths, maxDepth) projects := scanner.Scan() @@ -1283,9 +1298,8 @@ func (b *Bot) showComposeProjectPanel(chatID int64, projectID string, messageID // executeComposeAction 执行 Compose 动作(危险操作如 down 需二次确认)。 func (b *Bot) executeComposeAction(chatID int64, projectID, action string, messageID int64) { - // 重新扫描找到项目 - scanPaths := b.svcCtx.Config.Compose.ScanPaths - maxDepth := b.svcCtx.Config.Compose.MaxDepth + // 重新扫描找到项目(走 AppConfig 优先的配置) + scanPaths, maxDepth := b.composeScanConfig() scanner := compose.NewScanner(scanPaths, maxDepth) projects := scanner.Scan() diff --git a/internal/module/checkupdate.go b/internal/module/checkupdate.go index a819bf0a..490fbe09 100644 --- a/internal/module/checkupdate.go +++ b/internal/module/checkupdate.go @@ -82,7 +82,9 @@ const ( // 上限避免大量镜像时打爆 registry 速率限制或本地连接数。 checkConcurrency = 8 // digestHTTPTimeout 单次 manifest HEAD 请求超时。 - digestHTTPTimeout = 20 * time.Second + // 直连 Docker Hub 网络较慢、多架构镜像 manifest 拉取偏慢时,20s 易误超时, + // 故放宽至 60s,减少 context deadline exceeded 类误报。 + digestHTTPTimeout = 60 * time.Second ) func NewImageCheck() *ImageUpdateData { diff --git a/internal/module/containerops/logs.go b/internal/module/containerops/logs.go index 050b6ee1..309c2a00 100644 --- a/internal/module/containerops/logs.go +++ b/internal/module/containerops/logs.go @@ -19,6 +19,11 @@ func (s *Service) Logs(ctx context.Context, id string, tail int, since string, t if tail <= 0 { tail = 200 } + // tail 上限保护:日志文件很大时,Docker daemon 需从文件末尾回扫定位到第 N 行, + // N 越大回扫越慢。限制上限避免用户填超大值把 daemon 拖垮、拉取超时。 + if tail > 5000 { + tail = 5000 + } if maxOutput <= 0 { maxOutput = 512 * 1024 } diff --git a/internal/module/containerops/logs_stream.go b/internal/module/containerops/logs_stream.go new file mode 100644 index 00000000..7a2a72d4 --- /dev/null +++ b/internal/module/containerops/logs_stream.go @@ -0,0 +1,116 @@ +package containerops + +import ( + "bufio" + "context" + "io" + "strings" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/pkg/stdcopy" +) + +// LogsStreamOptions 流式日志读取参数。 +type LogsStreamOptions struct { + Tail int // 初始返回最后多少行(<=0 取默认 200,上限 5000) + Since string // 起始时间(RFC3339 或 Unix 秒,空表示不限制) + Timestamps bool // 是否包含时间戳 + Follow bool // 是否持续跟随新日志(类似 docker logs -f) + Search string // 后端关键词过滤(不区分大小写),空表示不过滤 +} + +// LogsStream 流式读取容器日志:逐行读取并通过 onLine 回调实时下发, +// 相比一次性读取,首行可秒级到达,且 Follow 模式下能持续跟随新日志。 +// - onLine 返回 false 时提前终止(用于消费方主动中断,如客户端断连) +// - 后端在此直接做关键词过滤(等效 docker logs | grep),只把命中行回调出去, +// 从而支持扫描远超前端承载量的日志,且减少传输量。 +func (s *Service) LogsStream(ctx context.Context, id string, opts LogsStreamOptions, onLine func(line string) bool) error { + tail := opts.Tail + if tail <= 0 { + tail = 200 + } + // tail 上限保护:日志文件很大时 daemon 需从末尾回扫定位,N 越大越慢。 + if tail > 5000 { + tail = 5000 + } + + dockerOpts := container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Timestamps: opts.Timestamps, + Tail: itoa(tail), + Since: opts.Since, + Follow: opts.Follow, + } + cli, err := s.cliOrErr() + if err != nil { + return err + } + // 先探测容器是否为 tty 模式:tty 的日志是原始流(非多路复用), + // 用 stdcopy 解复用会失败读不到内容,需直接逐行读原始流。 + tty := false + if insp, ierr := cli.ContainerInspect(ctx, id); ierr == nil && insp.Config != nil { + tty = insp.Config.Tty + } + + reader, err := cli.ContainerLogs(ctx, id, dockerOpts) + if err != nil { + return err + } + defer reader.Close() + + // 关键词过滤(大小写不敏感),空关键词直接放行。 + keyword := strings.ToLower(strings.TrimSpace(opts.Search)) + match := func(line string) bool { + if keyword == "" { + return true + } + return strings.Contains(strings.ToLower(line), keyword) + } + + // ctx 取消时关闭 reader,让阻塞的读取(尤其 Follow 模式)立即返回。 + go func() { + <-ctx.Done() + _ = reader.Close() + }() + + // 逐行扫描的数据源:tty 模式直接读原始流;否则用管道 + stdcopy 解复用。 + var lineSrc io.Reader + if tty { + lineSrc = reader + } else { + pr, pw := io.Pipe() + go func() { + _, cerr := stdcopy.StdCopy(pw, pw, reader) + // 用 CloseWithError 把解复用错误传给读端 Scanner,读端据此结束。 + _ = pw.CloseWithError(cerr) + }() + lineSrc = pr + } + + scanner := bufio.NewScanner(lineSrc) + // 放大单行缓冲上限,避免超长行(如大 JSON 日志)触发 bufio.ErrTooLong。 + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + for scanner.Scan() { + // 消费方要求中断(客户端断连等) + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + line := scanner.Text() + if !match(line) { + continue + } + if !onLine(line) { + return nil + } + } + // Scanner 结束:返回扫描错误(io.Pipe 的 CloseWithError 会把解复用错误在此透出; + // 正常 EOF 时 scanner.Err() 为 nil)。 + if serr := scanner.Err(); serr != nil && serr != io.EOF { + return serr + } + return nil +} diff --git a/internal/module/scheduler/runner.go b/internal/module/scheduler/runner.go index 3b8d0eb1..37960417 100644 --- a/internal/module/scheduler/runner.go +++ b/internal/module/scheduler/runner.go @@ -41,6 +41,23 @@ func RunRule(svcCtx *svc.ServiceContext, notifier notify.Notifier, rule appconfi notifier.Notify("定时更新开始", fmt.Sprintf("规则「%s」开始执行,容器数:%d", rule.Name, len(targets))) } + // 汇总任务:在任务中心落一条“规则维度”的任务,展示本轮更新了哪些容器/镜像及结果。 + // 与各容器自身的更新任务并存(后者是单容器进度,前者是整轮汇总)。 + summaryTaskID := "sched-update-" + rule.ID + summaryName := "定时更新·" + rule.Name + // 显式传本次开始时间:同一规则复用固定 taskID,多次执行会覆盖同一条记录, + // 若不显式覆盖 StartedAt,UpdateProgress 会沿用上一轮的开始时间导致排序错乱。 + nowMs := time.Now().UnixNano() / int64(time.Millisecond) + svcCtx.UpdateProgress(summaryTaskID, svc.TaskProgress{ + TaskID: summaryTaskID, + Name: summaryName, + Percentage: 0, + Message: fmt.Sprintf("开始执行,目标容器 %d 个", len(targets)), + DetailMsg: "正在获取容器列表…", + TaskType: svc.TaskTypeScheduledUpdate, + StartedAt: nowMs, + }) + // 聚合所有主机的容器,保证远程主机的目标也能被匹配到 containers, err := utiles.GetAllContainers(svcCtx) if err != nil { @@ -49,6 +66,12 @@ func RunRule(svcCtx *svc.ServiceContext, notifier notify.Notifier, rule appconfi notifier.Notify("定时更新失败", fmt.Sprintf("规则「%s」获取容器列表失败:%s", rule.Name, err.Error())) } recordResult(svcCtx, rule.ID, "获取容器列表失败:"+err.Error()) + // 汇总任务标记失败结束 + svcCtx.UpdateProgress(summaryTaskID, svc.TaskProgress{ + TaskID: summaryTaskID, Name: summaryName, Percentage: 100, + Message: "获取容器列表失败", DetailMsg: err.Error(), + TaskType: svc.TaskTypeScheduledUpdate, IsDone: true, Failed: true, + }) return } containers = utiles.CheckImageUpdate(svcCtx, containers) @@ -139,6 +162,19 @@ func RunRule(svcCtx *svc.ServiceContext, notifier notify.Notifier, rule appconfi recordResult(svcCtx, rule.ID, summary) logx.Infof("定时更新规则[%s]执行完成:%s", rule.Name, summary) + // 汇总任务收尾:把本轮更新/跳过/失败的容器明细铺进 DetailMsg(前端任务中心 + // 完整换行显示),failed>0 且无成功时标红为失败态。前端零改动即可查看。 + svcCtx.UpdateProgress(summaryTaskID, svc.TaskProgress{ + TaskID: summaryTaskID, + Name: summaryName, + Percentage: 100, + Message: summary, + DetailMsg: buildUpdateDetail(updatedList, skippedList, failedList), + TaskType: svc.TaskTypeScheduledUpdate, + IsDone: true, + Failed: failed > 0 && updated == 0, + }) + // 保存本次执行明细到公共 result store:供 Bot 端「查看跳过/失败明细」和「重试全部失败」取用。 result := ¬ify.RuleUpdateResult{ RuleID: rule.ID, @@ -185,6 +221,35 @@ func RunRule(svcCtx *svc.ServiceContext, notifier notify.Notifier, rule appconfi notifier.Notify("定时更新完成", msg.String()) } +// buildUpdateDetail 把本轮更新/跳过/失败的容器明细拼成多行文本, +// 供任务中心汇总任务的 DetailMsg 完整展示(前端按 whitespace-pre-wrap 换行渲染)。 +// 每类最多列出 30 条,超出用省略提示,避免超长文本拖慢渲染。 +func buildUpdateDetail(updated, skipped, failed []notify.ResultItem) string { + const maxPerGroup = 30 + writeGroup := func(sb *strings.Builder, title string, items []notify.ResultItem) { + if len(items) == 0 { + return + } + sb.WriteString(title) + sb.WriteString("\n") + for i, it := range items { + if i >= maxPerGroup { + sb.WriteString(fmt.Sprintf(" … 及其余 %d 个\n", len(items)-maxPerGroup)) + break + } + sb.WriteString(fmt.Sprintf(" • %s(%s)\n", it.Name, it.Reason)) + } + } + var sb strings.Builder + writeGroup(&sb, "✅ 已更新:", updated) + writeGroup(&sb, "⏭️ 已跳过:", skipped) + writeGroup(&sb, "❌ 更新失败:", failed) + if sb.Len() == 0 { + return "本轮无匹配容器" + } + return strings.TrimRight(sb.String(), "\n") +} + // moveSelfToEnd 将 DC 自身所在容器移动到列表末尾,保证它最后一个更新。 // 判定条件与 runOne 中走 SelfUpdate 的条件保持一致:仅本地主机 + 命中自身。 // 保持其余容器的原有相对顺序,避免影响既有更新次序。 diff --git a/internal/module/scheduler/runner_tasks.go b/internal/module/scheduler/runner_tasks.go index 544141bd..d4563bfb 100644 --- a/internal/module/scheduler/runner_tasks.go +++ b/internal/module/scheduler/runner_tasks.go @@ -112,7 +112,17 @@ func runBackup(svcCtx *svc.ServiceContext, notifier notify.Notifier, rule appcon continue } okCount++ - perHostSummary = append(perHostSummary, fmt.Sprintf("%s:成功", hostName)) + hostMsg := fmt.Sprintf("%s:成功", hostName) + // 备份成功后,按规则的最大保留数清理该主机的旧备份(0/负数=不限制)。 + // 清理失败不影响备份本身,仅记录日志与摘要。 + if rule.MaxBackups > 0 { + if deleted, cErr := utiles.CleanupBackupsForHost(hostID, rule.MaxBackups); cErr != nil { + logx.Errorf("定时备份规则[%s]主机[%s]清理旧备份失败: %v", rule.Name, hostName, cErr) + } else if deleted > 0 { + hostMsg = fmt.Sprintf("%s:成功(清理旧备份 %d 个,保留最近 %d 个)", hostName, deleted, rule.MaxBackups) + } + } + perHostSummary = append(perHostSummary, hostMsg) } summary := fmt.Sprintf("备份完成 %d/%d 个主机(%s)", okCount, len(hosts), time.Now().Format("2006-01-02 15:04:05")) diff --git a/internal/types/opsTypes.go b/internal/types/opsTypes.go index 1cc0b830..4b158513 100644 --- a/internal/types/opsTypes.go +++ b/internal/types/opsTypes.go @@ -33,6 +33,19 @@ type ContainerLogsReq struct { HostID string `form:"hostId,optional"` } +// ContainerLogsStreamReq SSE 流式日志请求。 +// EventSource 无法自定义头,故 token 走 query;follow 为实时跟随,search 为后端关键词过滤。 +type ContainerLogsStreamReq struct { + Id string `path:"id"` + Tail int `form:"tail,default=200"` + Timestamps bool `form:"timestamps,default=false"` + Since string `form:"since,optional"` + Follow bool `form:"follow,default=false"` + Search string `form:"search,optional"` + HostID string `form:"hostId,optional"` + Token string `form:"token,optional"` +} + // ContainerExecReq 容器内命令执行请求。 type ContainerExecReq struct { Id string `path:"id"` diff --git a/internal/types/scheduledTypes.go b/internal/types/scheduledTypes.go index 9e0b4470..8af800db 100644 --- a/internal/types/scheduledTypes.go +++ b/internal/types/scheduledTypes.go @@ -9,6 +9,7 @@ type ScheduledRuleReq struct { Name string `json:"name"` Type string `json:"type,optional"` // 任务类型:update/prune/backup,空按 update PruneMode string `json:"pruneMode,optional"` // 清理范围:dangling/unused(仅 prune) + MaxBackups int `json:"maxBackups,optional"` // 备份最大保留数(仅 backup,0/负=不限制,按主机计数) Enabled bool `json:"enabled,optional"` Cron string `json:"cron,optional"` // 该规则独立的定时表达式(五段式cron或简化写法daily/hourly/interval) ContainerNames []string `json:"containerNames,optional"` diff --git a/internal/utiles/cleanupoldbackups.go b/internal/utiles/cleanupoldbackups.go new file mode 100644 index 00000000..d96e0880 --- /dev/null +++ b/internal/utiles/cleanupoldbackups.go @@ -0,0 +1,115 @@ +package utiles + +import ( + "os" + "path/filepath" + "sort" + "strings" + + "github.com/l429609201/dockerCopilot/internal/module/appconfig" + "github.com/zeromicro/go-zero/core/logx" +) + +// CleanupBackupsForHost 清理指定主机的容器配置备份,仅保留最近 maxKeep 个。 +// 规则: +// - 只处理该主机对应的 backup-*.json(本地为 backup-<日期>.json; +// 远程为 backup--<日期>.json),不动 .yaml 及其他文件。 +// - 按文件修改时间倒序,超出 maxKeep 的最旧文件被删除。 +// - maxKeep <= 0 表示不限制,直接返回。 +// +// 按主机分别计数,避免多 Docker 主机的备份互相挤占。 +func CleanupBackupsForHost(hostID string, maxKeep int) (deleted int, err error) { + if maxKeep <= 0 { + return 0, nil + } + if hostID == "" { + hostID = appconfig.DockerHostLocalID + } + + dir := BackupDir() + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, err + } + + // 收集属于该主机的备份文件(含修改时间用于排序)。 + type backupFile struct { + name string + modTime int64 + } + var files []backupFile + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !isBackupOfHost(name, hostID) { + continue + } + info, ierr := e.Info() + if ierr != nil { + continue + } + files = append(files, backupFile{name: name, modTime: info.ModTime().UnixNano()}) + } + + if len(files) <= maxKeep { + return 0, nil + } + + // 按修改时间倒序:最新在前,最旧在后。 + sort.Slice(files, func(i, j int) bool { return files[i].modTime > files[j].modTime }) + + // 删除超出 maxKeep 的最旧文件。 + for _, f := range files[maxKeep:] { + full := filepath.Join(dir, f.name) + if rmErr := os.Remove(full); rmErr != nil { + logx.Errorf("清理旧备份失败 %s: %v", f.name, rmErr) + continue + } + deleted++ + logx.Infof("🧹 已清理旧备份: %s", f.name) + } + return deleted, nil +} + +// isBackupOfHost 判断备份文件名是否属于指定主机的容器配置备份。 +// - 本地主机:backup-<日期>.json,且不含额外的 hostID 段(形如 backup-YYYY-MM-DD.json)。 +// - 远程主机:backup--<日期>.json。 +// 仅认 backup- 前缀 + .json 后缀,排除 .yaml 与其他文件。 +func isBackupOfHost(name, hostID string) bool { + if !strings.HasPrefix(name, "backup-") || !strings.HasSuffix(name, ".json") { + return false + } + mid := strings.TrimSuffix(strings.TrimPrefix(name, "backup-"), ".json") + if hostID == "" || hostID == appconfig.DockerHostLocalID { + // 本地:backup-<日期>.json,中间应形如 YYYY-MM-DD(不带 hostID 前缀)。 + return isDateLike(mid) + } + // 远程:backup--<日期>.json,前缀须精确匹配 hostID- 且余下为日期。 + prefix := hostID + "-" + if !strings.HasPrefix(mid, prefix) { + return false + } + return isDateLike(strings.TrimPrefix(mid, prefix)) +} + +// isDateLike 粗判字符串是否形如 YYYY-MM-DD(长度10、两个连字符、其余为数字)。 +// 用于区分「本地日期备份」与「远程 hostID-日期备份」,避免 hostID 含连字符时误判。 +func isDateLike(s string) bool { + if len(s) != 10 || s[4] != '-' || s[7] != '-' { + return false + } + for i, c := range s { + if i == 4 || i == 7 { + continue + } + if c < '0' || c > '9' { + return false + } + } + return true +} diff --git a/internal/utiles/selfupdate.go b/internal/utiles/selfupdate.go index a01322f8..f1ece6cb 100644 --- a/internal/utiles/selfupdate.go +++ b/internal/utiles/selfupdate.go @@ -118,33 +118,62 @@ func InspectSelfContainer(svcCtx *svc.ServiceContext) (types.ContainerJSON, erro if selfID := GetSelfContainerID(); selfID != "" { candidates = append(candidates, selfID) } - if h, err := os.Hostname(); err == nil && h != "" { - candidates = append(candidates, h) + // 单独保留 hostname:既作为 inspect 候选,也用于后续 Config.Hostname 精确比对 + selfHostname, _ := os.Hostname() + if selfHostname != "" { + candidates = append(candidates, selfHostname) } - // 逐个直接 inspect + // 逐个直接 inspect(候选恰为容器ID或容器名时命中) for _, c := range candidates { if insp, err := cli.ContainerInspect(context.Background(), c); err == nil { return insp, nil } } - // 兜底:遍历本地容器列表,按 ID 前缀或 hostname 匹配后再 inspect + // 需要遍历列表的兜底:先取一次容器列表 list, err := cli.ContainerList(context.Background(), container.ListOptions{All: true}) - if err == nil { - for _, cand := range candidates { - for _, item := range list { - short := item.ID - if len(short) >= 12 { - short = short[:12] - } - if strings.HasPrefix(item.ID, cand) || strings.HasPrefix(cand, short) { - if insp, e := cli.ContainerInspect(context.Background(), item.ID); e == nil { - return insp, nil - } + if err != nil { + return types.ContainerJSON{}, fmt.Errorf("无法定位当前所在容器且列出容器失败:%v", err) + } + // 兜底1:按 ID 前缀 / hostname 前缀匹配后再 inspect + for _, cand := range candidates { + for _, item := range list { + short := item.ID + if len(short) >= 12 { + short = short[:12] + } + if strings.HasPrefix(item.ID, cand) || strings.HasPrefix(cand, short) { + if insp, e := cli.ContainerInspect(context.Background(), item.ID); e == nil { + return insp, nil } } } } - return types.ContainerJSON{}, fmt.Errorf("无法定位当前所在容器(尝试的标识:%v)", candidates) + // 兜底2+3:一次遍历逐个 inspect,覆盖 cgroup 提取失败 + 自定义 hostname/container_name/host 网络的场景: + // - Config.Hostname == 本进程 hostname → 立即命中(Docker 默认把短ID写进 Config.Hostname, + // 用户显式设 hostname 时该字段同样等于 os.Hostname(),故精确可靠) + // - 同时收集挂载了 docker.sock 的容器,全局唯一时作为最终兜底(DC 必挂 docker.sock 才能工作) + var sockMatches []types.ContainerJSON + for _, item := range list { + insp, e := cli.ContainerInspect(context.Background(), item.ID) + if e != nil { + continue + } + if selfHostname != "" && insp.Config != nil && insp.Config.Hostname == selfHostname { + logx.Infof("📍 通过 Config.Hostname 匹配定位到自身容器: %s", insp.ID) + return insp, nil + } + for _, m := range insp.Mounts { + if strings.HasSuffix(m.Destination, "docker.sock") { + sockMatches = append(sockMatches, insp) + break + } + } + } + if len(sockMatches) == 1 { + logx.Infof("📍 通过唯一 docker.sock 挂载定位到自身容器: %s", sockMatches[0].ID) + return sockMatches[0], nil + } + return types.ContainerJSON{}, fmt.Errorf("无法定位当前所在容器(尝试的标识:%v,docker.sock 候选数:%d)", candidates, len(sockMatches)) } // StartHelperContainer 用新镜像启动一个一次性辅助容器,接管主容器的更新收尾。 diff --git a/version b/version index e946d6bb..3e7bcf08 100644 --- a/version +++ b/version @@ -1 +1 @@ -v1.0.3 +v1.0.4