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:
1 parent
c983d0e3c1
commit
7e3356e32b
16 files changed
+1095
-69
No files matched your search
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user