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:
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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user