feat: 新增「部署客户端」日志采集器模块
三种采集器(host-promtail / k8s-daemonset / k8s-fluentbit-sidecar),每种关联已部署 Loki: - host-promtail:SSH 安装 Promtail(复用 deployBinary) - k8s-daemonset:helm 安装 Promtail DaemonSet(复用 deployHelm) - k8s-fluentbit-sidecar:kubectl patch 注入 Fluent Bit sidecar 含 Collector 数据模型、三个部署器、配置模板、CRUD API、前端页面。 附带 UI 优化:ErrorBoundary、部署卡片浏览器访问按钮、菜单名称调整。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
2a93de600d
commit
673b2c60c8
17 files changed
+1442
-4
No files matched your search
@@ -0,0 +1,362 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
RadioTower,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Upload,
|
||||
Download,
|
||||
Server,
|
||||
Monitor,
|
||||
Cloud,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { collectorApi, deploymentApi, clusterApi, hostApi } from '@/srv/api'
|
||||
import { statusLabel } from '@/lib/utils'
|
||||
import type { Collector, LokiDeployment, Cluster, Host } from '@/types'
|
||||
|
||||
const typeMeta: Record<string, { label: string; icon: typeof Server; color: string }> = {
|
||||
'host-promtail': { label: '主机 Promtail', icon: Monitor, color: 'bg-purple-100 text-purple-700' },
|
||||
'k8s-daemonset': { label: 'K8s DaemonSet', icon: Cloud, color: 'bg-blue-100 text-blue-700' },
|
||||
'k8s-fluentbit-sidecar': { label: 'K8s Sidecar', icon: Server, color: 'bg-cyan-100 text-cyan-700' },
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-emerald-100 text-emerald-700'
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-700'
|
||||
case 'pending':
|
||||
return 'bg-amber-100 text-amber-700'
|
||||
default:
|
||||
return 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
}
|
||||
|
||||
export default function Collectors() {
|
||||
const [collectors, setCollectors] = useState<Collector[]>([])
|
||||
const [deployments, setDeployments] = useState<LokiDeployment[]>([])
|
||||
const [clusters, setClusters] = useState<Cluster[]>([])
|
||||
const [hosts, setHosts] = useState<Host[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [busy, setBusy] = useState<number | null>(null)
|
||||
|
||||
// Form
|
||||
const [name, setName] = useState('')
|
||||
const [type, setType] = useState<'host-promtail' | 'k8s-daemonset' | 'k8s-fluentbit-sidecar'>('host-promtail')
|
||||
const [lokiID, setLokiID] = useState('')
|
||||
const [hostID, setHostID] = useState('')
|
||||
const [clusterID, setClusterID] = useState('')
|
||||
const [targetDeployment, setTargetDeployment] = useState('')
|
||||
const [namespace, setNamespace] = useState('')
|
||||
const [logPath, setLogPath] = useState('')
|
||||
const [labels, setLabels] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadAll()
|
||||
}, [])
|
||||
|
||||
const loadAll = async () => {
|
||||
try {
|
||||
const [cRes, dRes, clRes, hRes] = await Promise.all([
|
||||
collectorApi.list(1, 100),
|
||||
deploymentApi.list(1, 100),
|
||||
clusterApi.list(1, 100),
|
||||
hostApi.list(1, 100),
|
||||
])
|
||||
setCollectors(cRes.list)
|
||||
setDeployments(dRes.list)
|
||||
setClusters(clRes.list)
|
||||
setHosts(hRes.list)
|
||||
} catch (err) {
|
||||
console.error('Failed to load collectors:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setName('')
|
||||
setType('host-promtail')
|
||||
setLokiID('')
|
||||
setHostID('')
|
||||
setClusterID('')
|
||||
setTargetDeployment('')
|
||||
setNamespace('')
|
||||
setLogPath('')
|
||||
setLabels('')
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!lokiID) {
|
||||
alert('请选择关联的 Loki 部署')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await collectorApi.create({
|
||||
name,
|
||||
type,
|
||||
lokiDeploymentID: Number(lokiID),
|
||||
hostID: type === 'host-promtail' && hostID ? Number(hostID) : undefined,
|
||||
clusterID: type !== 'host-promtail' && clusterID ? Number(clusterID) : undefined,
|
||||
targetDeployment: type === 'k8s-fluentbit-sidecar' ? targetDeployment : undefined,
|
||||
namespace: type !== 'host-promtail' ? namespace : undefined,
|
||||
logPath,
|
||||
labels,
|
||||
})
|
||||
setOpen(false)
|
||||
resetForm()
|
||||
loadAll()
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '创建采集器失败'
|
||||
alert(message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定删除此采集器?(将自动卸载)')) return
|
||||
try {
|
||||
await collectorApi.remove(id)
|
||||
loadAll()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete collector:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAction = async (id: number, action: 'install' | 'uninstall') => {
|
||||
setBusy(id)
|
||||
try {
|
||||
await collectorApi[action](id)
|
||||
setTimeout(loadAll, 3000)
|
||||
} catch (err) {
|
||||
console.error('Collector action failed:', err)
|
||||
} finally {
|
||||
setTimeout(() => setBusy(null), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">部署客户端</h2>
|
||||
<p className="text-sm text-slate-500">部署日志采集器,将日志推送到关联的 Loki</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="bg-emerald-600 hover:bg-emerald-700">
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
新建采集器
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建日志采集器</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="c-name">名称 *</Label>
|
||||
<Input id="c-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="app-logs-collector" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>采集器类型</Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as 'host-promtail' | 'k8s-daemonset' | 'k8s-fluentbit-sidecar')}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="host-promtail">主机 Promtail(SSH)</SelectItem>
|
||||
<SelectItem value="k8s-daemonset">K8s Promtail DaemonSet</SelectItem>
|
||||
<SelectItem value="k8s-fluentbit-sidecar">K8s Fluent Bit Sidecar</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>关联 Loki 部署 *</Label>
|
||||
<Select value={lokiID} onValueChange={setLokiID}>
|
||||
<SelectTrigger><SelectValue placeholder="选择已部署的 Loki(推送目标)" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{deployments.map((d) => (
|
||||
<SelectItem key={d.id} value={String(d.id)}>{d.name}({statusLabel(d.status)})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{type === 'host-promtail' && (
|
||||
<div className="space-y-2">
|
||||
<Label>目标主机 *</Label>
|
||||
<Select value={hostID} onValueChange={setHostID}>
|
||||
<SelectTrigger><SelectValue placeholder="选择主机" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{hosts.map((h) => (
|
||||
<SelectItem key={h.id} value={String(h.id)}>{h.name}({h.hostIP})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type !== 'host-promtail' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>目标集群 *</Label>
|
||||
<Select value={clusterID} onValueChange={setClusterID}>
|
||||
<SelectTrigger><SelectValue placeholder="选择集群" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{clusters.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>{c.name}({statusLabel(c.status)})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="c-ns">命名空间</Label>
|
||||
<Input id="c-ns" value={namespace} onChange={(e) => setNamespace(e.target.value)} placeholder={type === 'k8s-daemonset' ? 'logging' : 'default'} />
|
||||
</div>
|
||||
{type === 'k8s-fluentbit-sidecar' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="c-deploy">目标 Deployment *</Label>
|
||||
<Input id="c-deploy" value={targetDeployment} onChange={(e) => setTargetDeployment(e.target.value)} placeholder="my-app" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="c-log">采集日志路径</Label>
|
||||
<Input id="c-log" value={logPath} onChange={(e) => setLogPath(e.target.value)} placeholder={type === 'host-promtail' ? '/var/log/*.log' : '/var/log/app/*.log'} />
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full bg-emerald-600 hover:bg-emerald-700" disabled={submitting}>
|
||||
{submitting ? '创建中...' : '创建并部署'}
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
) : collectors.length === 0 ? (
|
||||
<Card className="border-dashed border-slate-300">
|
||||
<CardContent className="p-12 text-center">
|
||||
<RadioTower className="w-12 h-12 text-slate-300 mx-auto mb-4" />
|
||||
<p className="text-slate-500 font-medium">暂无采集器</p>
|
||||
<p className="text-sm text-slate-400 mt-1">新建一个日志采集器,关联已部署的 Loki</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{collectors.map((c) => {
|
||||
const meta = typeMeta[c.type] || { label: c.type, icon: RadioTower, color: 'bg-slate-100 text-slate-600' }
|
||||
const Icon = meta.icon
|
||||
return (
|
||||
<Card key={c.id} className="border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-5 h-5 text-emerald-600" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-800 dark:text-white">{c.name}</h3>
|
||||
<p className="text-xs text-slate-500">关联 Loki:{c.lokiDeployment?.name || '未知'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(c.status)}`}>
|
||||
{statusLabel(c.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-xs text-slate-500 mb-3">
|
||||
<div className="flex justify-between">
|
||||
<span>类型</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${meta.color}`}>{meta.label}</span>
|
||||
</div>
|
||||
{c.host && (
|
||||
<div className="flex justify-between">
|
||||
<span>主机</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{c.host.name}({c.host.hostIP})</span>
|
||||
</div>
|
||||
)}
|
||||
{c.cluster && (
|
||||
<div className="flex justify-between">
|
||||
<span>集群</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{c.cluster.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{c.targetDeployment && (
|
||||
<div className="flex justify-between">
|
||||
<span>目标 Deployment</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{c.targetDeployment}</span>
|
||||
</div>
|
||||
)}
|
||||
{c.namespace && (
|
||||
<div className="flex justify-between">
|
||||
<span>命名空间</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">{c.namespace}</span>
|
||||
</div>
|
||||
)}
|
||||
{c.logPath && (
|
||||
<div className="flex justify-between">
|
||||
<span>日志路径</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300 truncate max-w-[200px]">{c.logPath}</span>
|
||||
</div>
|
||||
)}
|
||||
{c.statusMessage && (
|
||||
<div className="p-2 bg-slate-50 dark:bg-slate-800 rounded text-xs mt-1">{c.statusMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
|
||||
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => handleAction(c.id, 'install')} disabled={busy === c.id}>
|
||||
<Upload className="w-3 h-3 mr-1" />
|
||||
{busy === c.id ? '处理中...' : '部署'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => handleAction(c.id, 'uninstall')} disabled={busy === c.id}>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
卸载
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="text-red-500 hover:text-red-600 hover:bg-red-50" onClick={() => handleDelete(c.id)}>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Clock,
|
||||
PauseCircle,
|
||||
Activity,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -240,7 +241,7 @@ export default function Deployments() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">部署管理</h2>
|
||||
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">部署Loki</h2>
|
||||
<p className="text-sm text-slate-500">部署与管理 Grafana Loki 日志聚合服务</p>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
@@ -506,6 +507,18 @@ export default function Deployments() {
|
||||
<Terminal className="w-3 h-3 mr-1" />
|
||||
日志
|
||||
</Button>
|
||||
{d.endpoint && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs text-emerald-600 hover:text-emerald-700"
|
||||
onClick={() => window.open(d.endpoint, '_blank')}
|
||||
title="浏览器访问"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
访问
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
Reference in new issue
Block a user