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,4 +1,8 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
// collectors 域词典(zh/en 自注册模块,副作用导入须先于 useTranslation 使用)
import '@/i18n/locales/zh/collectors'
import '@/i18n/locales/en/collectors'
import {
RadioTower,
Trash2,
@@ -10,19 +14,19 @@ import {
Cloud,
type LucideIcon,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { StatusBadge } from '@/components/StatusBadge'
import { ResourceCard } from '@/components/ResourceCard'
import { CardActionButton } from '@/components/CardActionButton'
import type { Collector } from '@/types'
// 采集器类型元数据(图标 + 类型徽章配色)
const COLLECTOR_TYPE_META: Record<string, { label: string; icon: LucideIcon; color: string }> = {
'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' },
// 采集器类型元数据(图标 + 类型徽章配色);文案存词典键,渲染时经 t 取(未知类型留空)
const COLLECTOR_TYPE_META: Record<string, { labelKey: string; icon: LucideIcon; color: string }> = {
'host-promtail': { labelKey: 'typeLabel.hostPromtail', icon: Monitor, color: 'text-info' },
'k8s-daemonset': { labelKey: 'typeLabel.k8sDaemonset', icon: Cloud, color: 'text-info' },
'k8s-fluentbit-sidecar': { labelKey: 'typeLabel.k8sFluentbitSidecar', icon: Server, color: 'text-info' },
}
const FALLBACK_META = { label: '', icon: RadioTower, color: 'text-muted-foreground' }
const FALLBACK_META = { labelKey: '', icon: RadioTower, color: 'text-muted-foreground' }
interface CollectorCardProps {
collector: Collector
@@ -38,6 +42,7 @@ interface CollectorCardProps {
export const CollectorCard = memo(function CollectorCard({
collector, operating, onInstall, onUninstall, onDelete, onEdit,
}: CollectorCardProps) {
const { t } = useTranslation('collectors')
const meta = COLLECTOR_TYPE_META[collector.type] ?? FALLBACK_META
const Icon = meta.icon
const isBusy = operating === collector.id
@@ -46,31 +51,23 @@ export const CollectorCard = memo(function CollectorCard({
icon={Icon}
iconClassName={meta.color}
title={collector.name}
description={meta.label}
description={meta.labelKey ? t(meta.labelKey) : ''}
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 }] : []),
{ label: t('field.loki'), value: collector.lokiDeployment?.name || t('field.unknown') },
...(collector.cluster ? [{ label: t('field.cluster'), value: collector.cluster.name }] : []),
...(collector.host ? [{ label: t('field.host'), value: `${collector.host.name}(${collector.host.hostIP})` }] : []),
...(collector.namespace ? [{ label: t('field.namespace'), value: collector.namespace }] : []),
...(collector.logPath ? [{ label: t('field.logPath'), value: collector.logPath, title: collector.logPath }] : []),
...(collector.targetDeployment ? [{ label: t('field.target'), 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={() => onInstall(collector.id)} disabled={isBusy}>
<Upload className="size-3 mr-1" />{isBusy ? '处理中...' : '部署'}
</Button>
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onUninstall(collector.id)} disabled={isBusy}>
<Download className="size-3 mr-1" />卸载
</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="size-3.5" />
</Button>
<CardActionButton icon={Pencil} ariaLabel={t('action.editAria', { name: collector.name })} onClick={() => onEdit(collector)} />
<CardActionButton icon={Upload} label={isBusy ? t('common:dialog.processing') : t('action.install')} disabled={isBusy} onClick={() => onInstall(collector.id)} />
<CardActionButton icon={Download} label={t('action.uninstall')} disabled={isBusy} onClick={() => onUninstall(collector.id)} />
<CardActionButton icon={Trash2} ariaLabel={t('action.deleteAria', { name: collector.name })} destructive onClick={() => onDelete(collector)} />
</>
}
/>
@@ -2,6 +2,11 @@ import { memo, useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Trans, useTranslation } from 'react-i18next'
import type { TFunction } from 'i18next'
// collectors 域词典(zh/en 自注册模块,副作用导入须先于 useTranslation 使用)
import '@/i18n/locales/zh/collectors'
import '@/i18n/locales/en/collectors'
import { toast } from 'sonner'
import { clusterApi, collectorApi } from '@/api'
import { Button } from '@/components/ui/button'
@@ -10,25 +15,28 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
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(),
/** 采集的命名空间白名单;空数组 = 全集群 */
targetNamespaces: z.array(z.string()),
/** app 标签取值来源;留空 = 默认 controller_name 四级回退 */
appLabelSource: z.string().trim(),
/** 只采集这些 app(逗号分隔);留空 = 不限 */
appIncludeRaw: z.string().trim(),
})
type Values = z.infer<typeof schema>
// 表单 schema:错误消息经 t 注入(随语言重建,RHF 每次渲染刷新 resolver)
function buildSchema(t: TFunction) {
return z.object({
name: z.string().trim().min(1, t('edit.validation.nameRequired')),
logPath: z.string().trim(),
/** 采集的命名空间白名单;空数组 = 全集群 */
targetNamespaces: z.array(z.string()),
/** app 标签取值来源;留空 = 默认 controller_name 四级回退 */
appLabelSource: z.string().trim(),
/** 只采集这些 app(逗号分隔);留空 = 不限 */
appIncludeRaw: z.string().trim(),
})
}
type Values = z.infer<ReturnType<typeof buildSchema>>
/** app 标签来源的常用取值(可自由填写其它 Pod 标签名) */
/** app 标签来源的常用取值(可自由填写其它 Pod 标签名);文案存词典键,渲染时经 t 取 */
const APP_SOURCE_PRESETS = [
{ value: '', label: '默认:app.kubernetes.io/name → app → controller 名 → pod 名' },
{ value: 'controller_name', label: 'controller_name(Pod 的 ReplicaSet 名,去 hash = Deployment 名)' },
{ value: 'app.kubernetes.io/name', label: 'app.kubernetes.io/name(K8s 标准标签)' },
{ value: 'app', label: 'app(常见简写)' },
{ value: 'k8s.kuboard.cn/name', label: 'k8s.kuboard.cn/name(Kuboard 注入)' },
{ value: '', labelKey: 'edit.appSourcePreset.default' },
{ value: 'controller_name', labelKey: 'edit.appSourcePreset.controllerName' },
{ value: 'app.kubernetes.io/name', labelKey: 'edit.appSourcePreset.k8sAppName' },
{ value: 'app', labelKey: 'edit.appSourcePreset.app' },
{ value: 'k8s.kuboard.cn/name', labelKey: 'edit.appSourcePreset.kuboard' },
]
interface Props {
@@ -38,12 +46,15 @@ interface Props {
}
export const CollectorEditDialog = memo(function CollectorEditDialog({ collector, onOpenChange, onSaved }: Props) {
const { t } = useTranslation('collectors')
const [saving, setSaving] = useState(false)
const [namespaces, setNamespaces] = useState<string[]>([])
const [nsLoading, setNsLoading] = useState(false)
const [nsError, setNsError] = useState('')
const [nsFilter, setNsFilter] = useState('')
// schema 随语言重建(t 变化即语言切换),校验消息即时跟随当前语言
const schema = useMemo(() => buildSchema(t), [t])
const form = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: { name: '', logPath: '', targetNamespaces: [], appLabelSource: '', appIncludeRaw: '' },
@@ -75,10 +86,10 @@ export const CollectorEditDialog = memo(function CollectorEditDialog({ collector
setNsError('')
clusterApi.namespaces(clusterID)
.then((res) => { if (active) setNamespaces(res?.namespaces ?? []) })
.catch((err) => { if (active) setNsError(err instanceof Error ? err.message : '加载命名空间失败') })
.catch((err) => { if (active) setNsError(err instanceof Error ? err.message : t('edit.nsLoadFailed')) })
.finally(() => { if (active) setNsLoading(false) })
return () => { active = false }
}, [collector, isDaemonSet])
}, [collector, isDaemonSet, t])
const filteredNs = useMemo(
() => namespaces.filter((n) => n.toLowerCase().includes(nsFilter.toLowerCase())),
@@ -113,7 +124,7 @@ export const CollectorEditDialog = memo(function CollectorEditDialog({ collector
onOpenChange(false)
onSaved()
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存采集策略失败')
toast.error(err instanceof Error ? err.message : t('toast.saveFailed'))
} finally {
setSaving(false)
}
@@ -122,11 +133,11 @@ export const CollectorEditDialog = memo(function CollectorEditDialog({ collector
return (
<Dialog open={collector !== null} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader><DialogTitle>编辑采集策略</DialogTitle></DialogHeader>
<DialogHeader><DialogTitle>{t('edit.title')}</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>
<FormItem><FormLabel>{t('form.name')}</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
{isDaemonSet && (
@@ -134,29 +145,29 @@ export const CollectorEditDialog = memo(function CollectorEditDialog({ collector
{/* ── 采集命名空间:多选 ── */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>采集命名空间</FormLabel>
<FormLabel>{t('edit.namespaces')}</FormLabel>
<span className="text-xs text-muted-foreground">
{allSelected ? '全集群所有命名空间' : `已选 ${selectedNs.length} 个`}
{allSelected ? t('edit.allNamespaces') : t('edit.selectedCount', { count: selectedNs.length })}
</span>
</div>
{nsError && <p className="text-xs text-destructive">{nsError}</p>}
<div className="rounded-md border">
<label className="flex items-center gap-2 border-b px-3 py-2 text-sm font-medium cursor-pointer hover:bg-muted/50">
<input type="checkbox" checked={allSelected} onChange={setAll} />
所有命名空间(全集群采集)
{t('edit.allCheckbox')}
</label>
<div className="p-2">
<Input
className="mb-2 h-8"
placeholder="搜索命名空间…"
placeholder={t('edit.nsSearch')}
value={nsFilter}
onChange={(e) => setNsFilter(e.target.value)}
/>
<div className="max-h-44 overflow-auto grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
{nsLoading && <p className="p-2 text-xs text-muted-foreground">加载中…</p>}
{nsLoading && <p className="p-2 text-xs text-muted-foreground">{t('edit.loading')}</p>}
{!nsLoading && filteredNs.length === 0 && (
<p className="p-2 text-xs text-muted-foreground">
{namespaces.length === 0 ? '未取到命名空间列表(可手动输入)' : '无匹配'}
{namespaces.length === 0 ? t('edit.nsListEmpty') : t('edit.nsNoMatch')}
</p>
)}
{filteredNs.map((ns) => (
@@ -174,28 +185,32 @@ export const CollectorEditDialog = memo(function CollectorEditDialog({ collector
</div>
</div>
<p className="text-xs text-muted-foreground">
勾「所有命名空间」= 采集全集群;否则只采集勾选的命名空间。
生成 Promtail 的 <code className="font-mono">kubernetes_sd_configs.namespaces</code> 白名单。
<Trans i18nKey="edit.nsHint" t={t} components={{ code: <code className="font-mono" /> }}>
勾「所有命名空间」= 采集全集群;否则只采集勾选的命名空间。
生成 Promtail 的 <code className="font-mono">kubernetes_sd_configs.namespaces</code> 白名单。
</Trans>
</p>
</div>
{/* ── app 标签来源 ── */}
<FormField control={form.control} name="appLabelSource" render={({ field }) => (
<FormItem>
<FormLabel>app 标签取值来源</FormLabel>
<FormLabel>{t('edit.appLabelSource')}</FormLabel>
<FormControl>
<Input list="app-source-presets" placeholder="留空 = 默认(controller 名回退链)" {...field} />
<Input list="app-source-presets" placeholder={t('edit.appSourcePlaceholder')} {...field} />
</FormControl>
<datalist id="app-source-presets">
{APP_SOURCE_PRESETS.filter((p) => p.value).map((p) => (
<option key={p.value} value={p.value}>{p.label}</option>
<option key={p.value} value={p.value}>{t(p.labelKey)}</option>
))}
</datalist>
<FormMessage />
<p className="text-xs text-muted-foreground">
默认按 <code className="font-mono">app.kubernetes.io/name → app → controller 名 → pod 名</code> 四级回退。
若集群有自己的私有规范标签(如 <code className="font-mono">k8s.kuboard.cn/name</code>),
填在这里会作为最高优先级,其余仍兜底。
<Trans i18nKey="edit.appSourceHint" t={t} components={{ code: <code className="font-mono" /> }}>
默认按 <code className="font-mono">app.kubernetes.io/name → app → controller 名 → pod 名</code> 四级回退。
若集群有自己的私有规范标签(如 <code className="font-mono">k8s.kuboard.cn/name</code>),
填在这里会作为最高优先级,其余仍兜底。
</Trans>
</p>
</FormItem>
)} />
@@ -203,14 +218,16 @@ export const CollectorEditDialog = memo(function CollectorEditDialog({ collector
{/* ── app 白名单 ── */}
<FormField control={form.control} name="appIncludeRaw" render={({ field }) => (
<FormItem>
<FormLabel>只采集这些 app(可选)</FormLabel>
<FormLabel>{t('edit.appInclude')}</FormLabel>
<FormControl>
<Input placeholder="留空 = 不限;多个用逗号分隔,如 cex-spot-api, cex-asset-api" {...field} />
<Input placeholder={t('edit.appIncludePlaceholder')} {...field} />
</FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground">
填了之后 Promtail 只保留 app 命中这些值的日志(<code className="font-mono">action: keep</code>),
是控制日志量最直接的手段。值需与上面「app 标签取值来源」解析出的 app 一致。
<Trans i18nKey="edit.appIncludeHint" t={t} components={{ code: <code className="font-mono" /> }}>
填了之后 Promtail 只保留 app 命中这些值的日志(<code className="font-mono">action: keep</code>),
是控制日志量最直接的手段。值需与上面「app 标签取值来源」解析出的 app 一致。
</Trans>
</p>
</FormItem>
)} />
@@ -219,16 +236,16 @@ export const CollectorEditDialog = memo(function CollectorEditDialog({ collector
{supportsPath && <FormField control={form.control} name="logPath" render={({ field }) => (
<FormItem>
<FormLabel>采集日志路径</FormLabel>
<FormLabel>{t('form.logPath')}</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>
<p className="text-xs text-muted-foreground">{t('edit.logPathHint')}</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>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>{t('common:dialog.cancel')}</Button>
<Button type="submit" disabled={saving}>{saving ? t('edit.saving') : t('edit.save')}</Button>
</div>
</form>
</Form>
@@ -1,7 +1,12 @@
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 { useTranslation } from 'react-i18next'
import type { TFunction } from 'i18next'
// collectors 域词典(zh/en 自注册模块,副作用导入须先于 useTranslation 使用)
import '@/i18n/locales/zh/collectors'
import '@/i18n/locales/en/collectors'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
@@ -26,55 +31,64 @@ import {
FormMessage,
} from '@/components/ui/form'
import { clusterApi, collectorApi } from '@/api'
import { statusLabel } from '@/lib/utils'
import { toast } from 'sonner'
import type { Cluster, Host, LokiDeployment } from '@/types'
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: 'Kubernetes Promtail DaemonSet' },
{ value: 'k8s-fluentbit-sidecar', label: 'Kubernetes Fluent Bit Sidecar' },
// 类型下拉选项:文案存词典键,渲染时经 t 取
const COLLECTOR_TYPES: Array<{ value: CollectorType; labelKey: string }> = [
{ value: 'host-promtail', labelKey: 'form.typeOption.hostPromtail' },
{ value: 'k8s-daemonset', labelKey: 'form.typeOption.k8sDaemonset' },
{ value: 'k8s-fluentbit-sidecar', labelKey: 'form.typeOption.k8sFluentbitSidecar' },
]
// 表单 schema:zod 为唯一校验源,消息中文且具体
const collectorFormSchema = z
.object({
name: z.string().min(1, '请输入采集器名称'),
type: z.enum(['host-promtail', 'k8s-daemonset', 'k8s-fluentbit-sidecar']),
lokiDeploymentID: z.string().min(1, '请选择关联的 Loki 部署'),
hostID: z.string(),
clusterID: z.string(),
targetDeployment: z.string(),
namespace: z.string(),
logPath: z.string(),
/** 旧版表单即无录入入口,仅透传(默认空串) */
labels: z.string(),
})
.superRefine((data, ctx) => {
if (data.type === 'host-promtail' && !data.hostID) {
ctx.addIssue({ code: 'custom', path: ['hostID'], message: '请选择目标主机' })
}
if (data.type !== 'host-promtail' && !data.clusterID) {
ctx.addIssue({ code: 'custom', path: ['clusterID'], message: '请选择目标集群' })
}
if (
data.type === 'k8s-fluentbit-sidecar' &&
!data.targetDeployment.trim()
) {
ctx.addIssue({
code: 'custom',
path: ['targetDeployment'],
message: '请填写目标 Deployment 名称',
})
}
if (data.type === 'k8s-fluentbit-sidecar' && !data.namespace.trim()) {
ctx.addIssue({ code: 'custom', path: ['namespace'], message: '请选择命名空间' })
}
})
// 状态文案走 common:status.*;未收录的状态原样展示(与 StatusBadge 的回退一致)
function statusText(t: TFunction, status: string): string {
const key = `common:status.${status}`
const label = t(key)
return label === key ? status : label
}
type CollectorFormValues = z.infer<typeof collectorFormSchema>
// 表单 schema:zod 为唯一校验源;错误消息经 t 注入(随语言重建,RHF 每次渲染刷新 resolver)
function buildCollectorFormSchema(t: TFunction) {
return z
.object({
name: z.string().min(1, t('form.validation.nameRequired')),
type: z.enum(['host-promtail', 'k8s-daemonset', 'k8s-fluentbit-sidecar']),
lokiDeploymentID: z.string().min(1, t('form.validation.lokiRequired')),
hostID: z.string(),
clusterID: z.string(),
targetDeployment: z.string(),
namespace: z.string(),
logPath: z.string(),
/** 旧版表单即无录入入口,仅透传(默认空串) */
labels: z.string(),
})
.superRefine((data, ctx) => {
if (data.type === 'host-promtail' && !data.hostID) {
ctx.addIssue({ code: 'custom', path: ['hostID'], message: t('form.validation.hostRequired') })
}
if (data.type !== 'host-promtail' && !data.clusterID) {
ctx.addIssue({ code: 'custom', path: ['clusterID'], message: t('form.validation.clusterRequired') })
}
if (
data.type === 'k8s-fluentbit-sidecar' &&
!data.targetDeployment.trim()
) {
ctx.addIssue({
code: 'custom',
path: ['targetDeployment'],
message: t('form.validation.targetDeploymentRequired'),
})
}
if (data.type === 'k8s-fluentbit-sidecar' && !data.namespace.trim()) {
ctx.addIssue({ code: 'custom', path: ['namespace'], message: t('form.validation.namespaceRequired') })
}
})
}
type CollectorFormValues = z.infer<ReturnType<typeof buildCollectorFormSchema>>
const DEFAULT_VALUES: CollectorFormValues = {
name: '',
@@ -107,6 +121,7 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
clusters,
hosts,
}: CollectorFormDialogProps) {
const { t } = useTranslation('collectors')
const [submitting, setSubmitting] = useState(false)
const [namespaces, setNamespaces] = useState<string[]>([])
const [workloads, setWorkloads] = useState<Array<{ name: string; replicas: number; readyReplicas: number }>>([])
@@ -119,8 +134,10 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
const [manualNamespace, setManualNamespace] = useState(false)
const [manualDeployment, setManualDeployment] = useState(false)
// schema 随语言重建(t 变化即语言切换),校验消息即时跟随当前语言
const schema = useMemo(() => buildCollectorFormSchema(t), [t])
const form = useForm<CollectorFormValues>({
resolver: zodResolver(collectorFormSchema),
resolver: zodResolver(schema),
defaultValues: DEFAULT_VALUES,
})
const collectorType = form.watch('type')
@@ -149,11 +166,11 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
if (items.includes('default') && !form.getValues('namespace')) form.setValue('namespace', 'default')
})
.catch((err) => {
if (!controller.signal.aborted) setNamespacesError(err instanceof Error ? err.message : '读取命名空间失败')
if (!controller.signal.aborted) setNamespacesError(err instanceof Error ? err.message : t('form.nsLoadFailed'))
})
.finally(() => { if (!controller.signal.aborted) setNamespacesLoading(false) })
return () => controller.abort()
}, [open, isSidecarType, clusterID, form, namespaceReload])
}, [open, isSidecarType, clusterID, form, namespaceReload, t])
useEffect(() => {
if (!open || !isSidecarType || !clusterID || !namespace) return
@@ -165,12 +182,12 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
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 失败')
if (!controller.signal.aborted) setWorkloadsError(err instanceof Error ? err.message : t('form.wlLoadFailed'))
})
.finally(() => { if (!controller.signal.aborted) setWorkloadsLoading(false) })
}, 250)
return () => { clearTimeout(timer); controller.abort() }
}, [open, isSidecarType, clusterID, namespace, workloadReload])
}, [open, isSidecarType, clusterID, namespace, workloadReload, t])
const onSubmit = form.handleSubmit(async (values) => {
setSubmitting(true)
@@ -196,7 +213,7 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
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)
}
@@ -209,7 +226,7 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>添加采集策略</DialogTitle>
<DialogTitle>{t('form.title')}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
@@ -219,7 +236,7 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>名称 *</FormLabel>
<FormLabel>{t('form.name')} *</FormLabel>
<FormControl>
<Input placeholder="app-logs-collector" {...field} />
</FormControl>
@@ -232,16 +249,16 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>采集器类型</FormLabel>
<FormLabel>{t('form.type')}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLLECTOR_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
{COLLECTOR_TYPES.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{t(opt.labelKey)}
</SelectItem>
))}
</SelectContent>
@@ -258,16 +275,17 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
name="lokiDeploymentID"
render={({ field }) => (
<FormItem>
<FormLabel>关联 Loki 部署 *</FormLabel>
<FormLabel>{t('form.lokiDeployment')} *</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="选择已部署的 Loki(推送目标)" />
<SelectValue placeholder={t('form.lokiPlaceholder')} />
</SelectTrigger>
<SelectContent>
{deployments.map((d) => (
<SelectItem key={d.id} value={String(d.id)}>
{d.name}({statusLabel(d.status)})
{d.name}
{t('form.statusParen', { status: statusText(t, d.status) })}
</SelectItem>
))}
</SelectContent>
@@ -284,16 +302,17 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
name="hostID"
render={({ field }) => (
<FormItem>
<FormLabel>目标主机 *</FormLabel>
<FormLabel>{t('form.host')} *</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="选择主机" />
<SelectValue placeholder={t('form.hostPlaceholder')} />
</SelectTrigger>
<SelectContent>
{hosts.map((h) => (
<SelectItem key={h.id} value={String(h.id)}>
{h.name}({h.hostIP})
{h.name}
{t('form.hostParen', { ip: h.hostIP })}
</SelectItem>
))}
</SelectContent>
@@ -310,9 +329,9 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
<FormField
control={form.control}
name="clusterID"
render={({ field }) => (
render={({ field }) => (
<FormItem>
<FormLabel>目标集群 *</FormLabel>
<FormLabel>{t('form.cluster')} *</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={(value) => {
field.onChange(value)
@@ -323,12 +342,13 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
setWorkloads([])
}}>
<SelectTrigger>
<SelectValue placeholder="选择集群" />
<SelectValue placeholder={t('form.clusterPlaceholder')} />
</SelectTrigger>
<SelectContent>
{clusters.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.name}({statusLabel(c.status)})
{c.name}
{t('form.statusParen', { status: statusText(t, c.status) })}
</SelectItem>
))}
</SelectContent>
@@ -344,14 +364,14 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
name="namespace"
render={({ field }) => (
<FormItem>
<FormLabel>命名空间{isSidecarType ? ' *' : ''}</FormLabel>
<FormLabel>{t('form.namespace')}{isSidecarType ? ' *' : ''}</FormLabel>
<FormControl>
{isSidecarType && !manualNamespace ? (
<Select value={field.value} onValueChange={(value) => {
field.onChange(value)
form.setValue('targetDeployment', '')
}} disabled={!clusterID || namespacesLoading}>
<SelectTrigger><SelectValue placeholder={!clusterID ? '先选择集群' : namespacesLoading ? '加载命名空间…' : '选择命名空间'} /></SelectTrigger>
<SelectTrigger><SelectValue placeholder={!clusterID ? t('form.nsPickCluster') : namespacesLoading ? t('form.nsLoading') : t('form.nsPick')} /></SelectTrigger>
<SelectContent>
{namespaces.map((item) => <SelectItem key={item} value={item}>{item}</SelectItem>)}
</SelectContent>
@@ -366,10 +386,10 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
<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 ? '从集群选择' : '手动填写'}
{manualNamespace ? t('form.pickFromCluster') : t('form.manualInput')}
</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>}
{namespacesError && <><span className="text-destructive">{namespacesError}</span><Button type="button" variant="link" size="sm" className="h-auto p-0" onClick={() => setNamespaceReload((value) => value + 1)}>{t('common:error.retry')}</Button></>}
{!namespacesLoading && !namespacesError && clusterID && namespaces.length === 0 && <span className="text-muted-foreground">{t('form.nsEmpty')}</span>}
</div>}
</FormItem>
)}
@@ -380,16 +400,16 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
name="targetDeployment"
render={({ field }) => (
<FormItem>
<FormLabel>目标 Deployment *</FormLabel>
<FormLabel>{t('form.targetDeployment')} *</FormLabel>
<FormControl>
{manualDeployment ? (
<Input placeholder="my-app" {...field} />
) : (
<Select value={field.value} onValueChange={field.onChange} disabled={!namespace || workloadsLoading}>
<SelectTrigger><SelectValue placeholder={!namespace ? '先选择命名空间' : workloadsLoading ? '加载 Deployment…' : '选择 Deployment'} /></SelectTrigger>
<SelectTrigger><SelectValue placeholder={!namespace ? t('form.wlPickNamespace') : workloadsLoading ? t('form.wlLoading') : t('form.wlPick')} /></SelectTrigger>
<SelectContent>
{workloads.map((item) => <SelectItem key={item.name} value={item.name}>
{item.name}(就绪 {item.readyReplicas}/{item.replicas})
{item.name}{t('form.wlReady', { ready: item.readyReplicas, total: item.replicas })}
</SelectItem>)}
</SelectContent>
</Select>
@@ -398,10 +418,10 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
<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 ? '从集群选择' : '手动填写'}
{manualDeployment ? t('form.pickFromCluster') : t('form.manualInput')}
</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>}
{workloadsError && <><span className="text-destructive">{workloadsError}</span><Button type="button" variant="link" size="sm" className="h-auto p-0" onClick={() => setWorkloadReload((value) => value + 1)}>{t('common:error.retry')}</Button></>}
{!workloadsLoading && !workloadsError && namespace && workloads.length === 0 && <span className="text-muted-foreground">{t('form.wlEmpty')}</span>}
</div>
</FormItem>
)}
@@ -416,7 +436,7 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
name="logPath"
render={({ field }) => (
<FormItem>
<FormLabel>采集日志路径</FormLabel>
<FormLabel>{t('form.logPath')}</FormLabel>
<FormControl>
<Input
placeholder={isHostType ? '/var/log/*.log' : '/var/log/app/*.log'}
@@ -426,10 +446,10 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
<FormMessage />
{isHostType && (
<p className="text-xs text-muted-foreground">
支持 glob 通配(如 /var/log/*.log /var/log/syslog);多个路径用逗号分隔
{t('form.logPathHostHint')}
</p>
)}
{isSidecarType && <p className="text-xs text-muted-foreground">填写业务写入的文件路径,如 /var/log/app/*.log。已有日志卷会自动共享;无日志卷时仅在专用 logs 目录创建共享卷。/tmp/test.log 等普通目录不会自动挂载。</p>}
{isSidecarType && <p className="text-xs text-muted-foreground">{t('form.logPathSidecarHint')}</p>}
</FormItem>
)}
/>
@@ -439,13 +459,13 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
name="labels"
render={({ field }) => (
<FormItem>
<FormLabel>自定义标签(JSON,可选)</FormLabel>
<FormLabel>{t('form.labels')}</FormLabel>
<FormControl>
<Input placeholder='{"env": "prod", "team": "ops"}' {...field} />
</FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground">
合法 JSON 时将附加到每条日志的标签,便于在 Loki/Grafana 中筛选;留空则不加
{t('form.labelsHint')}
</p>
</FormItem>
)}
@@ -456,7 +476,7 @@ export const CollectorFormDialog = memo(function CollectorFormDialog({
className="w-full bg-primary hover:bg-primary/90"
disabled={submitting}
>
{submitting ? '创建中...' : '创建并部署'}
{submitting ? t('form.submitting') : t('form.submit')}
</Button>
</form>
</Form>
+22 -20
View File
@@ -1,5 +1,9 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { RadioTower, Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
// collectors 域词典(zh/en 自注册模块,副作用导入须先于 useTranslation 使用)
import '@/i18n/locales/zh/collectors'
import '@/i18n/locales/en/collectors'
import { Button } from '@/components/ui/button'
import { clusterApi, collectorApi, deploymentApi, hostApi } from '@/api'
import { PageContainer } from '@/components/PageContainer'
@@ -21,6 +25,7 @@ import { CollectorEditDialog } from './components/CollectorEditDialog'
* 部署(install)/卸载(uninstall)/删除保留;成功操作静默(列表刷新即反馈)。
*/
const CollectorsPage = memo(function CollectorsPage() {
const { t } = useTranslation('collectors')
const query = useListQuery<Collector>((params, signal) =>
collectorApi.list(params, { signal })
)
@@ -65,12 +70,12 @@ const CollectorsPage = memo(function CollectorsPage() {
setHosts(hostRes.list)
})
.catch((err) => {
toast.error(err instanceof Error ? err.message : '加载 Loki 部署/集群/主机数据失败')
toast.error(err instanceof Error ? err.message : t('toast.loadOptionsFailed'))
})
return () => {
cancelled = true
}
}, [])
}, [t])
const handleAction = useCallback(
async (id: number, action: 'install' | 'uninstall') => {
@@ -80,13 +85,17 @@ const CollectorsPage = memo(function CollectorsPage() {
addTimeout(query.refresh, 3000)
} catch (err) {
toast.error(
err instanceof Error ? err.message : action === 'install' ? '部署采集器失败' : '卸载采集器失败'
err instanceof Error
? err.message
: action === 'install'
? t('toast.installFailed')
: t('toast.uninstallFailed')
)
} finally {
addTimeout(() => setOperating(null), 3000)
}
},
[query, addTimeout]
[query, addTimeout, t]
)
const handleDelete = useCallback(async () => {
@@ -98,23 +107,20 @@ const CollectorsPage = memo(function CollectorsPage() {
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])
return (
<PageContainer
title="日志采集"
description="为主机与集群配置日志采集策略,日志将推送到关联的 Loki"
title={t('page.title')}
description={t('page.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('page.addAction')}
</Button>
}
>
@@ -125,8 +131,8 @@ const CollectorsPage = memo(function CollectorsPage() {
) : query.list.length === 0 ? (
<EmptyState
icon={RadioTower}
title='暂无采集策略'
description="新建一个日志采集器,关联已部署的 Loki"
title={t('empty.title')}
description={t('empty.description')}
/>
) : (
<>
@@ -169,11 +175,7 @@ const CollectorsPage = memo(function CollectorsPage() {
if (!open) setDeleteTarget(null)
}}
name={deleteTarget?.name}
description={
deleteTarget
? `确定要删除「${deleteTarget.name}」吗?删除时将自动卸载采集端,此操作不可恢复。`
: undefined
}
description={deleteTarget ? t('deleteConfirm', { name: deleteTarget.name }) : undefined}
loading={deleting}
onConfirm={handleDelete}
/>