feat(collectors): 主机采集器容器优先托管 + 采集器日志查看 + 推送地址纠正

现象
1. 主机采集器只有 systemd 一条路,跨发行版差异大(这轮已踩过 param 被删、日志路径、
   时间戳格式三处);进程存活/开机自启也没有统一保证。
2. 「采集器日志」在主机模式下无法从平台查看,排「起没起来/在报什么错」必须 SSH。
3. 实测发现:Loki 部署把 listenIP 填成 0.0.0.0 时,部署记录 endpoint 是
   http://0.0.0.0:3100,采集器照抄去推 -> connect: connection refused,
   而容器/systemd 都是 running、平台 status=active —— 日志静默丢失。

根因
1. 托管方式写死在代码里(promtail 二进制 + systemd 单元),没有能力探测。
2. 没有对外暴露采集器自身日志的接口。
3. 渲染 promtail 配置时直接用 deployment.Endpoint,没有校验它是不是「可连接的推送地址」;
   另外容器用 bridge 网络,127.0.0.1 在容器里指向容器自身,同机 Loki 也会推失败。

修法
- services/collector_host_runtime.go(新增)
  * detectHostCapabilities:一次 SSH 往返探测 架构/init/systemd/docker可用/开机自启/SELinux/journald
  * chooseHostRunMode:docker 优先 -> systemd 兜底 -> 两者都没有则明确报错(不裸跑进程)
  * installHostDocker:grafana/promtail:3.4.0 容器,--restart=always --memory=256m --cpus=1
    --log-opt max-size=10m,max-file=3,挂 /etc/promtail(:ro) /var/lib/promtail /var/log(:ro)
    (+journald 相关),SELinux Enforcing 时 --security-opt label=disable;
    先 docker run --rm -check-syntax 校验配置,再替换正式配置
  * waitContainerRunning:看 Status + RestartCount,起来就崩立即报错并带回 docker logs
  * hostPushEndpoint:0.0.0.0/:: 换真实主机 IP;容器模式下 127.0.0.1/localhost 也换;
    换不了明确报错
  * CollectorLogs:主机 docker logs / journalctl,K8s DaemonSet Pod 日志、Sidecar fluent-bit 容器日志
- collector_deployer.go:installHost 探测->选模式->渲染配置->分发;systemd 路径从 docker 迁回时
  清理同名容器;卸载时容器与 systemd 两种方式都清(RunMode 对老记录是空的)
- 拉不到镜像(内网无外网)时降级 systemd 二进制托管,其它错误原样抛出
- models/collector.go:新增 runMode 并纳入 Sanitized;services.Update 白名单同步
- 接口 GET /api/v1/collectors/:id/logs?tail=N;前端卡片加「运行方式」+「日志」弹窗
- build.sh:detect_go 的 INFO 走 stderr(原来被打进 GO_CMD,构建必失败)

验证(172.19.16.8 / pre-cex-ops-jenkins,collector id=2)
- 安装后 runMode=docker,容器 Up,RestartPolicy=always,systemd promtail 自动停用
- 推送地址自动纠正为 http://172.19.16.8:3100,Loki 近 10 分钟查到 4919 条
  job=system/job=app、host=pre-cex-ops-jenkins
- docker 垫片模拟「docker 不可用」-> 自动回退 runMode=systemd、unit active+enabled、
  日志接口 source=systemd;移除垫片重装 -> 切回 docker
- 卸载:容器 0 个、unit 文件不存在、/etc/promtail 不存在、runMode 清空;重装恢复 running/0 重启
- K8s 采集器日志接口 source=kubernetes(顺带暴露 429 限流,见后续日志量治理)
- go vet 干净;go test ./... 全绿(新增 chooseHostRunMode / hostPushEndpoint 等 6 组用例);
  tsc -b 通过
- 镜像 lghost/lokistack:latest = :v0.1 = sha256:906a6d5369d7,Pod imageID 一致
This commit is contained in:
hermes-agent committed 2026-10-28 20:01:38 +00:00
1 parent c983d0e3c1
commit 7e3356e32b
16 files changed
+1095 -69

No files matched your search

+13
View File
@@ -11,6 +11,15 @@ export interface CollectorPreview {
syslogFormat?: string;
}
export interface CollectorLogs {
/** 日志来源:docker | systemd | kubernetes */
source: string;
/** 展示用的来源描述(容器名 / systemd 单元 / Pod) */
sourceLabel: string;
/** 日志正文(可能为空 = 采集器尚未产生日志) */
content: string;
}
export const collectorApi = {
list: (params: ListParams = {}, options?: RequestOptions) =>
http.get<PageData<Collector>>(`/collectors${buildQuery(params)}`, options),
@@ -42,4 +51,8 @@ export const collectorApi = {
install: (id: number) => http.post<{ message: string }>(`/collectors/${id}/install`),
uninstall: (id: number) => http.post<{ message: string }>(`/collectors/${id}/uninstall`),
/** 读取采集器最近运行日志(主机:docker logs / journalctl;K8s:Pod 日志) */
logs: (id: number, tail = 200) =>
http.get<CollectorLogs>(`/collectors/${id}/logs?tail=${tail}`),
};
@@ -25,14 +25,32 @@ const collectors = {
namespace: 'Namespace',
logPath: 'Log Path',
target: 'Target',
runMode: 'Run Mode',
unknown: 'Unknown',
},
runMode: {
docker: 'Docker container',
systemd: 'systemd service',
},
action: {
install: 'Deploy',
uninstall: 'Uninstall',
logs: 'Logs',
editAria: 'Edit collection policy {{name}}',
deleteAria: 'Delete collector {{name}}',
},
logs: {
title: 'Collector logs · {{name}}',
description: 'Recent collector logs (host: docker logs / journalctl; K8s: collector Pod logs)',
source: 'Source: {{source}}',
tailLines: '{{count}} lines',
refresh: 'Refresh',
empty: 'No log output yet (the collector may have just started, or nothing matched so far)',
copy: 'Copy',
copied: 'Copied',
close: 'Close',
failed: 'Failed to read collector logs',
},
toast: {
loadOptionsFailed: 'Failed to load Loki deployments, clusters, or hosts',
installFailed: 'Failed to deploy collector',
@@ -26,14 +26,33 @@ const collectors = {
namespace: '命名空间',
logPath: '日志路径',
target: '目标',
runMode: '运行方式',
unknown: '未知',
},
// 主机采集器托管方式(安装时自动探测:docker 优先、systemd 兜底)
runMode: {
docker: 'Docker 容器',
systemd: 'systemd 服务',
},
action: {
install: '部署',
uninstall: '卸载',
logs: '日志',
editAria: '编辑采集策略 {{name}}',
deleteAria: '删除采集器 {{name}}',
},
logs: {
title: '采集器日志 · {{name}}',
description: '采集器最近运行日志(主机:docker logs / journalctl;K8s:采集 Pod 日志)',
source: '来源:{{source}}',
tailLines: '{{count}} 行',
refresh: '刷新',
empty: '暂无日志输出(采集器可能刚启动,或暂时没有匹配的日志行)',
copy: '复制',
copied: '已复制',
close: '关闭',
failed: '读取采集器日志失败',
},
toast: {
loadOptionsFailed: '加载 Loki 部署/集群/主机数据失败',
installFailed: '部署采集器失败',
@@ -5,6 +5,7 @@ import '@/i18n/locales/zh/collectors'
import '@/i18n/locales/en/collectors'
import {
RadioTower,
ScrollText,
Trash2,
Upload,
Download,
@@ -36,16 +37,23 @@ interface CollectorCardProps {
onUninstall: (id: number) => void
onDelete: (c: Collector) => void
onEdit: (c: Collector) => void
onLogs: (c: Collector) => void
}
/** 采集策略卡片 — 统一 ResourceCard 样式 */
export const CollectorCard = memo(function CollectorCard({
collector, operating, onInstall, onUninstall, onDelete, onEdit,
collector, operating, onInstall, onUninstall, onDelete, onEdit, onLogs,
}: CollectorCardProps) {
const { t } = useTranslation('collectors')
const meta = COLLECTOR_TYPE_META[collector.type] ?? FALLBACK_META
const Icon = meta.icon
const isBusy = operating === collector.id
// 运行方式只对主机采集器有意义(容器 vs systemd 单元);
// K8s 类型的运行形态已由类型徽章(DaemonSet / Sidecar)表达,不重复占一行。
const runModeLabel =
collector.type === 'host-promtail' && collector.runMode
? t(`runMode.${collector.runMode}`)
: ''
return (
<ResourceCard
icon={Icon}
@@ -55,6 +63,7 @@ export const CollectorCard = memo(function CollectorCard({
badges={<StatusBadge status={collector.status} />}
fields={[
{ label: t('field.loki'), value: collector.lokiDeployment?.name || t('field.unknown') },
...(runModeLabel ? [{ label: t('field.runMode'), value: runModeLabel }] : []),
...(collector.cluster ? [{ label: t('field.cluster'), value: collector.cluster.name }] : []),
...(collector.host ? [{ label: t('field.host'), value: `${collector.host.name}(${collector.host.hostIP})` }] : []),
...(collector.namespace ? [{ label: t('field.namespace'), value: collector.namespace }] : []),
@@ -67,6 +76,7 @@ export const CollectorCard = memo(function CollectorCard({
<CardActionButton icon={Pencil} ariaLabel={t('action.editAria', { name: collector.name })} onClick={() => onEdit(collector)} />
<CardActionButton icon={Upload} label={isBusy ? t('common:dialog.processing') : t('action.install')} disabled={isBusy} onClick={() => onInstall(collector.id)} />
<CardActionButton icon={Download} label={t('action.uninstall')} disabled={isBusy} onClick={() => onUninstall(collector.id)} />
<CardActionButton icon={ScrollText} label={t('action.logs')} onClick={() => onLogs(collector)} />
<CardActionButton icon={Trash2} ariaLabel={t('action.deleteAria', { name: collector.name })} destructive onClick={() => onDelete(collector)} />
</>
}
@@ -0,0 +1,141 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
// collectors 域词典(zh/en 自注册模块,副作用导入须先于 useTranslation 使用)
import '@/i18n/locales/zh/collectors'
import '@/i18n/locales/en/collectors'
import { RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { collectorApi, type CollectorLogs } from '@/api'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { toast } from 'sonner'
import type { Collector } from '@/types'
interface CollectorLogsDialogProps {
collector: Collector | null
onOpenChange: (open: boolean) => void
}
/** 可选的日志行数(默认 200:够看启动报错,又不至于把弹窗卡住) */
const TAIL_OPTIONS = [200, 500, 1000]
/**
* 采集器运行日志弹窗 —— 主机采集器在 docker 模式下是 `docker logs`,
* systemd 模式下是 `journalctl -u promtail`;K8s 采集器是采集 Pod 的容器日志。
* 排「采集器起没起来、在报什么错」不用再登机器。
*/
export const CollectorLogsDialog = memo(function CollectorLogsDialog({
collector,
onOpenChange,
}: CollectorLogsDialogProps) {
const { t } = useTranslation('collectors')
const [logs, setLogs] = useState<CollectorLogs | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [tail, setTail] = useState(TAIL_OPTIONS[0])
const load = useCallback(
async (id: number, lines: number) => {
setLoading(true)
setError('')
try {
setLogs(await collectorApi.logs(id, lines))
} catch (err) {
// 后端会指名原因(未部署 / 没有容器也没有 systemd 单元 / 缺 pods/log 权限),直接展示
setError(err instanceof Error ? err.message : t('logs.failed'))
setLogs(null)
} finally {
setLoading(false)
}
},
[t]
)
useEffect(() => {
if (!collector) {
setLogs(null)
setError('')
return
}
setTail(TAIL_OPTIONS[0])
void load(collector.id, TAIL_OPTIONS[0])
}, [collector, load])
const copying = Boolean(logs?.content)
return (
<Dialog open={collector !== null} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('logs.title', { name: collector?.name ?? '' })}</DialogTitle>
<DialogDescription>
{logs?.sourceLabel ? t('logs.source', { source: logs.sourceLabel }) : t('logs.description')}
</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap items-center gap-2">
{TAIL_OPTIONS.map((n) => (
<Button
key={n}
type="button"
size="sm"
variant={tail === n ? 'default' : 'outline'}
className="h-7 text-xs"
disabled={loading || !collector}
onClick={() => {
setTail(n)
if (collector) void load(collector.id, n)
}}
>
{t('logs.tailLines', { count: n })}
</Button>
))}
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs ml-auto"
disabled={loading || !collector}
onClick={() => {
if (collector) void load(collector.id, tail)
}}
>
<RefreshCw className="w-3.5 h-3.5 mr-1" />
{t('logs.refresh')}
</Button>
</div>
{loading ? (
<LoadingSpinner className="h-40" />
) : error ? (
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-xs text-destructive whitespace-pre-wrap">
{error}
</div>
) : (
<pre className="max-h-[55vh] overflow-auto rounded-md bg-muted p-3 text-[11px] leading-relaxed whitespace-pre-wrap break-all">
{logs?.content || t('logs.empty')}
</pre>
)}
<div className="flex items-center justify-between">
<Button
type="button"
size="sm"
variant="outline"
disabled={!copying}
onClick={() => {
if (!logs?.content) return
void navigator.clipboard?.writeText(logs.content)
toast.success(t('logs.copied'))
}}
>
{t('logs.copy')}
</Button>
<Button type="button" size="sm" onClick={() => onOpenChange(false)}>
{t('logs.close')}
</Button>
</div>
</DialogContent>
</Dialog>
)
})
+6
View File
@@ -18,6 +18,7 @@ import type { Cluster, Collector, Host, LokiDeployment } from '@/types'
import { CollectorCard } from './components/CollectorCard'
import { CollectorFormDialog } from './components/CollectorFormDialog'
import { CollectorEditDialog } from './components/CollectorEditDialog'
import { CollectorLogsDialog } from './components/CollectorLogsDialog'
/**
* 日志采集页 — 采集策略编排:列表走 useListQuery(取消+版本号),
@@ -38,6 +39,8 @@ const CollectorsPage = memo(function CollectorsPage() {
const [formOpen, setFormOpen] = useState(false)
const [editTarget, setEditTarget] = useState<Collector | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Collector | null>(null)
/** 日志弹窗目标(null = 关闭) */
const [logsTarget, setLogsTarget] = useState<Collector | null>(null)
const [deleting, setDeleting] = useState(false)
/** 部署/卸载指令进行中的采集器 ID(按钮 loading + 防重复点击) */
const [operating, setOperating] = useState<number | null>(null)
@@ -146,6 +149,7 @@ const CollectorsPage = memo(function CollectorsPage() {
onUninstall={(id) => handleAction(id, 'uninstall')}
onDelete={setDeleteTarget}
onEdit={setEditTarget}
onLogs={setLogsTarget}
/>
))}
</div>
@@ -169,6 +173,8 @@ const CollectorsPage = memo(function CollectorsPage() {
<CollectorEditDialog collector={editTarget} onOpenChange={(open) => { if (!open) setEditTarget(null) }} onSaved={query.refresh} />
<CollectorLogsDialog collector={logsTarget} onOpenChange={(open) => { if (!open) setLogsTarget(null) }} />
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
+2
View File
@@ -225,6 +225,8 @@ export interface Collector {
resolvedHostname?: string;
/** 上次安装探测到的 syslog 时间戳格式:iso8601 | rfc3164 */
syslogFormat?: string;
/** 主机采集器的托管方式(安装时自动探测决定):docker | systemd */
runMode?: 'docker' | 'systemd' | '';
status: string;
statusMessage: string;
createdAt: string;