feat: complete Loki query, insights, settings and deployment workflows

Add the Loki query UI, access policies, ingress insights, alert and registry settings, and their backend APIs. Include the related Kubernetes deployment and collector updates required by the integrated application.

Validated with go test ./..., npm run build, and npm run lint.
This commit is contained in:
logan committed 2026-10-25 21:28:32 +08:00
1 parent f9c6c1d826
commit 2d73661dd1
127 files changed
+8117 -2876

No files matched your search

@@ -4,25 +4,25 @@ import {
Trash2,
Upload,
Download,
Pencil,
Server,
Monitor,
Cloud,
type LucideIcon,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { StatusBadge } from '@/components/StatusBadge'
import { InfoRow } from '@/components/InfoRow'
import { ResourceCard } from '@/components/ResourceCard'
import type { Collector } from '@/types'
// 采集器类型元数据(图标 + 类型徽章配色)
const COLLECTOR_TYPE_META: Record<string, { label: string; icon: LucideIcon; color: string }> = {
'host-promtail': { label: '主机 Promtail', icon: Monitor, color: 'bg-purple-100 text-purple-700' },
'k8s-daemonset': { label: 'K8s DaemonSet', icon: Cloud, color: 'bg-info/10 text-info' },
'k8s-fluentbit-sidecar': { label: 'K8s Sidecar', icon: Server, color: 'bg-cyan-100 text-cyan-700' },
'host-promtail': { label: '主机 Promtail', icon: Monitor, color: 'text-info' },
'k8s-daemonset': { label: 'Promtail DaemonSet', icon: Cloud, color: 'text-info' },
'k8s-fluentbit-sidecar': { label: 'Fluent Bit Sidecar', icon: Server, color: 'text-info' },
}
const FALLBACK_META = { label: '', icon: RadioTower, color: 'bg-muted text-slate-600' }
const FALLBACK_META = { label: '', icon: RadioTower, color: 'text-muted-foreground' }
interface CollectorCardProps {
collector: Collector
@@ -31,114 +31,48 @@ interface CollectorCardProps {
onInstall: (id: number) => void
onUninstall: (id: number) => void
onDelete: (c: Collector) => void
onEdit: (c: Collector) => void
}
/** 采集器卡片 — 列表项展示与操作入口(部署/卸载/删除) */
/** 采集策略卡片 — 统一 ResourceCard 样式 */
export const CollectorCard = memo(function CollectorCard({
collector,
operating,
onInstall,
onUninstall,
onDelete,
collector, operating, onInstall, onUninstall, onDelete, onEdit,
}: CollectorCardProps) {
const meta = COLLECTOR_TYPE_META[collector.type] ?? {
...FALLBACK_META,
label: collector.type,
}
const meta = COLLECTOR_TYPE_META[collector.type] ?? FALLBACK_META
const Icon = meta.icon
const isBusy = operating === collector.id
return (
<Card className="border-border 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 min-w-0">
<Icon className="w-5 h-5 text-primary flex-shrink-0" />
<div className="min-w-0">
<h3 className="font-semibold text-foreground truncate">
{collector.name}
</h3>
<p className="text-xs text-muted-foreground">
关联 Loki:{collector.lokiDeployment?.name || '未知'}
</p>
</div>
</div>
<StatusBadge status={collector.status} />
</div>
<div className="space-y-1.5 text-xs text-muted-foreground 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>
{collector.host && (
<InfoRow
label="主机"
value={`${collector.host.name}(${collector.host.hostIP})`}
valueClass="truncate max-w-[200px]"
/>
)}
{collector.cluster && (
<InfoRow
label="集群"
value={collector.cluster.name}
valueClass="truncate max-w-[200px]"
/>
)}
{collector.targetDeployment && (
<InfoRow
label="目标 Deployment"
value={collector.targetDeployment}
valueClass="truncate max-w-[200px]"
/>
)}
{collector.namespace && (
<InfoRow label="命名空间" value={collector.namespace} valueClass="truncate max-w-[200px]" />
)}
{collector.logPath && (
<InfoRow label="日志路径" value={collector.logPath} valueClass="truncate max-w-[200px]" />
)}
{collector.statusMessage && (
<div className="p-2 bg-muted rounded text-xs mt-1">
{collector.statusMessage}
</div>
)}
</div>
<div className="flex gap-2 pt-3 border-t border-border">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onInstall(collector.id)}
disabled={isBusy}
>
<Upload className="w-3 h-3 mr-1" />
{isBusy ? '处理中...' : '部署'}
<ResourceCard
icon={Icon}
iconClassName={meta.color}
title={collector.name}
description={meta.label}
badges={<StatusBadge status={collector.status} />}
fields={[
{ label: '关联 Loki', value: collector.lokiDeployment?.name || '未知' },
...(collector.cluster ? [{ label: '集群', value: collector.cluster.name }] : []),
...(collector.host ? [{ label: '主机', value: `${collector.host.name}(${collector.host.hostIP})` }] : []),
...(collector.namespace ? [{ label: '命名空间', value: collector.namespace }] : []),
...(collector.logPath ? [{ label: '日志路径', value: collector.logPath, title: collector.logPath }] : []),
...(collector.targetDeployment ? [{ label: '目标', value: collector.targetDeployment }] : []),
]}
message={collector.statusMessage || undefined}
actions={
<>
<Button variant="outline" size="sm" aria-label={`编辑采集策略 ${collector.name}`} title="编辑采集策略" onClick={() => onEdit(collector)}>
<Pencil className="size-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onUninstall(collector.id)}
disabled={isBusy}
>
<Download className="w-3 h-3 mr-1" />
卸载
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onInstall(collector.id)} disabled={isBusy}>
<Upload className="size-3 mr-1" />{isBusy ? '处理中...' : '部署'}
</Button>
<Button
variant="outline"
size="sm"
aria-label={`删除采集器 ${collector.name}`}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onDelete(collector)}
>
<Trash2 className="w-3 h-3" />
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onUninstall(collector.id)} disabled={isBusy}>
<Download className="size-3 mr-1" />卸载
</Button>
</div>
</CardContent>
</Card>
<Button variant="outline" size="sm" aria-label={`删除采集器 ${collector.name}`} className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() => onDelete(collector)}>
<Trash2 className="size-3.5" />
</Button>
</>
}
/>
)
})
@@ -0,0 +1,74 @@
import { memo, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { collectorApi } from '@/api'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import type { Collector } from '@/types'
const schema = z.object({
name: z.string().trim().min(1, '请输入采集策略名称'),
logPath: z.string().trim(),
})
type Values = z.infer<typeof schema>
interface Props {
collector: Collector | null
onOpenChange: (open: boolean) => void
onSaved: () => void
}
export const CollectorEditDialog = memo(function CollectorEditDialog({ collector, onOpenChange, onSaved }: Props) {
const [saving, setSaving] = useState(false)
const form = useForm<Values>({ resolver: zodResolver(schema), defaultValues: { name: '', logPath: '' } })
useEffect(() => {
if (collector) form.reset({ name: collector.name, logPath: collector.logPath || '' })
}, [collector, form])
const submit = form.handleSubmit(async (values) => {
if (!collector) return
setSaving(true)
try {
await collectorApi.update(collector.id, values)
onOpenChange(false)
onSaved()
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存采集策略失败')
} finally {
setSaving(false)
}
})
const supportsPath = collector?.type !== 'k8s-daemonset'
return (
<Dialog open={collector !== null} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader><DialogTitle>编辑采集策略</DialogTitle></DialogHeader>
<Form {...form}>
<form onSubmit={submit} className="space-y-4">
<FormField control={form.control} name="name" render={({ field }) => (
<FormItem><FormLabel>名称</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
{supportsPath && <FormField control={form.control} name="logPath" render={({ field }) => (
<FormItem>
<FormLabel>采集日志路径</FormLabel>
<FormControl><Input placeholder={collector?.type === 'host-promtail' ? '/var/log/*.log' : '/var/log/app/*.log'} {...field} /></FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground">Sidecar 需使用业务容器的共享日志卷,或专用 logs 目录。保存后点击「部署」应用新配置。</p>
</FormItem>
)} />}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button type="submit" disabled={saving}>{saving ? '保存中…' : '保存'}</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
)
})
@@ -25,7 +25,7 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { collectorApi } from '@/api'
import { clusterApi, collectorApi } from '@/api'
import { statusLabel } from '@/lib/utils'
import { toast } from 'sonner'
import type { Cluster, Host, LokiDeployment } from '@/types'
@@ -34,8 +34,8 @@ type CollectorType = 'host-promtail' | 'k8s-daemonset' | 'k8s-fluentbit-sidecar'
const COLLECTOR_TYPES: Array<{ value: CollectorType; label: string }> = [
{ value: 'host-promtail', label: '主机 Promtail(SSH)' },
{ value: 'k8s-daemonset', label: 'K8s Promtail DaemonSet' },
{ value: 'k8s-fluentbit-sidecar', label: 'K8s Fluent Bit Sidecar' },
{ value: 'k8s-daemonset', label: 'Kubernetes Promtail DaemonSet' },
{ value: 'k8s-fluentbit-sidecar', label: 'Kubernetes Fluent Bit Sidecar' },
]
// 表单 schema:zod 为唯一校验源,消息中文且具体
@@ -69,6 +69,9 @@ const collectorFormSchema = z
message: '请填写目标 Deployment 名称',
})
}
if (data.type === 'k8s-fluentbit-sidecar' && !data.namespace.trim()) {
ctx.addIssue({ code: 'custom', path: ['namespace'], message: '请选择命名空间' })
}
})
type CollectorFormValues = z.infer<typeof collectorFormSchema>
@@ -105,20 +108,70 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
hosts,
}: CollectorFormDialogProps) {
const [submitting, setSubmitting] = useState(false)
const [namespaces, setNamespaces] = useState<string[]>([])
const [workloads, setWorkloads] = useState<Array<{ name: string; replicas: number; readyReplicas: number }>>([])
const [namespacesLoading, setNamespacesLoading] = useState(false)
const [workloadsLoading, setWorkloadsLoading] = useState(false)
const [namespacesError, setNamespacesError] = useState('')
const [workloadsError, setWorkloadsError] = useState('')
const [namespaceReload, setNamespaceReload] = useState(0)
const [workloadReload, setWorkloadReload] = useState(0)
const [manualNamespace, setManualNamespace] = useState(false)
const [manualDeployment, setManualDeployment] = useState(false)
const form = useForm<CollectorFormValues>({
resolver: zodResolver(collectorFormSchema),
defaultValues: DEFAULT_VALUES,
})
const collectorType = form.watch('type')
const clusterID = form.watch('clusterID')
const namespace = form.watch('namespace')
const isSidecarType = collectorType === 'k8s-fluentbit-sidecar'
// 打开时重置(关闭重置时机明确)
useEffect(() => {
if (open) {
form.reset(DEFAULT_VALUES)
setManualNamespace(false)
setManualDeployment(false)
}
}, [open, form])
useEffect(() => {
if (!open || !isSidecarType || !clusterID) return
const controller = new AbortController()
setNamespacesLoading(true)
setNamespacesError('')
setNamespaces([])
clusterApi.namespaces(Number(clusterID), { signal: controller.signal })
.then(({ namespaces: items }) => {
setNamespaces(items)
if (items.includes('default') && !form.getValues('namespace')) form.setValue('namespace', 'default')
})
.catch((err) => {
if (!controller.signal.aborted) setNamespacesError(err instanceof Error ? err.message : '读取命名空间失败')
})
.finally(() => { if (!controller.signal.aborted) setNamespacesLoading(false) })
return () => controller.abort()
}, [open, isSidecarType, clusterID, form, namespaceReload])
useEffect(() => {
if (!open || !isSidecarType || !clusterID || !namespace) return
const controller = new AbortController()
setWorkloadsLoading(true)
setWorkloadsError('')
setWorkloads([])
const timer = setTimeout(() => {
clusterApi.deployments(Number(clusterID), namespace, { signal: controller.signal })
.then(({ deployments: items }) => setWorkloads(items))
.catch((err) => {
if (!controller.signal.aborted) setWorkloadsError(err instanceof Error ? err.message : '读取 Deployment 失败')
})
.finally(() => { if (!controller.signal.aborted) setWorkloadsLoading(false) })
}, 250)
return () => { clearTimeout(timer); controller.abort() }
}, [open, isSidecarType, clusterID, namespace, workloadReload])
const onSubmit = form.handleSubmit(async (values) => {
setSubmitting(true)
try {
@@ -151,7 +204,6 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
const isHostType = collectorType === 'host-promtail'
const isK8sType = collectorType !== 'host-promtail'
const isSidecarType = collectorType === 'k8s-fluentbit-sidecar'
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -262,7 +314,14 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
<FormItem>
<FormLabel>目标集群 *</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<Select value={field.value} onValueChange={(value) => {
field.onChange(value)
form.setValue('namespace', '')
form.setValue('targetDeployment', '')
setManualNamespace(false)
setManualDeployment(false)
setWorkloads([])
}}>
<SelectTrigger>
<SelectValue placeholder="选择集群" />
</SelectTrigger>
@@ -285,14 +344,33 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
name="namespace"
render={({ field }) => (
<FormItem>
<FormLabel>命名空间</FormLabel>
<FormLabel>命名空间{isSidecarType ? ' *' : ''}</FormLabel>
<FormControl>
<Input
placeholder={collectorType === 'k8s-daemonset' ? 'logging' : 'default'}
{...field}
/>
{isSidecarType && !manualNamespace ? (
<Select value={field.value} onValueChange={(value) => {
field.onChange(value)
form.setValue('targetDeployment', '')
}} disabled={!clusterID || namespacesLoading}>
<SelectTrigger><SelectValue placeholder={!clusterID ? '先选择集群' : namespacesLoading ? '加载命名空间…' : '选择命名空间'} /></SelectTrigger>
<SelectContent>
{namespaces.map((item) => <SelectItem key={item} value={item}>{item}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input placeholder={collectorType === 'k8s-daemonset' ? 'logging' : 'default'} {...field} onChange={(event) => {
field.onChange(event)
if (isSidecarType) form.setValue('targetDeployment', '')
}} />
)}
</FormControl>
<FormMessage />
{isSidecarType && <div className="flex items-center gap-2 text-xs">
<Button type="button" variant="link" size="sm" className="h-auto p-0" onClick={() => setManualNamespace((value) => !value)}>
{manualNamespace ? '从集群选择' : '手动填写'}
</Button>
{namespacesError && <><span className="text-destructive">{namespacesError}</span><Button type="button" variant="link" size="sm" className="h-auto p-0" onClick={() => setNamespaceReload((value) => value + 1)}>重试</Button></>}
{!namespacesLoading && !namespacesError && clusterID && namespaces.length === 0 && <span className="text-muted-foreground">暂无可读取的命名空间</span>}
</div>}
</FormItem>
)}
/>
@@ -304,9 +382,27 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
<FormItem>
<FormLabel>目标 Deployment *</FormLabel>
<FormControl>
<Input placeholder="my-app" {...field} />
{manualDeployment ? (
<Input placeholder="my-app" {...field} />
) : (
<Select value={field.value} onValueChange={field.onChange} disabled={!namespace || workloadsLoading}>
<SelectTrigger><SelectValue placeholder={!namespace ? '先选择命名空间' : workloadsLoading ? '加载 Deployment…' : '选择 Deployment'} /></SelectTrigger>
<SelectContent>
{workloads.map((item) => <SelectItem key={item.name} value={item.name}>
{item.name}(就绪 {item.readyReplicas}/{item.replicas})
</SelectItem>)}
</SelectContent>
</Select>
)}
</FormControl>
<FormMessage />
<div className="flex items-center gap-2 text-xs">
<Button type="button" variant="link" size="sm" className="h-auto p-0" onClick={() => setManualDeployment((value) => !value)}>
{manualDeployment ? '从集群选择' : '手动填写'}
</Button>
{workloadsError && <><span className="text-destructive">{workloadsError}</span><Button type="button" variant="link" size="sm" className="h-auto p-0" onClick={() => setWorkloadReload((value) => value + 1)}>重试</Button></>}
{!workloadsLoading && !workloadsError && namespace && workloads.length === 0 && <span className="text-muted-foreground">此命名空间暂无 Deployment</span>}
</div>
</FormItem>
)}
/>
@@ -333,6 +429,7 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
支持 glob 通配(如 /var/log/*.log /var/log/syslog);多个路径用逗号分隔
</p>
)}
{isSidecarType && <p className="text-xs text-muted-foreground">填写业务写入的文件路径,如 /var/log/app/*.log。已有日志卷会自动共享;无日志卷时仅在专用 logs 目录创建共享卷。/tmp/test.log 等普通目录不会自动挂载。</p>}
</FormItem>
)}
/>
+6 -1
View File
@@ -13,6 +13,7 @@ import { useListQuery } from '@/hooks/useListQuery'
import type { Cluster, Collector, Host, LokiDeployment } from '@/types'
import { CollectorCard } from './components/CollectorCard'
import { CollectorFormDialog } from './components/CollectorFormDialog'
import { CollectorEditDialog } from './components/CollectorEditDialog'
/**
* 日志采集页 — 采集策略编排:列表走 useListQuery(取消+版本号),
@@ -30,6 +31,7 @@ const CollectorsPage = memo(function CollectorsPage() {
const [hosts, setHosts] = useState<Host[]>([])
const [formOpen, setFormOpen] = useState(false)
const [editTarget, setEditTarget] = useState<Collector | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Collector | null>(null)
const [deleting, setDeleting] = useState(false)
/** 部署/卸载指令进行中的采集器 ID(按钮 loading + 防重复点击) */
@@ -128,7 +130,7 @@ const CollectorsPage = memo(function CollectorsPage() {
/>
) : (
<>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{query.list.map((collector) => (
<CollectorCard
key={collector.id}
@@ -137,6 +139,7 @@ const CollectorsPage = memo(function CollectorsPage() {
onInstall={(id) => handleAction(id, 'install')}
onUninstall={(id) => handleAction(id, 'uninstall')}
onDelete={setDeleteTarget}
onEdit={setEditTarget}
/>
))}
</div>
@@ -158,6 +161,8 @@ const CollectorsPage = memo(function CollectorsPage() {
hosts={hosts}
/>
<CollectorEditDialog collector={editTarget} onOpenChange={(open) => { if (!open) setEditTarget(null) }} onSaved={query.refresh} />
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {