feat: 品牌统一 LokiStack + 全站中英 i18n + 用户批量删除 + 主题定时切换与浅色侧栏

- 品牌: Go 模块/数据文件/二进制/构建部署资产全部 opsx→lokistack;前端 localStorage 命名空间迁移(读旧写新,含按用户 id 前缀迁移);修复 nginx upstream 与 Makefile 引用已改名服务的断裂
- i18n: react-i18next 落地,common + 11 个页面域命名空间全站中英(菜单/顶栏/登录/列表页/表单/确认弹窗/状态徽章);设置页语言即时切换;修复词典自注册导致的循环导入白屏
- 用户管理: 批量删除(POST /users/batch-delete,不删自己/最后管理员,部分失败不阻断)+ 行复选/全选/批量工具条
- 主题: 浅色侧栏(中性灰激活态)、四态模式(浅色/深色/跟随系统/定时切换,按时区判定)、顶栏太阳月亮二态、设置页紧凑分段选择器
- UI: 登录记住用户名、CardActionButton 统一五页卡片操作钮、shadcn Badge 替换手写徽章、语义色修正
- 清理: 移除主机 Web 终端(前后端)、k8s-helm/Helm 遗留代码与字段、README 重写精简
- 修复: 侧栏与日志告警 order 重复、路由标题 i18n key 未翻译、词典双逗号等
This commit is contained in:
opsx committed 2026-10-27 17:11:38 +08:00
1 parent 02d1bd87a7
commit f3733f0bfc
201 files changed
+5438 -2388

No files matched your search

@@ -1,8 +1,9 @@
import { memo } from 'react'
import { Server, RefreshCw, Trash2, Eye, Pencil } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useTranslation } from 'react-i18next'
import { StatusBadge } from '@/components/StatusBadge'
import { ResourceCard } from '@/components/ResourceCard'
import { CardActionButton } from '@/components/CardActionButton'
import type { Cluster } from '@/types'
interface ClusterCardProps {
@@ -18,6 +19,7 @@ interface ClusterCardProps {
export const ClusterCard = memo(function ClusterCard({
cluster, validating, onViewInfo, onValidate, onDelete, onEdit,
}: ClusterCardProps) {
const { t } = useTranslation('clusters')
return (
<ResourceCard
icon={Server}
@@ -26,25 +28,23 @@ export const ClusterCard = memo(function ClusterCard({
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) },
{ label: t('fields.accessType'), value: cluster.configType === 'token' ? 'Token' : 'Kubeconfig' },
...(cluster.apiServer ? [{ label: t('fields.apiServer'), value: cluster.apiServer, title: cluster.apiServer }] : []),
{ label: t('fields.version'), value: cluster.version || t('fields.unknown') },
{ label: t('fields.nodes'), 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={() => onViewInfo(cluster)}>
<Eye className="size-3 mr-1" />详情
</Button>
<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>
<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>
<CardActionButton icon={Pencil} ariaLabel={t('actions.editAria', { name: cluster.name })} onClick={() => onEdit(cluster)} />
<CardActionButton icon={Eye} label={t('actions.detail')} onClick={() => onViewInfo(cluster)} />
<CardActionButton
icon={RefreshCw}
ariaLabel={t('actions.validateAria', { name: cluster.name })}
spinning={validating === cluster.id}
disabled={validating === cluster.id}
onClick={() => onValidate(cluster.id)}
/>
<CardActionButton icon={Trash2} ariaLabel={t('actions.deleteAria', { name: cluster.name })} destructive onClick={() => onDelete(cluster)} />
</>
}
/>
@@ -1,5 +1,6 @@
import { memo, useEffect, useState } from 'react'
import { Server } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
@@ -21,6 +22,7 @@ export const ClusterDetailDialog = memo(function ClusterDetailDialog({
cluster,
onClose,
}: ClusterDetailDialogProps) {
const { t } = useTranslation('clusters')
const [info, setInfo] = useState<ClusterInfo | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -33,12 +35,12 @@ export const ClusterDetailDialog = memo(function ClusterDetailDialog({
if (!cancelled) setInfo(data)
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : '加载集群详情失败')
if (!cancelled) setError(err instanceof Error ? err.message : t('detail.loadError'))
})
return () => {
cancelled = true
}
}, [cluster])
}, [cluster, t])
return (
<Dialog open={cluster !== null} onOpenChange={(open) => !open && onClose()}>
@@ -64,21 +66,22 @@ export const ClusterDetailDialog = memo(function ClusterDetailDialog({
/** 集群详情内容:版本/上下文/节点/命名空间 */
const ClusterDetail = memo(function ClusterDetail({ info }: { info: ClusterInfo }) {
const { t } = useTranslation('clusters')
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-muted rounded-lg">
<p className="text-xs text-muted-foreground">版本</p>
<p className="text-sm font-medium">{info.version || '未知'}</p>
<p className="text-xs text-muted-foreground">{t('detail.version')}</p>
<p className="text-sm font-medium">{info.version || t('detail.unknown')}</p>
</div>
<div className="p-3 bg-muted rounded-lg">
<p className="text-xs text-muted-foreground">当前上下文</p>
<p className="text-sm font-medium">{info.currentContext || '默认'}</p>
<p className="text-xs text-muted-foreground">{t('detail.currentContext')}</p>
<p className="text-sm font-medium">{info.currentContext || t('detail.defaultContext')}</p>
</div>
</div>
<div>
<h4 className="text-sm font-semibold mb-2">节点({info.nodes.length})</h4>
<h4 className="text-sm font-semibold mb-2">{t('detail.nodesCount', { count: info.nodes.length })}</h4>
<div className="space-y-2">
{info.nodes.map((node) => (
<div
@@ -90,17 +93,17 @@ const ClusterDetail = memo(function ClusterDetail({ info }: { info: ClusterInfo
<span
className={`px-2 py-0.5 rounded-full text-xs ${
node.status === 'Ready'
? 'bg-success/10 text-primary'
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
>
{node.status === 'Ready' ? '就绪' : '未就绪'}
{node.status === 'Ready' ? t('detail.nodeReady') : t('detail.nodeNotReady')}
</span>
</div>
<div className="grid grid-cols-3 gap-2 mt-2 text-xs text-muted-foreground">
<span>IP:{node.internalIP}</span>
<span>系统:{node.os}/{node.arch}</span>
<span>版本:{node.version}</span>
<span>{t('detail.nodeIp', { value: node.internalIP })}</span>
<span>{t('detail.nodeOs', { os: node.os, arch: node.arch })}</span>
<span>{t('detail.nodeVersion', { version: node.version })}</span>
</div>
{node.roles.length > 0 && (
<div className="flex gap-1 mt-2">
@@ -120,7 +123,7 @@ const ClusterDetail = memo(function ClusterDetail({ info }: { info: ClusterInfo
</div>
<div>
<h4 className="text-sm font-semibold mb-2">命名空间</h4>
<h4 className="text-sm font-semibold mb-2">{t('detail.namespaces')}</h4>
<div className="flex flex-wrap gap-1">
{info.namespaces.map((ns) => (
<span
@@ -1,7 +1,9 @@
import { memo, useEffect, useState } from 'react'
import { memo, useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import type { TFunction } from 'i18next'
import { Trans, useTranslation } from 'react-i18next'
import { Key, FileKey } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -30,33 +32,37 @@ const parseApiServer = (kubeconfig: string): string => {
return m ? m[1] : ''
}
// 表单 schema:zod 为唯一校验源,消息中文且具体
const clusterFormSchema = z
.object({
name: z.string().min(1, '请输入集群名称'),
description: z.string().optional(),
configType: z.enum(['kubeconfig', 'token']),
kubeconfig: z.string().optional(),
apiServer: z.string().optional(),
token: z.string().optional(),
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 内容' })
}
if (data.configType === 'token') {
if (!data.apiServer?.trim()) {
ctx.addIssue({ code: 'custom', path: ['apiServer'], message: '请填写 API Server 地址' })
}
if (!data.token?.trim()) {
ctx.addIssue({ code: 'custom', path: ['token'], message: '请填写 Token' })
}
}
})
/** 添加集群弹窗 — react-hook-form + zod,自包含表单逻辑,页面只传开关与回调 */
const EDITING_REF = { current: false as boolean | null }
type ClusterFormValues = z.infer<typeof clusterFormSchema>
// 表单 schema:zod 为唯一校验源,消息随语言(由组件内以 t 构造,语言切换后新校验即时生效)
const makeClusterFormSchema = (t: TFunction<'clusters'>) =>
z
.object({
name: z.string().min(1, t('form.validation.nameRequired')),
description: z.string().optional(),
configType: z.enum(['kubeconfig', 'token']),
kubeconfig: z.string().optional(),
apiServer: z.string().optional(),
token: z.string().optional(),
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: t('form.validation.kubeconfigRequired') })
}
if (data.configType === 'token') {
if (!data.apiServer?.trim()) {
ctx.addIssue({ code: 'custom', path: ['apiServer'], message: t('form.validation.apiServerRequired') })
}
if (!data.token?.trim()) {
ctx.addIssue({ code: 'custom', path: ['token'], message: t('form.validation.tokenRequired') })
}
}
})
type ClusterFormValues = z.infer<ReturnType<typeof makeClusterFormSchema>>
const DEFAULT_VALUES: ClusterFormValues = {
name: '',
@@ -77,19 +83,18 @@ interface ClusterFormDialogProps {
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 { t } = useTranslation('clusters')
const [submitting, setSubmitting] = useState(false)
const schema = useMemo(() => makeClusterFormSchema(t), [t])
const form = useForm<ClusterFormValues>({
resolver: zodResolver(clusterFormSchema),
resolver: zodResolver(schema),
defaultValues: DEFAULT_VALUES,
})
const configType = form.watch('configType')
@@ -131,7 +136,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
onOpenChange(false)
onCreated()
} catch (err) {
toast.error(err instanceof Error ? err.message : '创建集群失败')
toast.error(err instanceof Error ? err.message : t('toast.createFailed'))
} finally {
setSubmitting(false)
}
@@ -141,7 +146,13 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? `编辑集群${editing.name ? ` · ${editing.name}` : ''}` : '添加 Kubernetes 集群'}</DialogTitle>
<DialogTitle>
{editing
? editing.name
? t('form.editTitleWithName', { name: editing.name })
: t('form.editTitle')
: t('form.addTitle')}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
@@ -150,7 +161,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>集群名称 *</FormLabel>
<FormLabel>{t('form.nameLabel')}</FormLabel>
<FormControl>
<Input placeholder="production-cluster" {...field} />
</FormControl>
@@ -163,9 +174,9 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>描述</FormLabel>
<FormLabel>{t('form.descriptionLabel')}</FormLabel>
<FormControl>
<Input placeholder="生产环境日志集群" {...field} />
<Input placeholder={t('form.descriptionPlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -173,7 +184,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
/>
<FormItem>
<FormLabel>接入方式</FormLabel>
<FormLabel>{t('form.accessTypeLabel')}</FormLabel>
<FormControl>
<Tabs
value={configType}
@@ -195,10 +206,12 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
name="kubeconfig"
render={({ field }) => (
<FormItem>
<FormLabel>Kubeconfig 内容{editing ? '(留空保持不变)' : ' *'}</FormLabel>
<FormLabel>
{editing ? t('form.kubeconfigLabelEdit') : t('form.kubeconfigLabel')}
</FormLabel>
<FormControl>
<Textarea
placeholder="粘贴 kubeconfig YAML(自动识别 API Server)..."
placeholder={t('form.kubeconfigPlaceholder')}
className="min-h-[200px] font-mono text-xs"
{...field}
/>
@@ -209,7 +222,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
/>
{kubeconfig && parseApiServer(kubeconfig) && (
<p className="text-xs text-primary">
已识别 API Server:{parseApiServer(kubeconfig)}
{t('form.apiServerDetected', { server: parseApiServer(kubeconfig) })}
</p>
)}
</TabsContent>
@@ -219,7 +232,7 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
name="apiServer"
render={({ field }) => (
<FormItem>
<FormLabel>API Server *</FormLabel>
<FormLabel>{t('form.apiServerLabel')}</FormLabel>
<FormControl>
<Input placeholder="https://10.0.0.1:6443" {...field} />
</FormControl>
@@ -232,35 +245,39 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
name="token"
render={({ field }) => (
<FormItem>
<FormLabel>Token{editing ? '(留空保持不变)' : ' *'}</FormLabel>
<FormLabel>
{editing ? t('form.tokenLabelEdit') : t('form.tokenLabel')}
</FormLabel>
<FormControl>
<Textarea
placeholder="粘贴 Service Account Token..."
placeholder={t('form.tokenPlaceholder')}
className="min-h-[120px] font-mono text-xs"
{...field}
/>
</FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground">
采用 insecure 模式,便于内网/自签证书集群接入
{t('form.tokenInsecureHint')}
</p>
</FormItem>
)}
/>
<details className="min-w-0 overflow-hidden rounded-md border bg-muted/30 px-3 py-2 text-xs">
<summary className="cursor-pointer select-none font-medium">
最小授权建议(点击展开)
{t('form.rbacSummary')}
</summary>
<div className="mt-2 min-w-0 space-y-2 break-words text-muted-foreground">
<p>
只授予 <code>log</code> 命名空间权限,Token 泄露也不会影响其他命名空间;
<strong>不要使用 cluster-admin</strong>。
</p>
<p>
一次性创建 <code>log</code> 命名空间、ServiceAccount、命名空间级 Role 和长期 Token
(在集群里执行一次,Token 粘贴到下方输入框):
</p>
<pre className="max-w-full overflow-x-auto whitespace-pre-wrap [overflow-wrap:anywhere] rounded bg-background p-2 font-mono text-[11px] leading-relaxed">{`# 1) 一次创建:命名空间 + SA + 命名空间级 Role + 长期 Token
<Trans
t={t}
i18nKey="form.rbacScope"
components={{ code: <code />, strong: <strong /> }}
/>
<Trans
t={t}
i18nKey="form.rbacScriptIntro"
components={{ code: <code /> }}
/>
<pre className="max-w-full overflow-x-auto whitespace-pre-wrap [overflow-wrap:anywhere] rounded bg-background p-2 font-mono text-[11px] leading-relaxed">{`${t('form.rbacScriptComment1')}
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Namespace
@@ -313,21 +330,18 @@ metadata:
type: kubernetes.io/service-account-token
EOF
# 2) 取出长期 Token(不自动过期),粘贴到 Token 输入框
${t('form.rbacScriptComment2')}
kubectl -n log get secret lokistack-token -o jsonpath='{.data.token}' | base64 -d`}</pre>
<p>
上面的 Namespace 由管理员创建,平台 Token 本身没有任何集群级权限。
只有确实需要平台自建命名空间时,才额外授予
<code>namespaces: create/get</code> 的 ClusterRole;
注意 namespace 的 create 无法按名称限制,所以预建 <code>log</code> 更安全。
</p>
<p>
Token 长期有效,但只绑定 <code>log</code> 命名空间的 Role
(仅能操作该命名空间的 Pod / 工作负载),泄露影响面仅限于 <code>log</code>;
服务端会转成 kubeconfig 并用 <code>ENCRYPTION_KEY</code> 加密存储,
数据库里只有 <code>ENC:</code> 密文,接口不返回明文。
轮换时删除该 Secret 重建即可,旧 Token 立即失效。
</p>
<Trans
t={t}
i18nKey="form.rbacNamespaceNote"
components={{ code: <code /> }}
/>
<Trans
t={t}
i18nKey="form.rbacTokenNote"
components={{ code: <code /> }}
/>
</div>
</details>
</TabsContent>
@@ -341,9 +355,9 @@ kubectl -n log get secret lokistack-token -o jsonpath='{.data.token}' | base64 -
name="context"
render={({ field }) => (
<FormItem>
<FormLabel>上下文(可选)</FormLabel>
<FormLabel>{t('form.contextLabel')}</FormLabel>
<FormControl>
<Input placeholder="留空则使用默认上下文" {...field} />
<Input placeholder={t('form.contextPlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -356,7 +370,13 @@ kubectl -n log get secret lokistack-token -o jsonpath='{.data.token}' | base64 -
className="w-full bg-primary hover:bg-primary/90"
disabled={submitting}
>
{submitting ? (editing ? '保存中...' : '添加中...') : editing ? '保存修改' : '添加集群'}
{submitting
? editing
? t('form.submittingSave')
: t('form.submittingAdd')
: editing
? t('form.submitSave')
: t('form.submitAdd')}
</Button>
</form>
</Form>
+25 -16
View File
@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { Server, Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { clusterApi, deploymentApi, collectorApi } from '@/api'
import { PageContainer } from '@/components/PageContainer'
@@ -11,15 +12,23 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'
import { toast } from 'sonner'
import { useListQuery } from '@/hooks/useListQuery'
import type { Cluster } from '@/types'
import i18n from '@/i18n'
import zhClusters from '@/i18n/locales/zh/clusters'
import enClusters from '@/i18n/locales/en/clusters'
import { ClusterCard } from './components/ClusterCard'
import { ClusterFormDialog } from './components/ClusterFormDialog'
import { ClusterDetailDialog } from './components/ClusterDetailDialog'
// 注册本域命名空间(i18n/index.ts 只负责 common,域词典就近注册;页面为懒加载,渲染前必然已注册)
i18n.addResourceBundle('zh', 'clusters', zhClusters)
i18n.addResourceBundle('en', 'clusters', enClusters)
/**
* 集群管理页 — 只做编排:数据走 useListQuery(取消+版本号),
* 卡片/表单/详情/删除确认均为域内子组件。成功操作静默(列表刷新即反馈)。
*/
const ClustersPage = memo(function ClustersPage() {
const { t } = useTranslation('clusters')
const query = useListQuery<Cluster>((params, signal) =>
clusterApi.list(params, { signal })
)
@@ -64,11 +73,11 @@ const ClustersPage = memo(function ClustersPage() {
setDeleteTarget(null)
query.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '删除集群失败')
toast.error(err instanceof Error ? err.message : t('toast.deleteFailed'))
} finally {
setDeleting(false)
}
}, [deleteTarget, query])
}, [deleteTarget, query, t])
const handleValidate = useCallback(
async (id: number) => {
@@ -77,25 +86,22 @@ const ClustersPage = memo(function ClustersPage() {
await clusterApi.validate(id)
timeoutsRef.current.push(setTimeout(() => query.refresh(), 3000))
} catch (err) {
toast.error(err instanceof Error ? err.message : '验证失败')
toast.error(err instanceof Error ? err.message : t('toast.validateFailed'))
} finally {
timeoutsRef.current.push(setTimeout(() => setValidating(null), 3000))
}
},
[query]
[query, t]
)
return (
<PageContainer
title="集群管理"
description="管理用于 Loki 部署的 Kubernetes 集群"
title={t('title')}
description={t('description')}
action={
<Button
className="bg-primary hover:bg-primary/90"
onClick={() => setFormOpen(true)}
>
<Button onClick={() => setFormOpen(true)}>
<Plus className="w-4 h-4 mr-1" />
添加集群
{t('actions.add')}
</Button>
}
>
@@ -106,8 +112,8 @@ const ClustersPage = memo(function ClustersPage() {
) : query.list.length === 0 ? (
<EmptyState
icon={Server}
title="暂无集群"
description="添加第一个 Kubernetes 集群开始使用"
title={t('empty.title')}
description={t('empty.description')}
/>
) : (
<>
@@ -141,7 +147,7 @@ const ClustersPage = memo(function ClustersPage() {
setEditingTarget(null)
}
}}
onCreated={query.refresh}
onCreated={() => (editingTarget ? query.refresh() : query.reload())}
editing={editingTarget}
/>
@@ -151,7 +157,6 @@ const ClustersPage = memo(function ClustersPage() {
onClose={() => setDetailCluster(null)}
/>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
@@ -161,7 +166,11 @@ const ClustersPage = memo(function ClustersPage() {
loading={deleting}
description={
refCount.dep + refCount.col > 0
? `确定要删除「${deleteTarget?.name}」吗?警告:仍有 ${refCount.dep} 个部署、${refCount.col} 个采集策略引用该集群,删除后它们将无法工作。`
? t('delete.warning', {
name: deleteTarget?.name ?? '',
dep: refCount.dep,
col: refCount.col,
})
: undefined
}
onConfirm={handleDelete}