From efe3af06d0d87de4e7182707b619ab95b20f153c Mon Sep 17 00:00:00 2001
From: l429609201 <429609201@qq.com>
Date: Sat, 29 Aug 2026 19:09:15 +0800
Subject: [PATCH 07/12] =?UTF-8?q?```=20feat(container):=20=E6=94=B9?=
=?UTF-8?q?=E8=BF=9B=E5=AE=B9=E5=99=A8=E8=87=AA=E6=A3=80=E9=80=BB=E8=BE=91?=
=?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=A4=9A=E7=A7=8D=E5=AE=9A=E4=BD=8D=E7=AD=96?=
=?UTF-8?q?=E7=95=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
单独保留hostname用于后续Config.Hostname精确比对,
增加通过Config.Hostname匹配和docker.sock挂载检测的定位方式,
提升在复杂部署环境下的容器识别准确率,
同时优化错误处理提供更详细的错误信息。
```
---
internal/utiles/selfupdate.go | 61 ++++++++++++++++++++++++++---------
1 file changed, 45 insertions(+), 16 deletions(-)
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 用新镜像启动一个一次性辅助容器,接管主容器的更新收尾。
From 35e23892528566ec19c75ab4eb97445cff9ed3ca Mon Sep 17 00:00:00 2001
From: l429609201 <429609201@qq.com>
Date: Sun, 30 Aug 2026 19:49:44 +0800
Subject: [PATCH 08/12] =?UTF-8?q?```=20feat(container):=20=E5=AE=B9?=
=?UTF-8?q?=E5=99=A8=E6=97=A5=E5=BF=97=E6=94=AF=E6=8C=81SSE=E6=B5=81?=
=?UTF-8?q?=E5=BC=8F=E8=AF=BB=E5=8F=96=E5=92=8C=E5=90=8E=E7=AB=AF=E8=BF=87?=
=?UTF-8?q?=E6=BB=A4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增SE流式日志接口,支持边读边渲染,首行秒级到达
- 添加后端关键词过滤功能(search参数),支持扫描大量日志
- 实现实时跟随模式(follow参数),持续接收新日志
- 日志下载功能改为独立接口,避免受前端流式渲染影响
- 添加搜索防抖机制,提升用户体验
- 优化前端日志面板UI,显示实时状态和错误信息
- 增加tail参数上限保护,防止Docker daemon回扫超时
```
---
frontend-react/src/api/client.js | 24 ++-
.../src/components/ContainerOps.jsx | 161 +++++++++++++-----
internal/handler/ops/logsstreamhandler.go | 85 +++++++++
internal/handler/routes.go | 6 +
internal/module/containerops/logs.go | 5 +
internal/module/containerops/logs_stream.go | 116 +++++++++++++
internal/types/opsTypes.go | 13 ++
7 files changed, 364 insertions(+), 46 deletions(-)
create mode 100644 internal/handler/ops/logsstreamhandler.go
create mode 100644 internal/module/containerops/logs_stream.go
diff --git a/frontend-react/src/api/client.js b/frontend-react/src/api/client.js
index 594f7a2c..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)}`),
diff --git a/frontend-react/src/components/ContainerOps.jsx b/frontend-react/src/components/ContainerOps.jsx
index 8cc31876..73ef48cb 100644
--- a/frontend-react/src/components/ContainerOps.jsx
+++ b/frontend-react/src/components/ContainerOps.jsx
@@ -266,28 +266,82 @@ function StructuredLogRow({ obj, 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(false) // 实时跟随(-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)
+
+ const url = containerAPI.buildLogsStreamURL(
+ id, { tail, timestamps, follow, search: appliedSearch }, hostId,
+ )
+ const es = new EventSource(url)
+ esRef.current = es
- React.useEffect(() => { load() }, [load])
+ // 批量 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('log', (ev) => {
+ bufRef.current.push(ev.data)
+ setLoading(false)
+ })
+ es.addEventListener('end', () => {
+ flush()
+ setLoading(false)
+ setStreaming(false)
+ es.close(); esRef.current = null
+ })
+ es.addEventListener('error', (ev) => {
+ // 后端主动下发的错误事件带 data;EventSource 网络错误则 data 为空
+ flush()
+ setLoading(false)
+ setStreaming(false)
+ if (ev.data) setErrMsg(ev.data)
+ else if (!follow) setErrMsg('日志流连接中断')
+ es.close(); esRef.current = null
+ })
+
+ 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(() => {
@@ -297,14 +351,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(() => {
@@ -321,21 +369,31 @@ 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 对齐,避免工具栏和深色日志区直接贴弹窗外框边缘。
@@ -354,12 +412,22 @@ function LogsPanel({ id, name, hostId }) {
setPretty(e.target.checked)} /> 解析
)}
- {/* 搜索框:实时过滤并高亮匹配行 */}
+ {/* 搜索框:输入后交后端 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
+ {loading && !logs
?
加载中...
- : (kw && shownLines.length === 0)
- ?
(无匹配行)
+ : (logs === '' || shownLines.every(l => !l.trim()))
+ ?
{kw ? '(无匹配行)' : '(无日志)'}
: rows.map(({ raw, obj }, i) => (
(structured && pretty && obj) ? (
diff --git a/internal/handler/ops/logsstreamhandler.go b/internal/handler/ops/logsstreamhandler.go
new file mode 100644
index 00000000..fe85452e
--- /dev/null
+++ b/internal/handler/ops/logsstreamhandler.go
@@ -0,0 +1,85 @@
+package ops
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "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()
+
+ // sendEvent 按 SSE 规范下发一条事件。日志行可能含换行,
+ // 需把内部换行拆成多条 data: 行(SSE 规定多行 data 以 \n 拼接为一条消息)。
+ sendEvent := func(event, payload string) {
+ 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()
+ }
+
+ svc := containerops.NewForHost(svcCtx, req.HostID)
+ opts := containerops.LogsStreamOptions{
+ Tail: req.Tail,
+ Since: req.Since,
+ Timestamps: req.Timestamps,
+ Follow: req.Follow,
+ Search: req.Search,
+ }
+
+ // 每收到一行就以 SSE "log" 事件下发;ctx 取消(客户端断连)时终止。
+ err := svc.LogsStream(ctx, req.Id, opts, func(line string) bool {
+ select {
+ case <-ctx.Done():
+ return false
+ default:
+ }
+ sendEvent("log", line)
+ return true
+ })
+
+ 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 82cd1555..5fcb3f64 100644
--- a/internal/handler/routes.go
+++ b/internal/handler/routes.go
@@ -565,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/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/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"`
From 7bde0d8cb3237a7791b61a52a5e00fdba07470e9 Mon Sep 17 00:00:00 2001
From: l429609201 <429609201@qq.com>
Date: Sun, 30 Aug 2026 23:14:19 +0800
Subject: [PATCH 09/12] =?UTF-8?q?```=20feat(scheduler):=20=E6=B7=BB?=
=?UTF-8?q?=E5=8A=A0=E5=A4=87=E4=BB=BD=E6=9C=80=E5=A4=A7=E4=BF=9D=E7=95=99?=
=?UTF-8?q?=E6=95=B0=E9=99=90=E5=88=B6=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 在规则编辑器中添加 maxBackups 字段,用于控制备份文件的最大保留数量
- 实现按主机分别计数的备份清理机制,超出限制时自动删除最旧的备份文件
- 添加 CleanupBackupsForHost 工具函数,支持按修改时间排序并清理旧备份
- 在前端界面提供最大保留数量输入框和相关说明提示
- 备份完成后根据规则配置自动执行旧备份清理,并在摘要中显示清理结果
```
---
frontend-react/src/components/RuleEditor.jsx | 21 +++-
internal/logic/schedule/schedulelogic.go | 1 +
internal/module/appconfig/types.go | 4 +
internal/module/scheduler/runner_tasks.go | 12 +-
internal/types/scheduledTypes.go | 1 +
internal/utiles/cleanupoldbackups.go | 115 +++++++++++++++++++
6 files changed, 149 insertions(+), 5 deletions(-)
create mode 100644 internal/utiles/cleanupoldbackups.go
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/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/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/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
+}
From 82eac69f28bbd71f62ee8dc65481ba316a2bde6a Mon Sep 17 00:00:00 2001
From: l429609201 <429609201@qq.com>
Date: Mon, 31 Aug 2026 12:43:51 +0800
Subject: [PATCH 10/12] =?UTF-8?q?```=20feat(scheduler):=20=E6=B7=BB?=
=?UTF-8?q?=E5=8A=A0=E5=AE=9A=E6=97=B6=E6=9B=B4=E6=96=B0=E8=A7=84=E5=88=99?=
=?UTF-8?q?=E6=B1=87=E6=80=BB=E4=BB=BB=E5=8A=A1=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 在任务中心创建规则维度的汇总任务,展示整轮更新的容器/镜像及结果
- 支持显示本轮更新、跳过、失败的容器明细信息
- 为同一规则的多次执行复用固定taskID,正确处理开始时间避免排序错乱
- 实现buildUpdateDetail函数,将更新结果按类别格式化为多行文本展示
- 最多显示每类30条明细,超出部分用省略号提示避免性能问题
- 当失败数量大于0且无成功更新时标记任务为失败状态
```
---
internal/module/scheduler/runner.go | 65 +++++++++++++++++++++++++++++
1 file changed, 65 insertions(+)
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 的条件保持一致:仅本地主机 + 命中自身。
// 保持其余容器的原有相对顺序,避免影响既有更新次序。
From 2ed17aee1e1c1140b8193f80790fe9ac997aeac5 Mon Sep 17 00:00:00 2001
From: l429609201 <429609201@qq.com>
Date: Mon, 31 Aug 2026 15:00:33 +0800
Subject: [PATCH 11/12] =?UTF-8?q?```=20feat(container-logs):=20=E9=BB=98?=
=?UTF-8?q?=E8=AE=A4=E5=BC=80=E5=90=AF=E5=AE=9E=E6=97=B6=E8=B7=9F=E9=9A=8F?=
=?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=B9=B6=E4=BC=98=E5=8C=96=E8=BF=9E=E6=8E=A5?=
=?UTF-8?q?=E7=AE=A1=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
默认开启日志实时跟随模式,提升用户体验,打开日志面板即可持续接收新日志。
优化 EventSource 连接错误处理机制:
- 区分业务错误和连接层错误,避免误关闭重连中的连接
- 连接建立时清除错误状态,确保重连后恢复正常
- 仅在浏览器放弃重连时才进行收尾处理
后端添加并发安全保护和心跳机制:
- 使用互斥锁防止日志推送和心跳并发写入导致的数据竞争
- follow 模式下每15秒发送ping消息保活长连接
- 添加详细日志记录日志流的开始和结束状态
同时修复了搜索功能的防抖逻辑,确保关键词正确应用到后端grep操作。
```
---
.../src/components/ContainerOps.jsx | 37 +++++++++++++++----
internal/handler/ops/logsstreamhandler.go | 32 ++++++++++++++++
2 files changed, 62 insertions(+), 7 deletions(-)
diff --git a/frontend-react/src/components/ContainerOps.jsx b/frontend-react/src/components/ContainerOps.jsx
index 73ef48cb..ed3da271 100644
--- a/frontend-react/src/components/ContainerOps.jsx
+++ b/frontend-react/src/components/ContainerOps.jsx
@@ -277,7 +277,7 @@ function LogsPanel({ id, name, hostId }) {
const [errMsg, setErrMsg] = useState('') // 流错误提示
const [search, setSearch] = useState('') // 搜索关键词(提交后交后端 grep)
const [appliedSearch, setAppliedSearch] = useState('') // 已应用到后端的关键词(防抖后)
- const [follow, setFollow] = useState(false) // 实时跟随(-f)
+ const [follow, setFollow] = useState(true) // 实时跟随(-f),默认开启:打开即持续接收新日志
const [pretty, setPretty] = useState(true) // 是否结构化展示(仅对 JSON 日志生效)
const [autoScroll, setAutoScroll] = useState(true) // 自动滚动到最新一行,默认开启
const [reloadKey, setReloadKey] = useState(0) // 手动刷新触发重连
@@ -311,25 +311,48 @@ function LogsPanel({ id, name, hostId }) {
}
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) => {
- // 后端主动下发的错误事件带 data;EventSource 网络错误则 data 为空
+ 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)
- setStreaming(false)
- if (ev.data) setErrMsg(ev.data)
- else if (!follow) setErrMsg('日志流连接中断')
- es.close(); esRef.current = null
- })
+ 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])
diff --git a/internal/handler/ops/logsstreamhandler.go b/internal/handler/ops/logsstreamhandler.go
index fe85452e..946001e6 100644
--- a/internal/handler/ops/logsstreamhandler.go
+++ b/internal/handler/ops/logsstreamhandler.go
@@ -4,6 +4,8 @@ import (
"fmt"
"net/http"
"strings"
+ "sync"
+ "time"
"github.com/l429609201/dockerCopilot/internal/module/containerops"
"github.com/l429609201/dockerCopilot/internal/svc"
@@ -38,9 +40,14 @@ func LogsStreamHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
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)
}
@@ -51,6 +58,27 @@ func LogsStreamHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
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,
@@ -60,7 +88,9 @@ func LogsStreamHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
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():
@@ -68,8 +98,10 @@ func LogsStreamHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
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" 事件告知前端
From fc2f0ae8dbf49ec6e4b3986d3fbc4f1867b7dbf0 Mon Sep 17 00:00:00 2001
From: l429609201 <429609201@qq.com>
Date: Mon, 31 Aug 2026 19:56:16 +0800
Subject: [PATCH 12/12] =?UTF-8?q?```=20feat(bot):=20=E7=BB=9F=E4=B8=80?=
=?UTF-8?q?=E4=BD=BF=E7=94=A8AppConfig=E4=BC=98=E5=85=88=E7=9A=84Compose?=
=?UTF-8?q?=E6=89=AB=E6=8F=8F=E9=85=8D=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 修改handleCallback中的cmpaconf处理逻辑,使用composeScanConfig方法
- 新增composeScanConfig方法,优先读取动态配置(AppConfig),回退到静态yaml配置
- 更新listComposeProjects、showComposeProjectPanel、executeComposeAction方法
使用统一的composeScanConfig配置读取逻辑
- 避免Bot端只读静态yaml导致与Web端配置不一致的问题
```
---
internal/module/bot/handler.go | 38 +++++++++++++++++++++++-----------
1 file changed, 26 insertions(+), 12 deletions(-)
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()