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:
Logan.LiandClaude committed 2026-06-29 19:26:41 +08:00
1 parent 2a93de600d
commit 673b2c60c8
17 files changed
+1442 -4

No files matched your search

+9
View File
@@ -6,6 +6,7 @@ import Clusters from '@/pages/Clusters'
import Hosts from '@/pages/Hosts'
import Storage from '@/pages/Storage'
import Deployments from '@/pages/Deployments'
import Collectors from '@/pages/Collectors'
import Settings from '@/pages/Settings'
function RequireAuth({ children }: { children: React.ReactNode }) {
@@ -60,6 +61,14 @@ export default function App() {
</RequireAuth>
}
/>
<Route
path="/collectors"
element={
<RequireAuth>
<Collectors />
</RequireAuth>
}
/>
<Route
path="/settings"
element={
+3 -1
View File
@@ -5,6 +5,7 @@ import {
Monitor,
HardDrive,
Rocket,
RadioTower,
Settings,
LogOut,
PanelLeftClose,
@@ -21,7 +22,8 @@ const menuItems = [
{ path: '/clusters', icon: Server, label: '集群管理' },
{ path: '/hosts', icon: Monitor, label: '主机管理' },
{ path: '/storage', icon: HardDrive, label: '存储配置' },
{ path: '/deployments', icon: Rocket, label: '部署管理' },
{ path: '/deployments', icon: Rocket, label: '部署Loki' },
{ path: '/collectors', icon: RadioTower, label: '部署客户端' },
{ path: '/settings', icon: Settings, label: '系统设置' },
]
+362
View File
@@ -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>
)
}
+14 -1
View File
@@ -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"
+20
View File
@@ -0,0 +1,20 @@
import { request } from './client';
import type { PageData, Collector, CreateCollectorRequest } from '@/types';
export const collectorApi = {
list: (page = 1, pageSize = 100) =>
request<PageData<Collector>>(`/collectors?page=${page}&pageSize=${pageSize}`),
get: (id: number) => request<Collector>(`/collectors/${id}`),
create: (data: CreateCollectorRequest) =>
request<Collector>('/collectors', { method: 'POST', body: JSON.stringify(data) }),
remove: (id: number) => request<{ message: string }>(`/collectors/${id}`, { method: 'DELETE' }),
install: (id: number) =>
request<{ message: string }>(`/collectors/${id}/install`, { method: 'POST' }),
uninstall: (id: number) =>
request<{ message: string }>(`/collectors/${id}/uninstall`, { method: 'POST' }),
};
+1
View File
@@ -4,3 +4,4 @@ export { clusterApi } from './cluster';
export { hostApi } from './host';
export { storageApi } from './storage';
export { deploymentApi } from './deployment';
export { collectorApi } from './collector';
+34
View File
@@ -166,6 +166,40 @@ export interface PageData<T> {
pageSize: number;
}
// Collector(日志采集器 / 部署客户端)
export interface Collector {
id: number;
name: string;
type: 'host-promtail' | 'k8s-daemonset' | 'k8s-fluentbit-sidecar';
lokiDeploymentID: number;
lokiDeployment?: { id: number; name: string; endpoint: string; status: string };
refType: string;
hostID?: number;
host?: Host;
clusterID?: number;
cluster?: Cluster;
targetDeployment: string;
namespace: string;
logPath: string;
labels: string;
status: string;
statusMessage: string;
createdAt: string;
updatedAt: string;
}
export interface CreateCollectorRequest {
name: string;
type: string;
lokiDeploymentID: number;
hostID?: number;
clusterID?: number;
targetDeployment?: string;
namespace?: string;
logPath?: string;
labels?: string;
}
// Dashboard Stats
export interface DashboardStats {
totalDeployments: number;