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

@@ -1,9 +1,8 @@
import { memo } from 'react'
import { Server, RefreshCw, Trash2, Eye } from 'lucide-react'
import { Server, RefreshCw, Trash2, Eye, Pencil } 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 { Cluster } from '@/types'
interface ClusterCardProps {
@@ -12,90 +11,42 @@ interface ClusterCardProps {
onViewInfo: (c: Cluster) => void
onValidate: (id: number) => void
onDelete: (c: Cluster) => void
onEdit: (c: Cluster) => void
}
/** 集群卡片 — 列表项展示与操作入口 */
/** 集群卡片 — 统一 ResourceCard 样式 */
export const ClusterCard = memo(function ClusterCard({
cluster,
validating,
onViewInfo,
onValidate,
onDelete,
cluster, validating, onViewInfo, onValidate, onDelete, onEdit,
}: ClusterCardProps) {
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">
<Server className="w-5 h-5 text-info flex-shrink-0" />
<h3 className="font-semibold text-foreground truncate">
{cluster.name}
</h3>
</div>
<StatusBadge status={cluster.status} />
</div>
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">
{cluster.description || '无描述'}
</p>
<div className="space-y-1.5 text-xs text-muted-foreground mb-4">
<InfoRow
label="接入方式"
value={cluster.configType === 'token' ? 'Token' : 'Kubeconfig'}
/>
{cluster.apiServer && (
<InfoRow
label="API Server"
value={cluster.apiServer}
valueClass="truncate max-w-[150px]"
/>
)}
<InfoRow label="版本" value={cluster.version || '未知'} />
<InfoRow label="节点数" value={String(cluster.nodesCount || 0)} />
<InfoRow
label="最后连接"
value={
cluster.lastConnected
? new Date(cluster.lastConnected).toLocaleString()
: '从未'
}
/>
</div>
<div className="flex gap-2 pt-3 border-t border-border">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onViewInfo(cluster)}
>
<Eye className="w-3 h-3 mr-1" />
详情
<ResourceCard
icon={Server}
iconClassName="text-info"
title={cluster.name}
description={cluster.description || undefined}
badges={<StatusBadge status={cluster.status} />}
fields={[
{ label: '接入方式', value: cluster.configType === 'token' ? 'Token' : 'Kubeconfig' },
...(cluster.apiServer ? [{ label: 'API Server', value: cluster.apiServer, title: cluster.apiServer }] : []),
{ label: '版本', value: cluster.version || '未知' },
{ label: '节点数', value: String(cluster.nodesCount || 0) },
]}
actions={
<>
<Button variant="outline" size="sm" aria-label={`编辑集群 ${cluster.name}`} onClick={() => onEdit(cluster)}>
<Pencil className="size-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="flex-1 text-xs"
onClick={() => onValidate(cluster.id)}
disabled={validating === cluster.id}
>
<RefreshCw
className={`w-3 h-3 mr-1 ${validating === cluster.id ? 'animate-spin' : ''}`}
/>
验证
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onViewInfo(cluster)}>
<Eye className="size-3 mr-1" />详情
</Button>
<Button
variant="outline"
size="sm"
aria-label={`删除集群 ${cluster.name}`}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onDelete(cluster)}
>
<Trash2 className="w-3 h-3" />
<Button variant="outline" size="sm" aria-label={`验证集群 ${cluster.name}`} className="text-xs" disabled={validating === cluster.id} onClick={() => onValidate(cluster.id)}>
<RefreshCw className={`size-3 ${validating === cluster.id ? 'animate-spin' : ''}`} />
</Button>
</div>
</CardContent>
</Card>
<Button variant="outline" size="sm" aria-label={`删除集群 ${cluster.name}`} className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() => onDelete(cluster)}>
<Trash2 className="size-3.5" />
</Button>
</>
}
/>
)
})
@@ -42,6 +42,7 @@ const clusterFormSchema = z
context: z.string().optional(),
})
.superRefine((data, ctx) => {
if (EDITING_REF.current) return // 编辑模式:凭证留空=保留,不做必填校验
if (data.configType === 'kubeconfig' && !data.kubeconfig?.trim()) {
ctx.addIssue({ code: 'custom', path: ['kubeconfig'], message: '请填写 kubeconfig 内容' })
}
@@ -72,13 +73,18 @@ interface ClusterFormDialogProps {
onOpenChange: (open: boolean) => void
/** 创建成功回调(父级刷新列表并关闭弹窗) */
onCreated: () => void
/** 编辑目标(编辑模式:kubeconfig 留空=保留) */
editing?: import('@/types').Cluster | null
}
/** 添加集群弹窗 — react-hook-form + zod,自包含表单逻辑,页面只传开关与回调 */
const EDITING_REF = { current: false as boolean | null }
export const ClusterFormDialog = memo(function ClusterFormDialog({
open,
onOpenChange,
onCreated,
editing,
}: ClusterFormDialogProps) {
const [submitting, setSubmitting] = useState(false)
@@ -90,24 +96,38 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
const kubeconfig = form.watch('kubeconfig')
// 打开时重置(编辑回填/关闭重置时机明确)
EDITING_REF.current = !!editing
useEffect(() => {
if (open) {
form.reset(DEFAULT_VALUES)
form.reset(editing ? {
name: editing.name, description: editing.description || '',
configType: (editing.configType as 'kubeconfig' | 'token') || 'kubeconfig',
kubeconfig: '', apiServer: editing.apiServer || '', token: '', context: editing.context || '',
} as never : DEFAULT_VALUES)
}
}, [open, form])
}, [open, form, editing])
const onSubmit = form.handleSubmit(async (values) => {
setSubmitting(true)
try {
await clusterApi.create({
name: values.name,
description: values.description,
configType: values.configType,
apiServer: values.configType === 'token' ? values.apiServer : undefined,
kubeconfig: values.configType === 'kubeconfig' ? values.kubeconfig : undefined,
token: values.configType === 'token' ? values.token : undefined,
context: values.context,
})
await (editing
? clusterApi.update(editing.id, {
name: values.name, description: values.description,
configType: values.configType,
apiServer: values.configType === 'token' ? values.apiServer : undefined,
kubeconfig: values.configType === 'kubeconfig' && values.kubeconfig ? values.kubeconfig : undefined,
token: values.configType === 'token' && values.token ? values.token : undefined,
context: values.context,
})
: clusterApi.create({
name: values.name,
description: values.description,
configType: values.configType,
apiServer: values.configType === 'token' ? values.apiServer : undefined,
kubeconfig: values.configType === 'kubeconfig' ? values.kubeconfig : undefined,
token: values.configType === 'token' ? values.token : undefined,
context: values.context,
}))
onOpenChange(false)
onCreated()
} catch (err) {
@@ -121,7 +141,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>添加 Kubernetes 集群</DialogTitle>
<DialogTitle>{editing ? `编辑集群${editing.name ? ` · ${editing.name}` : ''}` : '添加 Kubernetes 集群'}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
@@ -175,7 +195,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
name="kubeconfig"
render={({ field }) => (
<FormItem>
<FormLabel>Kubeconfig 内容 *</FormLabel>
<FormLabel>Kubeconfig 内容{editing ? '(留空保持不变)' : ' *'}</FormLabel>
<FormControl>
<Textarea
placeholder="粘贴 kubeconfig YAML(自动识别 API Server)..."
@@ -212,7 +232,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
name="token"
render={({ field }) => (
<FormItem>
<FormLabel>Token *</FormLabel>
<FormLabel>Token{editing ? '(留空保持不变)' : ' *'}</FormLabel>
<FormControl>
<Textarea
placeholder="粘贴 Service Account Token..."
@@ -253,7 +273,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
className="w-full bg-primary hover:bg-primary/90"
disabled={submitting}
>
{submitting ? '添加中...' : '添加集群'}
{submitting ? (editing ? '保存中...' : '添加中...') : editing ? '保存修改' : '添加集群'}
</Button>
</form>
</Form>
+13 -4
View File
@@ -27,6 +27,7 @@ const ClustersPage = memo(function ClustersPage() {
const [formOpen, setFormOpen] = useState(false)
const [detailCluster, setDetailCluster] = useState<Cluster | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Cluster | null>(null)
const [editingTarget, setEditingTarget] = useState<Cluster | null>(null)
const [deleting, setDeleting] = useState(false)
const [validating, setValidating] = useState<number | null>(null)
@@ -110,7 +111,7 @@ const ClustersPage = memo(function ClustersPage() {
/>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{query.list.map((cluster) => (
<ClusterCard
key={cluster.id}
@@ -119,6 +120,7 @@ const ClustersPage = memo(function ClustersPage() {
onViewInfo={setDetailCluster}
onValidate={handleValidate}
onDelete={setDeleteTarget}
onEdit={setEditingTarget}
/>
))}
</div>
@@ -132,9 +134,15 @@ const ClustersPage = memo(function ClustersPage() {
)}
<ClusterFormDialog
open={formOpen}
onOpenChange={setFormOpen}
onCreated={query.reload}
open={formOpen || editingTarget !== null}
onOpenChange={(o) => {
if (!o) {
setFormOpen(false)
setEditingTarget(null)
}
}}
onCreated={query.refresh}
editing={editingTarget}
/>
<ClusterDetailDialog
@@ -143,6 +151,7 @@ const ClustersPage = memo(function ClustersPage() {
onClose={() => setDetailCluster(null)}
/>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {