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

+89 -68
View File
@@ -21,6 +21,7 @@ import { alertsApi, type AlertRule, type AlertDelivery, type NotifyChannel } fro
import { logsApi, type LogSource } from '@/api/logs'
import { toast } from 'sonner'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
interface RuleForm {
name: string
@@ -39,43 +40,37 @@ const DEFAULTS: RuleForm = {
}
interface AlertTemplate {
label: string
desc: string
key: 'nsError' | 'allNsError' | 'ingress5xx' | 'ingress4xx' | 'fatal'
query: string
windowMin: number
threshold: number
cooldownMin: number
}
/** 常用告警模板 — 只做预填,应用后可改 namespace / 阈值 / 窗口 */
/** 常用告警模板 — 只做预填,应用后可改 namespace / 阈值 / 窗口(label/desc 走 i18n) */
const TEMPLATES: AlertTemplate[] = [
{
label: '业务 NS error',
desc: '指定命名空间(默认 astralx)内 error 日志',
key: 'nsError',
query: '{namespace="astralx"} |~ "(?i)error"',
windowMin: 5, threshold: 10, cooldownMin: 30,
},
{
label: '全 NS error > 1000',
desc: '所有命名空间 error 日志总量超过 1000 条',
key: 'allNsError',
query: '{namespace=~".+"} |~ "(?i)error"',
windowMin: 15, threshold: 1000, cooldownMin: 60,
},
{
label: 'Ingress 5xx',
desc: 'ingress-nginx 5xx(需 JSON access log,平台可一键标准化)',
key: 'ingress5xx',
query: '{namespace="ingress-nginx"} | json | status >= 500',
windowMin: 5, threshold: 5, cooldownMin: 15,
},
{
label: 'Ingress 4xx',
desc: 'ingress-nginx 4xx(需 JSON access log)',
key: 'ingress4xx',
query: '{namespace="ingress-nginx"} | json | status >= 400 | status < 500',
windowMin: 5, threshold: 50, cooldownMin: 30,
},
{
label: 'FATAL',
desc: '所有命名空间出现 FATAL 即告警',
key: 'fatal',
query: '{namespace=~".+"} |= "FATAL"',
windowMin: 5, threshold: 0, cooldownMin: 15,
},
@@ -83,6 +78,7 @@ const TEMPLATES: AlertTemplate[] = [
/** 告警规则页:规则 CRUD + 立即检查 + 发送历史 */
const AlertsPage = memo(function AlertsPage() {
const { t } = useTranslation(['alerts', 'common'])
const [rules, setRules] = useState<AlertRule[]>([])
const [sources, setSources] = useState<LogSource[]>([])
const [channels, setChannels] = useState<NotifyChannel[]>([])
@@ -106,9 +102,9 @@ const AlertsPage = memo(function AlertsPage() {
setDeliveries(Array.isArray(d) ? d : [])
setChannels(Array.isArray(ch) ? ch : [])
} catch (err) {
toast.error(err instanceof Error ? err.message : '加载告警数据失败')
toast.error(err instanceof Error ? err.message : t('loadFailed'))
}
}, [])
}, [t])
useEffect(() => {
void refresh()
@@ -130,12 +126,12 @@ const AlertsPage = memo(function AlertsPage() {
setOpen(true)
}
const applyTemplate = (t: AlertTemplate) => {
form.setValue('name', t.label)
form.setValue('query', t.query)
form.setValue('windowMin', String(t.windowMin))
form.setValue('threshold', String(t.threshold))
form.setValue('cooldownMin', String(t.cooldownMin))
const applyTemplate = (tpl: AlertTemplate) => {
form.setValue('name', t(`tpl.${tpl.key}.label`))
form.setValue('query', tpl.query)
form.setValue('windowMin', String(tpl.windowMin))
form.setValue('threshold', String(tpl.threshold))
form.setValue('cooldownMin', String(tpl.cooldownMin))
}
const onSubmit = form.handleSubmit(async (v) => {
@@ -153,7 +149,7 @@ const AlertsPage = memo(function AlertsPage() {
setOpen(false)
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存失败')
toast.error(err instanceof Error ? err.message : t('saveFailed'))
} finally {
setSubmitting(false)
}
@@ -167,7 +163,7 @@ const AlertsPage = memo(function AlertsPage() {
setDeleteTarget(null)
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '删除失败')
toast.error(err instanceof Error ? err.message : t('deleteFailed'))
} finally {
setDeleting(false)
}
@@ -177,10 +173,10 @@ const AlertsPage = memo(function AlertsPage() {
setChecking(true)
try {
await alertsApi.checkNow()
toast.info('已执行一次检查,结果见发送历史')
toast.info(t('checkDone'))
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '检查失败')
toast.error(err instanceof Error ? err.message : t('checkFailed'))
} finally {
setChecking(false)
}
@@ -191,23 +187,23 @@ const AlertsPage = memo(function AlertsPage() {
await alertsApi.updateRule(rule.id, { enabled })
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '切换失败')
toast.error(err instanceof Error ? err.message : t('toggleFailed'))
}
}
return (
<PageContainer
title="日志告警"
description="按 LogQL 计数规则监控日志,超阈值推送飞书卡片(周期 1 分钟)"
title={t('title')}
description={t('subtitle')}
action={
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCheckNow} disabled={checking}>
<Play className="w-4 h-4 mr-1" />
{checking ? '检查中...' : '立即检查'}
{checking ? t('checking') : t('checkNow')}
</Button>
<Button onClick={openCreate}>
<Plus className="w-4 h-4 mr-1" />
新建规则
{t('create')}
</Button>
</div>
}
@@ -215,8 +211,8 @@ const AlertsPage = memo(function AlertsPage() {
{rules.length === 0 ? (
<EmptyState
icon={BellRing}
title="暂无告警规则"
description={'例如:{namespace=~".+"} |= "FATAL",15 分钟内超过 0 条即推送飞书'}
title={t('emptyTitle')}
description={t('emptyDesc')}
/>
) : (
<div className="space-y-3">
@@ -230,16 +226,33 @@ const AlertsPage = memo(function AlertsPage() {
<p className="font-medium">{rule.name}</p>
<p className="text-xs text-muted-foreground truncate font-mono mt-0.5">{rule.query}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{src?.name ?? `数据源 ${rule.sourceId}`} → {(rule.channelIds || []).map((id) => channels.find((c) => c.id === id)?.name || `#${id}`).join('、') || '未关联通道'} · {rule.windowMin} 分钟超过 {rule.threshold} 条触发 · 冷却 {rule.cooldownMin} 分钟
{rule.lastFiredAt && !rule.lastFiredAt.startsWith('0001') && ` · 上次触发 ${new Date(rule.lastFiredAt).toLocaleString()}`}
{src?.name ?? t('sourceFallback', { id: rule.sourceId })} →{' '}
{(rule.channelIds || [])
.map((id) => channels.find((c) => c.id === id)?.name || `#${id}`)
.join(t('channelSep')) || t('noChannels')}{' '}
· {t('ruleMeta', {
window: rule.windowMin,
threshold: rule.threshold,
cooldown: rule.cooldownMin,
})}
{rule.lastFiredAt && !rule.lastFiredAt.startsWith('0001') && ` · ${t('lastFired', {
time: new Date(rule.lastFiredAt).toLocaleString(),
})}`}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" aria-label={`编辑 ${rule.name}`} onClick={() => openEdit(rule)}>
<Button
variant="outline"
size="sm"
aria-label={t('editAria', { name: rule.name })}
onClick={() => openEdit(rule)}
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="outline" size="sm" aria-label={`删除 ${rule.name}`}
variant="outline"
size="sm"
aria-label={t('deleteAria', { name: rule.name })}
className="text-destructive hover:bg-destructive/10"
onClick={() => setDeleteTarget(rule)}
>
@@ -257,7 +270,7 @@ const AlertsPage = memo(function AlertsPage() {
{deliveries.length > 0 && (
<Card className="border-border">
<CardContent className="p-4">
<h2 className="text-sm font-semibold mb-3">发送历史(最近 50 条)</h2>
<h2 className="text-sm font-semibold mb-3">{t('historyTitle')}</h2>
<div className="divide-y divide-border text-sm">
{deliveries.map((d) => (
<div key={d.id} className="py-2 flex items-center gap-3 flex-wrap">
@@ -268,10 +281,16 @@ const AlertsPage = memo(function AlertsPage() {
: 'bg-destructive/10 text-destructive'
}`}
>
{d.status === 'sent' ? '已发送' : d.status === 'cooldown' ? '冷却跳过' : '失败'}
{d.status === 'sent'
? t('delivery.sent')
: d.status === 'cooldown'
? t('delivery.cooldown')
: t('delivery.failed')}
</span>
<span className="font-medium">{d.ruleName}</span>
<span className="text-muted-foreground text-xs">{d.count} 条命中</span>
<span className="text-muted-foreground text-xs">
{t('delivery.hits', { count: d.count })}
</span>
{d.reason && <span className="text-destructive text-xs truncate flex-1">{d.reason}</span>}
<span className="text-muted-foreground text-xs ml-auto">{new Date(d.createdAt).toLocaleString()}</span>
</div>
@@ -282,34 +301,36 @@ const AlertsPage = memo(function AlertsPage() {
)}
<p className="text-xs text-muted-foreground">
通知未配置?去
<Link to="/settings" className="text-primary mx-1">系统设置</Link>
配置飞书 Webhook
{t('notifyHintPre')}
<Link to="/settings" className="text-primary mx-1">{t('common:menu.settings')}</Link>
{t('notifyHintPost')}
</p>
{/* 规则弹窗 */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{editing ? `编辑规则 · ${editing.name}` : '新建告警规则'}</DialogTitle>
<DialogTitle>
{editing ? t('editTitle', { name: editing.name }) : t('createTitle')}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
{!editing && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">快捷模板(点击预填,可再修改)</p>
<p className="text-xs font-medium text-muted-foreground">{t('templates')}</p>
<div className="flex flex-wrap gap-2">
{TEMPLATES.map((t) => (
{TEMPLATES.map((tpl) => (
<Button
key={t.label}
key={tpl.key}
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
title={`${t.desc}\n${t.query}`}
onClick={() => applyTemplate(t)}
title={`${t(`tpl.${tpl.key}.desc`)}\n${tpl.query}`}
onClick={() => applyTemplate(tpl)}
>
{t.label}
{t(`tpl.${tpl.key}.label`)}
</Button>
))}
</div>
@@ -318,16 +339,16 @@ const AlertsPage = memo(function AlertsPage() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField control={form.control} name="name" render={({ field }) => (
<FormItem>
<FormLabel>规则名称 *</FormLabel>
<FormControl><Input placeholder="应用致命异常" {...field} /></FormControl>
<FormLabel>{t('form.name')}</FormLabel>
<FormControl><Input placeholder={t('form.namePlaceholder')} {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="sourceId" render={({ field }) => (
<FormItem>
<FormLabel>数据源 *</FormLabel>
<FormLabel>{t('form.source')}</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl><SelectTrigger><SelectValue placeholder="选择数据源" /></SelectTrigger></FormControl>
<FormControl><SelectTrigger><SelectValue placeholder={t('form.sourcePlaceholder')} /></SelectTrigger></FormControl>
<SelectContent>
{sources.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
@@ -340,21 +361,21 @@ const AlertsPage = memo(function AlertsPage() {
</div>
<FormField control={form.control} name="query" render={({ field }) => (
<FormItem>
<FormLabel>LogQL 查询 *</FormLabel>
<FormLabel>{t('form.query')}</FormLabel>
<FormControl>
<Input className="font-mono" placeholder='{namespace="log"} |= "FATAL"' {...field} />
</FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground">
{'常用:{app="xxx"} |= "FATAL" / {namespace=~".+"} |~ "(?i)error" / {app="x"} |= "OutOfMemoryError"'}
{t('form.queryHint')}
</p>
</FormItem>
)} />
<FormField control={form.control} name="channelIds" render={({ field }) => (
<FormItem>
<FormLabel>通知通道(可多选,扇出到多个告警群)</FormLabel>
<FormField control={form.control} name="channelIds" render={({ field }) => (
<FormItem>
<FormLabel>{t('form.channels')}</FormLabel>
<div className="grid grid-cols-2 gap-2 rounded-md border bg-background p-3 max-h-40 overflow-auto">
{channels.length === 0 && <p className="text-xs text-muted-foreground col-span-2">请先在「系统设置 → 告警通知」创建通道</p>}
{channels.length === 0 && <p className="text-xs text-muted-foreground col-span-2">{t('form.noChannels')}</p>}
{channels.map((ch) => {
const checked = field.value.includes(String(ch.id))
return (
@@ -369,7 +390,7 @@ const AlertsPage = memo(function AlertsPage() {
className="accent-primary"
/>
{ch.name}
{!ch.enabled && <span className="text-xs text-muted-foreground">(禁用)</span>}
{!ch.enabled && <span className="text-xs text-muted-foreground">{t('form.channelDisabled')}</span>}
</label>
)
})}
@@ -380,22 +401,22 @@ const AlertsPage = memo(function AlertsPage() {
<div className="grid grid-cols-3 gap-4">
<FormField control={form.control} name="windowMin" render={({ field }) => (
<FormItem>
<FormLabel>统计窗口(分)</FormLabel>
<FormLabel>{t('form.window')}</FormLabel>
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="threshold" render={({ field }) => (
<FormItem>
<FormLabel>触发阈值(条)</FormLabel>
<FormLabel>{t('form.threshold')}</FormLabel>
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground">0 = 出现即告警</p>
<p className="text-xs text-muted-foreground">{t('form.thresholdHint')}</p>
</FormItem>
)} />
<FormField control={form.control} name="cooldownMin" render={({ field }) => (
<FormItem>
<FormLabel>冷却(分)</FormLabel>
<FormLabel>{t('form.cooldown')}</FormLabel>
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
</FormItem>
@@ -404,13 +425,13 @@ const AlertsPage = memo(function AlertsPage() {
<FormField control={form.control} name="enabled" render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl>
<FormLabel>启用规则</FormLabel>
<FormLabel>{t('form.enabled')}</FormLabel>
</FormItem>
)} />
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>取消</Button>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>{t('common:dialog.cancel')}</Button>
<Button type="submit" disabled={submitting}>
{submitting ? '保存中...' : editing ? '保存修改' : '创建规则'}
{submitting ? t('saving') : editing ? t('save') : t('submitCreate')}
</Button>
</div>
</form>
@@ -422,7 +443,7 @@ const AlertsPage = memo(function AlertsPage() {
open={deleteTarget !== null}
onOpenChange={(o) => { if (!o) setDeleteTarget(null) }}
name={deleteTarget?.name}
title="确认删除告警规则"
title={t('confirmDeleteTitle')}
loading={deleting}
onConfirm={handleDelete}
/>
@@ -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}
@@ -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}
/>
@@ -1,13 +1,20 @@
import { memo } from 'react'
import { useNavigate } from 'react-router'
import { Rocket } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { EmptyState } from '@/components/EmptyState'
import { StatusIcon } from '@/components/StatusBadge'
import { getStatusColorClass } from '@/components/status-config'
import { statusLabel } from '@/lib/utils'
import type { LokiDeployment } from '@/types'
// 状态标签走 common.status.*;未收录的状态原样展示(与 StatusBadge 口径一致)
function statusLabelOf(t: (key: string, opts?: { ns?: string }) => string, status: string): string {
const key = `status.${status}`
const label = t(key, { ns: 'common' })
return label === key ? status : label
}
/** 最近部署卡片 — 展示最近 5 条部署,点击跳转部署页 */
export const RecentDeploymentsCard = memo(function RecentDeploymentsCard({
deployments,
@@ -16,18 +23,19 @@ export const RecentDeploymentsCard = memo(function RecentDeploymentsCard({
deployments: LokiDeployment[]
onViewAll: () => void
}) {
const { t } = useTranslation('dashboard')
const navigate = useNavigate()
return (
<Card className="border-border">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">最近部署</CardTitle>
<CardTitle className="text-base">{t('recent.title')}</CardTitle>
<button
onClick={onViewAll}
className="text-xs text-primary hover:text-primary/80 font-medium"
>
查看全部
{t('recent.viewAll')}
</button>
</div>
</CardHeader>
@@ -36,8 +44,8 @@ export const RecentDeploymentsCard = memo(function RecentDeploymentsCard({
{deployments.length === 0 ? (
<EmptyState
icon={Rocket}
title="暂无部署"
description="创建你的第一个 Loki 部署"
title={t('recent.emptyTitle')}
description={t('recent.emptyDesc')}
/>
) : (
deployments.map((d) => (
@@ -62,7 +70,7 @@ export const RecentDeploymentsCard = memo(function RecentDeploymentsCard({
d.status
)}`}
>
{statusLabel(d.status)}
{statusLabelOf(t, d.status)}
</span>
</div>
))
@@ -1,7 +1,7 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { getStatusColorClass } from '@/components/status-config'
import { statusLabel } from '@/lib/utils'
// 用具体结构类型而非泛型:memo 包裹泛型组件会导致 TS 无法从 props 推断 T
interface ResourceItem {
@@ -22,6 +22,13 @@ interface ResourceListCardProps {
getItemExtra?: (item: ResourceItem) => string | undefined
}
// 状态标签走 common.status.*;未收录的状态原样展示(与 StatusBadge 口径一致)
function statusLabelOf(t: (key: string, opts?: { ns?: string }) => string, status: string): string {
const key = `status.${status}`
const label = t(key, { ns: 'common' })
return label === key ? status : label
}
/** 资源列表卡片 — 集群/主机快捷列表(仪表盘) */
export const ResourceListCard = memo(function ResourceListCard({
title,
@@ -33,6 +40,7 @@ export const ResourceListCard = memo(function ResourceListCard({
getItemStatus,
getItemExtra,
}: ResourceListCardProps) {
const { t } = useTranslation('dashboard')
return (
<Card className="border-border">
<CardHeader className="pb-3">
@@ -45,14 +53,16 @@ export const ResourceListCard = memo(function ResourceListCard({
onClick={onManage}
className="text-xs text-primary hover:text-primary/80 font-medium"
>
管理
{t('list.manage')}
</button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{items.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">暂无{title}</p>
<p className="text-sm text-muted-foreground text-center py-4">
{t('list.empty', { title })}
</p>
) : (
items.map((item) => (
<div
@@ -73,7 +83,7 @@ export const ResourceListCard = memo(function ResourceListCard({
getItemStatus(item)
)}`}
>
{statusLabel(getItemStatus(item))}
{statusLabelOf(t, getItemStatus(item))}
</span>
</div>
))
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import i18n from '@/i18n'
import { clusterApi, deploymentApi, hostApi } from '@/api'
import type { Cluster, DashboardStats, Host, LokiDeployment } from '@/types'
@@ -47,7 +48,7 @@ export function useDashboardStats(intervalMs: number = REFRESH_INTERVAL) {
setError(null)
} catch (err) {
if (version !== versionRef.current) return
setError(err instanceof Error ? err.message : '加载仪表盘数据失败')
setError(err instanceof Error ? err.message : i18n.t('dashboard:loadFailed'))
} finally {
if (version === versionRef.current) {
setLoading(false)
+26 -20
View File
@@ -2,6 +2,8 @@ import { memo, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router'
import { Server, Monitor, Activity, TrendingUp, CheckCircle, Rocket, Wand2 } from 'lucide-react'
import { Link } from 'react-router'
import { useTranslation } from 'react-i18next'
import '@/i18n/domains'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { PageContainer } from '@/components/PageContainer'
@@ -13,10 +15,11 @@ import { RecentDeploymentsCard } from './components/RecentDeploymentsCard'
import { ResourceListCard } from './components/ResourceListCard'
import type { DashboardStats } from '@/types'
// 状态概览三色指标;标签走 common.status.*(渲染处翻译)
const STATUS_ITEMS = [
{ key: 'running', label: '运行中', colorClass: 'text-primary' },
{ key: 'failed', label: '失败', colorClass: 'text-destructive' },
{ key: 'pending', label: '等待中', colorClass: 'text-warning' },
{ key: 'running', colorClass: 'text-primary' },
{ key: 'failed', colorClass: 'text-destructive' },
{ key: 'pending', colorClass: 'text-warning' },
] as const
/**
@@ -24,6 +27,7 @@ const STATUS_ITEMS = [
* 数据由 useDashboardStats 轮询(页面隐藏暂停);首次失败渲染错误态而非空数据。
*/
const DashboardPage = memo(function DashboardPage() {
const { t } = useTranslation('dashboard')
const { data, loading, error, refresh } = useDashboardStats()
const navigate = useNavigate()
@@ -39,7 +43,7 @@ const DashboardPage = memo(function DashboardPage() {
const cards: Array<React.ReactNode> = [
<StatCard
key="deployments"
label="部署总数"
label={t('stat.totalDeployments')}
value={stats.totalDeployments}
icon={Rocket}
iconColor="text-primary"
@@ -48,12 +52,12 @@ const DashboardPage = memo(function DashboardPage() {
icon: TrendingUp,
color: 'text-primary',
textColor: 'text-primary',
text: `${stats.running} 个运行中`,
text: t('stat.runningCount', { count: stats.running }),
}}
/>,
<StatCard
key="clusters"
label="集群"
label={t('stat.clusters')}
value={stats.clusterCount}
icon={Server}
iconColor="text-info"
@@ -62,12 +66,12 @@ const DashboardPage = memo(function DashboardPage() {
icon: CheckCircle,
color: 'text-primary',
textColor: 'text-muted-foreground',
text: '已接入',
text: t('stat.connected'),
}}
/>,
<StatCard
key="hosts"
label="主机"
label={t('stat.hosts')}
value={stats.hostCount}
icon={Monitor}
iconColor="text-purple-600"
@@ -76,12 +80,12 @@ const DashboardPage = memo(function DashboardPage() {
icon: CheckCircle,
color: 'text-primary',
textColor: 'text-muted-foreground',
text: 'SSH 就绪',
text: t('stat.sshReady'),
}}
/>,
]
return cards
}, [stats])
}, [stats, t])
if (loading) {
return <LoadingSpinner className="h-64" />
@@ -93,7 +97,7 @@ const DashboardPage = memo(function DashboardPage() {
return (
<PageContainer>
{/* 快速开始引导:尚无任何部署时置顶(状态推导引导,opsx-sec 范式) */}
{/* 快速开始引导:尚无任何部署时置顶(状态推导式引导) */}
{(data?.stats.totalDeployments ?? 0) === 0 && (
<Card className="border-primary/30 bg-primary/5">
<CardContent className="p-5 flex items-center justify-between flex-wrap gap-3">
@@ -102,15 +106,15 @@ const DashboardPage = memo(function DashboardPage() {
<Wand2 className="w-5 h-5 text-primary" />
</div>
<div>
<p className="font-medium">三步开始收集日志</p>
<p className="font-medium">{t('quickStart.title')}</p>
<p className="text-xs text-muted-foreground mt-0.5">
环境与目标 → 存储 → 部署 Loki 与 Grafana,最后添加采集策略
{t('quickStart.subtitle')}
</p>
</div>
</div>
<Button asChild className="bg-primary hover:bg-primary/90">
<Link to="/wizard">
进入部署向导
{t('quickStart.cta')}
<Wand2 className="w-4 h-4 ml-1" />
</Link>
</Button>
@@ -127,14 +131,16 @@ const DashboardPage = memo(function DashboardPage() {
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">状态</p>
<p className="text-sm text-muted-foreground">{t('stat.status')}</p>
<div className="flex gap-3 mt-1">
{STATUS_ITEMS.map((item) => (
<div key={item.key} className="text-center">
<p className={`text-lg font-bold tabular-nums ${item.colorClass}`}>
{(stats?.[item.key as keyof DashboardStats] as number) || 0}
</p>
<p className="text-xs text-muted-foreground">{item.label}</p>
<p className="text-xs text-muted-foreground">
{t(`status.${item.key}`, { ns: 'common' })}
</p>
</div>
))}
</div>
@@ -156,7 +162,7 @@ const DashboardPage = memo(function DashboardPage() {
<div className="space-y-4">
<ResourceListCard
title="集群"
title={t('stat.clusters')}
icon={<Server className="w-4 h-4 text-info" />}
items={(data?.clusters ?? []).slice(0, 3)}
onManage={navigateTo('/clusters')}
@@ -165,11 +171,11 @@ const DashboardPage = memo(function DashboardPage() {
getItemStatus={(c) => c.status}
/>
<ResourceListCard
title="主机"
icon={<Monitor className="w-4 h-4 text-purple-500" />}
title={t('stat.hosts')}
icon={<Monitor className="w-4 h-4 text-info" />}
items={(data?.hosts ?? []).slice(0, 3)}
onManage={navigateTo('/hosts')}
getItemIcon={() => <Monitor className="w-4 h-4 text-purple-500" />}
getItemIcon={() => <Monitor className="w-4 h-4 text-info" />}
getItemLabel={(h) => h.name}
getItemStatus={(h) => h.status}
getItemExtra={(h) => h.hostIP}
@@ -1,18 +1,12 @@
import { memo, useEffect, useState } from 'react'
import { Play, Square, RotateCw, Trash2, Terminal, Pencil, Rocket } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useTranslation } from 'react-i18next'
import { Play, Square, RotateCw, Trash2, Terminal, Settings, Rocket } from 'lucide-react'
import { StatusBadge } from '@/components/StatusBadge'
import { deploymentApi } from '@/api'
import { ResourceCard } from '@/components/ResourceCard'
import { CardActionButton } from '@/components/CardActionButton'
import type { LokiDeployment } from '@/types'
/** 部署目标展示:优先集群,其次主机 */
const getTargetLabel = (d: LokiDeployment) => {
if (d.cluster) return `集群:${d.cluster.name}`
if (d.host) return `主机:${d.host.name}`
return '未知'
}
interface DeploymentCardProps {
deployment: LokiDeployment
/** 部署/重启进行中的部署 ID(按钮 loading 态) */
@@ -36,6 +30,7 @@ export const DeploymentCard = memo(function DeploymentCard({
onViewLogs,
onEditRetention,
}: DeploymentCardProps) {
const { t } = useTranslation('deployments')
// Loki 就绪探测(运行中时经数据源代理 /ready)
const [ready, setReady] = useState<boolean | null>(null)
useEffect(() => {
@@ -58,6 +53,13 @@ export const DeploymentCard = memo(function DeploymentCard({
const isBusy = operating === d.id
const canDeploy = d.status === 'pending' || d.status === 'failed' || d.status === 'stopped'
// 部署目标展示:优先集群,其次主机
const targetLabel = d.cluster
? t('card.targetCluster', { name: d.cluster.name })
: d.host
? t('card.targetHost', { name: d.host.name })
: t('card.targetUnknown')
return (
<ResourceCard
icon={Rocket}
@@ -67,22 +69,22 @@ export const DeploymentCard = memo(function DeploymentCard({
d.status === 'running' && ready !== null ? (
<span
className={`w-2 h-2 rounded-full shrink-0 ${ready ? 'bg-success' : 'bg-destructive'}`}
title={ready ? 'Loki 就绪' : 'Loki 未就绪'}
title={ready ? t('card.ready') : t('card.notReady')}
/>
) : undefined
}
badges={<StatusBadge status={d.status} />}
description={d.description || d.uuid}
fields={[
{ label: '方式', value: d.mode === 'host-docker' ? '主机 Docker' : d.mode === 'k8s-yaml' ? 'Kubernetes' : d.mode },
{ label: '版本', value: d.version },
{ label: '目标', value: getTargetLabel(d) },
{ label: t('card.mode'), value: d.mode === 'host-docker' ? t('card.modeDocker') : d.mode === 'k8s-yaml' ? t('card.modeK8s') : d.mode },
{ label: t('card.version'), value: d.version },
{ label: t('card.target'), value: targetLabel },
...(d.mode !== 'host-docker' ? [
{ label: '命名空间', value: d.namespace || 'log' },
{ label: '副本', value: `${d.replicas} · 保留 ${d.retentionDays || 30} 天` },
] : [{ label: '保留', value: `${d.retentionDays || 30} 天` }]),
{ label: '存储', value: d.storageConfig?.name || '本地' },
// 不再展示 endpoint:K8s 模式下它是 http://loki.log.svc.cluster.local:3100
{ label: t('card.namespace'), value: d.namespace || 'log' },
{ label: t('card.replicas'), value: t('card.replicasValue', { replicas: d.replicas, days: d.retentionDays || 30 }) },
] : [{ label: t('card.retention'), value: t('card.retentionValue', { days: d.retentionDays || 30 }) }]),
{ label: t('card.storage'), value: d.storageConfig?.name || t('card.localStorage') },
// 不再展示 endpoint:Kubernetes 模式下它是 http://loki.log.svc.cluster.local:3100
// 这类集群内地址,浏览器点不开,纯属噪音
...(d.grafanaEnabled && d.grafanaEndpoint ? [{ label: 'Grafana', value: (
<a href={d.grafanaEndpoint} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline" title={`${d.grafanaEndpoint}(admin/admin)`}>
@@ -93,38 +95,30 @@ export const DeploymentCard = memo(function DeploymentCard({
message={
d.statusMessage ||
(d.status === 'running'
? '提示:到「日志查询 → 数据源」关联 Loki 后即可查询;跨集群/公网访问需先为 Loki 配置 Ingress 域名。'
? t('card.runningHint')
: undefined)
}
actions={
<>
<Button variant="outline" size="sm" className="text-xs" onClick={() => onEditRetention(d)} disabled={isDeploying || isBusy}>
<Pencil className="size-3 mr-1" />配置
</Button>
<CardActionButton icon={Settings} label={t('card.configure')} disabled={isDeploying || isBusy} onClick={() => onEditRetention(d)} />
{canDeploy ? (
<Button variant="outline" size="sm" className="text-xs bg-success/10 text-primary hover:bg-success/20 border-success/30" onClick={() => onDeploy(d.id)} disabled={isBusy}>
<Play className="size-3 mr-1" />{isBusy ? '部署中...' : '部署'}
</Button>
<CardActionButton
icon={Play}
label={isBusy ? t('card.deploying') : t('card.deploy')}
className="bg-success/10 text-success hover:bg-success/20 border-success/30"
disabled={isBusy}
onClick={() => onDeploy(d.id)}
/>
) : isDeploying ? (
<Button variant="outline" size="sm" className="text-xs" disabled>
<RotateCw className="size-3 mr-1 animate-spin" />部署中...
</Button>
<CardActionButton icon={RotateCw} label={t('card.deploying')} spinning disabled />
) : (
<>
<Button variant="outline" size="sm" className="text-xs" onClick={() => onStop(d.id)}>
<Square className="size-3 mr-1" />停止
</Button>
<Button variant="outline" size="sm" className="text-xs" onClick={() => onRestart(d.id)} disabled={isBusy}>
<RotateCw className={`size-3 mr-1 ${isBusy ? 'animate-spin' : ''}`} />重启
</Button>
<CardActionButton icon={Square} label={t('card.stop')} onClick={() => onStop(d.id)} />
<CardActionButton icon={RotateCw} label={t('card.restart')} spinning={isBusy} disabled={isBusy} onClick={() => onRestart(d.id)} />
</>
)}
<Button variant="outline" size="sm" className="text-xs" onClick={() => onViewLogs(d)}>
<Terminal className="size-3 mr-1" />日志
</Button>
<Button variant="outline" size="sm" aria-label={`删除部署 ${d.name}`} className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() => onDelete(d)}>
<Trash2 className="size-3.5" />
</Button>
<CardActionButton icon={Terminal} label={t('card.logs')} onClick={() => onViewLogs(d)} />
<CardActionButton icon={Trash2} ariaLabel={t('card.deleteAria', { name: d.name })} destructive onClick={() => onDelete(d)} />
</>
}
/>
@@ -1,4 +1,5 @@
import { memo, useEffect, useState } from 'react'
import { memo, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Button } from '@/components/ui/button'
@@ -27,13 +28,12 @@ import {
FormMessage,
} from '@/components/ui/form'
import { deploymentApi } from '@/api'
import { statusLabel } from '@/lib/utils'
import { toast } from 'sonner'
import type { Cluster, Host, StorageConfig } from '@/types'
import {
FALLBACK_VERSIONS,
isK8sMode,
deploymentFormSchema,
buildDeploymentFormSchema,
DEFAULT_VALUES,
type DeploymentFormValues,
} from '../form-schema'
@@ -57,6 +57,9 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
hosts,
storages,
}: DeploymentFormDialogProps) {
const { t } = useTranslation('deployments')
// 集群状态标签走 common.status.*(替代 lib/utils 的硬编码中文映射)
const { t: tCommon } = useTranslation('common')
const [submitting, setSubmitting] = useState(false)
// 版本列表来自后端 LokiVersionList,失败回退兜底
const [versions, setVersions] = useState<string[]>(FALLBACK_VERSIONS)
@@ -76,6 +79,8 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
}
}, [])
// schema 随语言重建(校验消息跟随语言切换)
const deploymentFormSchema = useMemo(() => buildDeploymentFormSchema(t), [t])
const form = useForm<DeploymentFormValues>({
resolver: zodResolver(deploymentFormSchema),
defaultValues: DEFAULT_VALUES,
@@ -114,7 +119,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
onOpenChange(false)
onCreated()
} catch (err) {
toast.error(err instanceof Error ? err.message : '创建部署失败')
toast.error(err instanceof Error ? err.message : t('form.errCreate'))
} finally {
setSubmitting(false)
}
@@ -123,11 +128,18 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
const showK8s = isK8sMode(mode)
const showHost = mode === 'host-docker'
// 状态标签走 common.status.*;未收录的状态原样展示(数据层保留英文状态值)
const clusterStatusLabel = (s: string) => {
const key = `status.${s}`
const label = tCommon(key)
return label === key ? s : label
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>创建 Loki 部署</DialogTitle>
<DialogTitle>{t('form.title')}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
@@ -136,7 +148,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>部署名称 *</FormLabel>
<FormLabel>{t('form.name')}</FormLabel>
<FormControl>
<Input placeholder="production-loki" {...field} />
</FormControl>
@@ -149,9 +161,9 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>描述</FormLabel>
<FormLabel>{t('form.description')}</FormLabel>
<FormControl>
<Textarea placeholder="生产环境 Loki 日志服务" {...field} />
<Textarea placeholder={t('form.descriptionPlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -164,7 +176,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
name="version"
render={({ field }) => (
<FormItem>
<FormLabel>版本</FormLabel>
<FormLabel>{t('form.version')}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
@@ -188,7 +200,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>部署方式</FormLabel>
<FormLabel>{t('form.mode')}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={(next) => {
field.onChange(next)
@@ -198,8 +210,8 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="k8s-yaml">Kubernetes</SelectItem>
<SelectItem value="host-docker">主机 Docker</SelectItem>
<SelectItem value="k8s-yaml">{t('form.modeK8s')}</SelectItem>
<SelectItem value="host-docker">{t('form.modeDocker')}</SelectItem>
</SelectContent>
</Select>
</FormControl>
@@ -216,16 +228,16 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
name="clusterID"
render={({ field }) => (
<FormItem>
<FormLabel>目标集群 *</FormLabel>
<FormLabel>{t('form.cluster')}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<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}({clusterStatusLabel(c.status)})
</SelectItem>
))}
</SelectContent>
@@ -240,7 +252,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
name="namespace"
render={({ field }) => (
<FormItem>
<FormLabel>命名空间 *</FormLabel>
<FormLabel>{t('form.namespace')}</FormLabel>
<FormControl>
<Input placeholder="log" {...field} />
</FormControl>
@@ -257,11 +269,11 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
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) => (
@@ -278,13 +290,13 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
/>
<div className="grid grid-cols-2 gap-4">
<FormField control={form.control} name="listenIP" render={({ field }) => (
<FormItem><FormLabel>监听内网 IP</FormLabel><FormControl><Input placeholder={hosts.find((h) => String(h.id) === hostID)?.hostIP || '选择主机后默认使用主机 IP'} {...field} /></FormControl><FormMessage /></FormItem>
<FormItem><FormLabel>{t('form.listenIP')}</FormLabel><FormControl><Input placeholder={hosts.find((h) => String(h.id) === hostID)?.hostIP || t('form.listenIPPlaceholder')} {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="listenPort" render={({ field }) => (
<FormItem><FormLabel>访问端口</FormLabel><FormControl><Input type="number" min={1} max={65535} {...field} /></FormControl><FormMessage /></FormItem>
<FormItem><FormLabel>{t('form.listenPort')}</FormLabel><FormControl><Input type="number" min={1} max={65535} {...field} /></FormControl><FormMessage /></FormItem>
)} />
</div>
<p className="text-xs text-muted-foreground">通过 SSH 在主机启动 Docker 容器,配置和数据保存在主机本地。</p>
<p className="text-xs text-muted-foreground">{t('form.dockerHint')}</p>
</>)}
<FormField
@@ -292,14 +304,14 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
name="storageConfigID"
render={({ field }) => (
<FormItem>
<FormLabel>存储配置</FormLabel>
<FormLabel>{t('form.storage')}</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="请选择存储(可选)" />
<SelectValue placeholder={t('form.storagePlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">本地存储(默认)</SelectItem>
<SelectItem value="0">{t('form.localStorage')}</SelectItem>
{storages.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}({s.storageType})
@@ -319,7 +331,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
name="replicas"
render={({ field }) => (
<FormItem>
<FormLabel>副本数</FormLabel>
<FormLabel>{t('form.replicas')}</FormLabel>
<FormControl>
<Input type="number" min={1} max={10} {...field} />
</FormControl>
@@ -332,7 +344,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
name="retentionDays"
render={({ field }) => (
<FormItem>
<FormLabel>保留天数</FormLabel>
<FormLabel>{t('form.retentionDays')}</FormLabel>
<FormControl>
<Input type="number" min={1} {...field} />
</FormControl>
@@ -345,7 +357,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
name="storageSize"
render={({ field }) => (
<FormItem>
<FormLabel>存储容量</FormLabel>
<FormLabel>{t('form.storageSize')}</FormLabel>
<FormControl>
<Input placeholder="20Gi" {...field} />
</FormControl>
@@ -374,7 +386,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
name="resourcesMem"
render={({ field }) => (
<FormItem>
<FormLabel>内存</FormLabel>
<FormLabel>{t('form.memory')}</FormLabel>
<FormControl>
<Input placeholder="2Gi" {...field} />
</FormControl>
@@ -395,7 +407,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
checked={field.value}
onCheckedChange={field.onChange}
/>
<FormLabel>启用多租户鉴权</FormLabel>
<FormLabel>{t('form.authEnabled')}</FormLabel>
</div>
</FormControl>
<FormMessage />
@@ -408,7 +420,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
className="w-full bg-primary hover:bg-primary/90"
disabled={submitting}
>
{submitting ? '创建中...' : '创建部署'}
{submitting ? t('form.creating') : t('form.create')}
</Button>
</form>
</Form>
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { Terminal } from 'lucide-react'
import {
@@ -21,6 +22,7 @@ export const LogViewerDialog = memo(function LogViewerDialog({
deployment,
onClose,
}: LogViewerDialogProps) {
const { t } = useTranslation('deployments')
const [logs, setLogs] = useState<DeploymentLog[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -37,7 +39,7 @@ export const LogViewerDialog = memo(function LogViewerDialog({
setLogs(res.list ?? [])
} catch (err) {
if (current !== versionRef.current) return
setError(err instanceof Error ? err.message : '加载部署日志失败')
setError(err instanceof Error ? err.message : t('logs.errLoad'))
} finally {
if (current === versionRef.current) {
setLoading(false)
@@ -58,7 +60,7 @@ export const LogViewerDialog = memo(function LogViewerDialog({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-primary" />
部署日志 - {deployment?.name}
{t('logs.title', { name: deployment?.name ?? '' })}
</DialogTitle>
</DialogHeader>
@@ -79,10 +81,11 @@ export const LogViewerDialog = memo(function LogViewerDialog({
/** 终端风格日志列表 */
const LogView = memo(function LogView({ logs }: { logs: DeploymentLog[] }) {
const { t } = useTranslation('deployments')
return (
<div className="bg-card dark:bg-card text-green-400 font-mono text-xs p-4 rounded-lg h-96 overflow-auto">
{logs.length === 0 ? (
<p className="text-muted-foreground">暂无日志...</p>
<p className="text-muted-foreground">{t('logs.empty')}</p>
) : (
logs.map((log) => (
<div
@@ -1,4 +1,5 @@
import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -8,9 +9,10 @@ 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 { Textarea } from '@/components/ui/textarea'
import i18n from '@/i18n'
import type { LokiDeployment } from '@/types'
const schema = z.object({ yaml: z.string().trim().min(1, '请输入 YAML 配置') })
const schema = z.object({ yaml: z.string().trim().min(1, i18n.t('deployments:retention.yamlRequired')) })
type Values = z.infer<typeof schema>
interface Props {
@@ -19,14 +21,14 @@ interface Props {
onSaved: () => void
}
function configLocation(deployment: LokiDeployment) {
if (deployment.mode === 'k8s-helm') return 'Helm values.yaml → loki.limits_config.retention_period'
if (deployment.mode === 'host-binary') return '/etc/loki/loki.yaml → limits_config.retention_period'
if (deployment.mode === 'host-docker') return `主机 /etc/lokideploy/loki-${deployment.id}/loki.yaml → limits_config.retention_period`
return `ConfigMap ${deployment.namespace || 'log'}/loki-config → loki.yaml`
function configLocation(deployment: LokiDeployment, t: (k: string, o?: Record<string, unknown>) => string) {
if (deployment.mode === 'host-binary') return t('retention.locationHostBinary')
if (deployment.mode === 'host-docker') return t('retention.locationHostDocker', { id: deployment.id })
return t('retention.locationK8s', { namespace: deployment.namespace || 'log' })
}
export const RetentionDialog = memo(function RetentionDialog({ deployment, onClose, onSaved }: Props) {
const { t } = useTranslation('deployments')
const [saving, setSaving] = useState(false)
const [liveConfig, setLiveConfig] = useState<{ location: string; yaml: string } | null>(null)
const [configLoading, setConfigLoading] = useState(false)
@@ -51,7 +53,7 @@ export const RetentionDialog = memo(function RetentionDialog({ deployment, onClo
try {
setLiveConfig(await deploymentApi.liveConfig(deployment.id))
} catch (err) {
setConfigError(err instanceof Error ? err.message : '读取运行配置失败')
setConfigError(err instanceof Error ? err.message : t('retention.errLoad'))
} finally {
setConfigLoading(false)
}
@@ -64,9 +66,9 @@ export const RetentionDialog = memo(function RetentionDialog({ deployment, onClo
const result = await deploymentApi.updateRetention(deployment.id, yaml, apply)
onSaved()
onClose()
if (apply) toast.info(`已保存 ${result.retentionDays} 天,Loki 正在重新部署`)
if (apply) toast.info(t('retention.savedApplied', { days: result.retentionDays }))
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存 Loki 配置失败')
toast.error(err instanceof Error ? err.message : t('retention.errSave'))
onSaved()
} finally {
setSaving(false)
@@ -77,23 +79,22 @@ export const RetentionDialog = memo(function RetentionDialog({ deployment, onClo
<Dialog open={deployment !== null} onOpenChange={(open) => { if (!open && !saving) onClose() }}>
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>编辑 Loki 配置 · {deployment?.name}</DialogTitle>
<DialogTitle>{deployment ? t('retention.title', { name: deployment.name }) : ''}</DialogTitle>
</DialogHeader>
<div className="space-y-1 text-sm text-muted-foreground">
<p>配置位置:{deployment ? configLocation(deployment) : ''}</p>
<p>保留策略由 Loki compactor 执行;MinIO 只负责存储日志对象。</p>
<p>{t('retention.locationLabel')}{deployment ? configLocation(deployment, t) : ''}</p>
<p>{t('retention.compactorNote')}</p>
</div>
<div className="space-y-2 rounded-md border border-border p-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">运行中的完整 loki.yaml</p>
<p className="text-xs text-muted-foreground">从部署目标读取,存储凭证已遮盖;下方保留天数仍单独编辑。</p>
<p className="text-sm font-medium">{t('retention.liveTitle')}</p>
<p className="text-xs text-muted-foreground">{t('retention.liveHint')}</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={loadConfig} disabled={configLoading || deployment?.mode === 'k8s-helm'}>
{configLoading ? '读取中…' : liveConfig ? '刷新配置' : '查看完整配置'}
<Button type="button" variant="outline" size="sm" onClick={loadConfig} disabled={configLoading}>
{configLoading ? t('retention.loading') : liveConfig ? t('retention.refresh') : t('retention.view')}
</Button>
</div>
{deployment?.mode === 'k8s-helm' && <p className="text-xs text-muted-foreground">Helm 模式暂不支持读取运行配置。</p>}
{configError && <p className="text-sm text-destructive">{configError}</p>}
{liveConfig && (
<div>
@@ -103,7 +104,7 @@ export const RetentionDialog = memo(function RetentionDialog({ deployment, onClo
)}
</div>
<div className="rounded-md border border-border bg-muted/40 p-3 text-xs">
<p className="mb-1 font-medium text-foreground">由部署模板生成</p>
<p className="mb-1 font-medium text-foreground">{t('retention.templateNote')}</p>
<pre className="text-muted-foreground">{'compactor:\n retention_enabled: true\n retention_delete_delay: 2h'}</pre>
</div>
<Form {...form}>
@@ -113,28 +114,24 @@ export const RetentionDialog = memo(function RetentionDialog({ deployment, onClo
name="yaml"
render={({ field }) => (
<FormItem>
<FormLabel>保留配置 YAML</FormLabel>
<FormLabel>{t('retention.yamlLabel')}</FormLabel>
<FormControl>
<Textarea rows={4} spellCheck={false} className="font-mono text-sm" {...field} />
</FormControl>
<p className="text-xs text-muted-foreground">
编辑 limits_config.retention_period,例如 90d 或 2160h。此处只接受这一项配置。
</p>
<p className="text-xs text-muted-foreground">{t('retention.yamlHint')}</p>
<FormMessage />
</FormItem>
)}
/>
<p className="text-sm text-muted-foreground">
{deployment?.status === 'running'
? '仅保存会更新部署记录;保存并应用会重新部署 Loki,查询可能短暂中断。'
: '保存后在下次部署时生效。'}
{deployment?.status === 'running' ? t('retention.applyHintRunning') : t('retention.applyHintIdle')}
</p>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>取消</Button>
<Button type="submit" variant="outline" disabled={saving}>仅保存</Button>
<Button type="button" variant="outline" onClick={onClose} disabled={saving}>{t('retention.cancel')}</Button>
<Button type="submit" variant="outline" disabled={saving}>{t('retention.saveOnly')}</Button>
{deployment?.status === 'running' && (
<Button type="button" onClick={save(true)} disabled={saving}>
{saving ? '处理中…' : '保存并应用'}
{saving ? t('retention.processing') : t('retention.saveApply')}
</Button>
)}
</div>
+48 -45
View File
@@ -1,7 +1,9 @@
import { z } from 'zod'
import type { TFunction } from 'i18next'
// 部署表单 schema 与常量 — zod 为唯一校验源,消息中文且具体
// (纯数据/纯逻辑抽离,见开发标准 07 章页面内聚 utils 范式)
// 部署表单 schema 与常量 — zod 为唯一校验源,校验消息经 t 取词典
// (纯数据/纯逻辑抽离,见开发标准 07 章页面内聚 utils 范式;
// schema 为工厂函数:t 随语言切换变化,组件内 useMemo 按 t 重建)
// 版本下拉兜底值;实际列表运行时从 GET /deployments/versions 拉取
export const FALLBACK_VERSIONS = ['3.0.0', '2.9.8', '2.9.7', '2.9.6']
@@ -26,51 +28,52 @@ const parseGiB = (v: string): number | null => {
return n
}
export const deploymentFormSchema = z
.object({
name: z.string().min(1, '请输入部署名称'),
description: z.string(),
version: z.string().min(1, '请选择 Loki 版本'),
mode: z.enum(['k8s-yaml', 'host-docker']),
clusterID: z.string(),
hostID: z.string(),
listenIP: z.string(),
listenPort: z.string(),
namespace: z.string(),
storageConfigID: z.string(),
authEnabled: z.boolean(),
retentionDays: z
.string()
.refine((v) => isPositiveInt(v, 1), '保留天数需为不小于 1 的整数'),
replicas: z
.string()
.refine((v) => isPositiveInt(v, 1, 10), '副本数需为 1-10 之间的整数'),
resourcesCPU: z.string().min(1, '请填写 CPU 配额(如 500m)'),
resourcesMem: z.string().min(1, '请填写内存配额(如 512Mi)'),
storageSize: z.string().min(1, '请填写存储容量(如 20Gi)'),
})
.superRefine((data, ctx) => {
if (isK8sMode(data.mode) && !data.clusterID) {
ctx.addIssue({ code: 'custom', path: ['clusterID'], message: '请选择目标集群' })
}
if (data.mode === 'host-docker' && !data.hostID) {
ctx.addIssue({ code: 'custom', path: ['hostID'], message: '请选择目标主机' })
}
if (data.mode === 'host-docker' && !isPositiveInt(data.listenPort, 1, 65535)) {
ctx.addIssue({ code: 'custom', path: ['listenPort'], message: '端口须在 1-65535 之间' })
}
if (isK8sMode(data.mode) && !data.namespace.trim()) {
ctx.addIssue({ code: 'custom', path: ['namespace'], message: '请填写命名空间' })
}
if (isK8sMode(data.mode)) {
const gi = parseGiB(data.storageSize)
if (gi === null || gi < 20) {
ctx.addIssue({ code: 'custom', path: ['storageSize'], message: '阿里云 ESSD 最小 20Gi,请填 20Gi 或以上' })
export const buildDeploymentFormSchema = (t: TFunction) =>
z
.object({
name: z.string().min(1, t('form.nameRequired')),
description: z.string(),
version: z.string().min(1, t('form.versionRequired')),
mode: z.enum(['k8s-yaml', 'host-docker']),
clusterID: z.string(),
hostID: z.string(),
listenIP: z.string(),
listenPort: z.string(),
namespace: z.string(),
storageConfigID: z.string(),
authEnabled: z.boolean(),
retentionDays: z
.string()
.refine((v) => isPositiveInt(v, 1), t('form.retentionInvalid')),
replicas: z
.string()
.refine((v) => isPositiveInt(v, 1, 10), t('form.replicasInvalid')),
resourcesCPU: z.string().min(1, t('form.cpuRequired')),
resourcesMem: z.string().min(1, t('form.memRequired')),
storageSize: z.string().min(1, t('form.storageSizeRequired')),
})
.superRefine((data, ctx) => {
if (isK8sMode(data.mode) && !data.clusterID) {
ctx.addIssue({ code: 'custom', path: ['clusterID'], message: t('form.clusterRequired') })
}
}
})
if (data.mode === 'host-docker' && !data.hostID) {
ctx.addIssue({ code: 'custom', path: ['hostID'], message: t('form.hostRequired') })
}
if (data.mode === 'host-docker' && !isPositiveInt(data.listenPort, 1, 65535)) {
ctx.addIssue({ code: 'custom', path: ['listenPort'], message: t('form.portInvalid') })
}
if (isK8sMode(data.mode) && !data.namespace.trim()) {
ctx.addIssue({ code: 'custom', path: ['namespace'], message: t('form.namespaceRequired') })
}
if (isK8sMode(data.mode)) {
const gi = parseGiB(data.storageSize)
if (gi === null || gi < 20) {
ctx.addIssue({ code: 'custom', path: ['storageSize'], message: t('form.storageSizeMin') })
}
}
})
export type DeploymentFormValues = z.infer<typeof deploymentFormSchema>
export type DeploymentFormValues = z.infer<ReturnType<typeof buildDeploymentFormSchema>>
export const DEFAULT_VALUES: DeploymentFormValues = {
name: '',
+23 -19
View File
@@ -1,4 +1,5 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Rocket, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { clusterApi, deploymentApi, hostApi, storageApi } from '@/api'
@@ -22,6 +23,7 @@ import { LogViewerDialog } from './components/LogViewerDialog'
* 卡片/表单/日志查看均为域内子组件。成功操作静默(列表刷新即反馈)。
*/
const DeploymentsPage = memo(function DeploymentsPage() {
const { t } = useTranslation('deployments')
const query = useListQuery<LokiDeployment>((params, signal) =>
deploymentApi.list(params, { signal })
)
@@ -67,12 +69,12 @@ const DeploymentsPage = memo(function DeploymentsPage() {
setStorages(storageRes.list)
})
.catch((err) => {
toast.error(err instanceof Error ? err.message : '加载集群/主机/存储数据失败')
toast.error(err instanceof Error ? err.message : t('page.errLoadRefs'))
})
return () => {
cancelled = true
}
}, [])
}, [t])
const handleDeploy = useCallback(
async (id: number) => {
@@ -81,12 +83,12 @@ const DeploymentsPage = memo(function DeploymentsPage() {
await deploymentApi.deploy(id)
addTimeout(query.refresh, 2000)
} catch (err) {
toast.error(err instanceof Error ? err.message : '部署失败')
toast.error(err instanceof Error ? err.message : t('page.errDeploy'))
} finally {
addTimeout(() => setOperating(null), 5000)
}
},
[query, addTimeout]
[query, addTimeout, t]
)
const handleStop = useCallback(
@@ -95,10 +97,10 @@ const DeploymentsPage = memo(function DeploymentsPage() {
await deploymentApi.stop(id)
query.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '停止失败')
toast.error(err instanceof Error ? err.message : t('page.errStop'))
}
},
[query]
[query, t]
)
const handleRestart = useCallback(
@@ -108,12 +110,12 @@ const DeploymentsPage = memo(function DeploymentsPage() {
await deploymentApi.restart(id)
addTimeout(query.refresh, 3000)
} catch (err) {
toast.error(err instanceof Error ? err.message : '重启失败')
toast.error(err instanceof Error ? err.message : t('page.errRestart'))
} finally {
addTimeout(() => setOperating(null), 5000)
}
},
[query, addTimeout]
[query, addTimeout, t]
)
const handleDelete = useCallback(async () => {
@@ -125,23 +127,20 @@ const DeploymentsPage = memo(function DeploymentsPage() {
setDeleteTarget(null)
query.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '删除部署失败')
toast.error(err instanceof Error ? err.message : t('page.errDelete'))
} finally {
setDeleting(false)
}
}, [deleteTarget, query])
}, [deleteTarget, query, t])
return (
<PageContainer
title="部署Loki"
description="部署与管理 Grafana 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.create')}
</Button>
}
>
@@ -152,8 +151,8 @@ const DeploymentsPage = memo(function DeploymentsPage() {
) : query.list.length === 0 ? (
<EmptyState
icon={Rocket}
title="暂无部署"
description="创建你的第一个 Loki 部署"
title={t('page.emptyTitle')}
description={t('page.emptyDescription')}
/>
) : (
<>
@@ -208,6 +207,11 @@ const DeploymentsPage = memo(function DeploymentsPage() {
}}
name={deleteTarget?.name}
loading={deleting}
description={
deleteTarget?.mode === 'host-docker'
? t('page.deleteDockerDesc', { name: deleteTarget?.name })
: t('page.deleteRecordDesc', { name: deleteTarget?.name })
}
onConfirm={handleDelete}
/>
</PageContainer>
@@ -1,15 +1,15 @@
import { memo } from 'react'
import { Monitor, RefreshCw, Trash2, Eye, Terminal, Pencil } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Monitor, RefreshCw, Trash2, Eye, Pencil } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { StatusBadge } from '@/components/StatusBadge'
import { ResourceCard } from '@/components/ResourceCard'
import { CardActionButton } from '@/components/CardActionButton'
import type { Host } from '@/types'
interface HostCardProps {
host: Host
validating: number | null
onViewInfo: (h: Host) => void
onOpenTerminal: (h: Host) => void
onValidate: (id: number) => void
onDelete: (h: Host) => void
onEdit: (h: Host) => void
@@ -17,37 +17,33 @@ interface HostCardProps {
/** 主机卡片 — 统一 ResourceCard 样式 */
export const HostCard = memo(function HostCard({
host, validating, onViewInfo, onOpenTerminal, onValidate, onDelete, onEdit,
host, validating, onViewInfo, onValidate, onDelete, onEdit,
}: HostCardProps) {
const { t } = useTranslation('hosts')
return (
<ResourceCard
icon={Monitor}
iconClassName="text-purple-500"
iconClassName="text-info"
title={host.name}
badges={<StatusBadge status={host.status} />}
fields={[
{ label: '地址', value: `${host.hostIP}:${host.port}` },
{ label: '用户', value: host.user },
{ label: '认证', value: host.authType === 'key' ? 'SSH 密钥' : '密码' },
{ label: '系统', value: `${host.os || '未知'} / ${host.arch || '未知'}` },
{ label: t('fields.address'), value: `${host.hostIP}:${host.port}` },
{ label: t('fields.user'), value: host.user },
{ label: t('fields.auth'), value: host.authType === 'key' ? t('fields.authKey') : t('fields.authPassword') },
{ label: t('fields.os'), value: `${host.os || t('fields.unknown')} / ${host.arch || t('fields.unknown')}` },
]}
actions={
<>
<Button variant="outline" size="sm" aria-label={'编辑主机 ' + host.name} onClick={() => onEdit(host)}>
<Pencil className="size-3.5" />
</Button>
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onViewInfo(host)}>
<Eye className="size-3 mr-1" />详情
</Button>
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onOpenTerminal(host)}>
<Terminal className="size-3 mr-1" />终端
</Button>
<Button variant="outline" size="sm" aria-label={`验证主机 ${host.name}`} disabled={validating === host.id} onClick={() => onValidate(host.id)}>
<RefreshCw className={`size-3 ${validating === host.id ? 'animate-spin' : ''}`} />
</Button>
<Button variant="outline" size="sm" aria-label={`删除主机 ${host.name}`} className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() => onDelete(host)}>
<Trash2 className="size-3.5" />
</Button>
<CardActionButton icon={Pencil} ariaLabel={t('actions.editAria', { name: host.name })} onClick={() => onEdit(host)} />
<CardActionButton icon={Eye} label={t('actions.detail')} onClick={() => onViewInfo(host)} />
<CardActionButton
icon={RefreshCw}
ariaLabel={t('actions.validateAria', { name: host.name })}
spinning={validating === host.id}
disabled={validating === host.id}
onClick={() => onValidate(host.id)}
/>
<CardActionButton icon={Trash2} ariaLabel={t('actions.deleteAria', { name: host.name })} destructive onClick={() => onDelete(host)} />
</>
}
/>
@@ -1,5 +1,6 @@
import { memo, useEffect, useState } from 'react'
import { Monitor } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
Dialog,
DialogContent,
@@ -25,6 +26,7 @@ export const HostDetailDialog = memo(function HostDetailDialog({
host,
onClose,
}: HostDetailDialogProps) {
const { t } = useTranslation('hosts')
const [info, setInfo] = useState<HostInfo | null>(null)
const [error, setError] = useState<string | null>(null)
@@ -37,19 +39,19 @@ export const HostDetailDialog = memo(function HostDetailDialog({
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
}
}, [host])
}, [host, t])
return (
<Dialog open={host !== null} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Monitor className="w-5 h-5 text-purple-500" />
<Monitor className="w-5 h-5 text-info" />
{host?.name}
</DialogTitle>
</DialogHeader>
@@ -68,20 +70,21 @@ export const HostDetailDialog = memo(function HostDetailDialog({
/** 主机详情内容:资源使用率 + 基本信息 */
const HostDetail = memo(function HostDetail({ info }: { info: HostInfo }) {
const { t } = useTranslation('hosts')
return (
<div className="space-y-5">
{/* 资源使用率 */}
<div>
<h4 className="text-sm font-semibold mb-3">资源使用率</h4>
<h4 className="text-sm font-semibold mb-3">{t('detail.usage')}</h4>
<div className="space-y-3">
<UsageBar label="CPU" value={info.cpuUsage} />
<UsageBar
label="内存"
label={t('detail.memory')}
value={info.memUsage}
detail={`${info.memUsed} / ${info.memTotal}`}
/>
<UsageBar
label="磁盘"
label={t('detail.disk')}
value={info.diskUsage}
detail={`${info.diskUsed} / ${info.diskTotal}`}
/>
@@ -90,14 +93,14 @@ const HostDetail = memo(function HostDetail({ info }: { info: HostInfo }) {
{/* 基本信息 */}
<div>
<h4 className="text-sm font-semibold mb-3">基本信息</h4>
<h4 className="text-sm font-semibold mb-3">{t('detail.basicInfo')}</h4>
<div className="grid grid-cols-2 gap-3">
<InfoCard label="主机名" value={info.hostname || '未知'} />
<InfoCard label="系统" value={`${info.os || '未知'} ${info.version}`} />
<InfoCard label="架构" value={info.arch || '未知'} />
<InfoCard label="内核" value={info.kernel || '未知'} />
<InfoCard label="CPU 核数" value={info.cpu || '未知'} />
<InfoCard label="运行时长" value={info.uptime || '未知'} />
<InfoCard label={t('detail.hostname')} value={info.hostname || t('detail.unknown')} />
<InfoCard label={t('detail.os')} value={`${info.os || t('detail.unknown')} ${info.version}`} />
<InfoCard label={t('detail.arch')} value={info.arch || t('detail.unknown')} />
<InfoCard label={t('detail.kernel')} value={info.kernel || t('detail.unknown')} />
<InfoCard label={t('detail.cpuCores')} value={info.cpu || t('detail.unknown')} />
<InfoCard label={t('detail.uptime')} value={info.uptime || t('detail.unknown')} />
</div>
</div>
</div>
@@ -114,15 +117,16 @@ const UsageBar = memo(function UsageBar({
value: number
detail?: string
}) {
const { t } = useTranslation('hosts')
return (
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium">
{value.toFixed(1)}% {detail && `(${detail})`}
{value.toFixed(1)}%{detail ? ` ${t('detail.usageDetail', { detail })}` : ''}
</span>
</div>
<div className="h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className={`h-full ${usageColor(value)} rounded-full transition-all`}
style={{ width: `${Math.min(value, 100)}%` }}
@@ -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 { useTranslation } from 'react-i18next'
import { Key, Lock } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -24,43 +26,47 @@ import { hostApi } from '@/api'
import { PasswordInput } from '@/components/PasswordInput'
import { toast } from 'sonner'
// 表单 schema:zod 为唯一校验源,消息中文且具体
// (port 在表单内为字符串、提交时转数字,避免 z.coerce 输入/输出类型分叉导致 RHF 泛型不兼容)
const hostFormSchema = z
.object({
name: z.string().min(1, '请输入主机名称'),
hostIP: z.string().min(1, '请输入主机 IP'),
port: z
.string()
.min(1, '请输入端口')
.regex(/^\d+$/, '端口必须为数字')
.refine((v) => Number(v) >= 1 && Number(v) <= 65535, '端口必须在 1-65535 之间'),
user: z.string().min(1, '请输入用户名'),
authType: z.enum(['password', 'key']),
password: z.string().optional(),
privateKey: z.string().optional(),
keyPhrase: z.string().optional(),
labels: z.string().optional(),
})
.superRefine((data, ctx) => {
if (EDITING_REF.current) return
if (data.authType === 'password' && !data.password?.trim()) {
ctx.addIssue({
code: 'custom',
path: ['password'],
message: '请填写 SSH 登录密码',
})
}
if (data.authType === 'key' && !data.privateKey?.trim()) {
ctx.addIssue({
code: 'custom',
path: ['privateKey'],
message: '请粘贴 SSH 私钥内容',
})
}
})
/** 添加 SSH 主机弹窗 — react-hook-form + zod,自包含表单逻辑,页面只传开关与回调 */
const EDITING_REF = { current: false as boolean | null }
type HostFormValues = z.infer<typeof hostFormSchema>
// 表单 schema:zod 为唯一校验源,消息随语言(由组件内以 t 构造)
// (port 在表单内为字符串、提交时转数字,避免 z.coerce 输入/输出类型分叉导致 RHF 泛型不兼容)
const makeHostFormSchema = (t: TFunction<'hosts'>) =>
z
.object({
name: z.string().min(1, t('form.validation.nameRequired')),
hostIP: z.string().min(1, t('form.validation.hostIpRequired')),
port: z
.string()
.min(1, t('form.validation.portRequired'))
.regex(/^\d+$/, t('form.validation.portNumeric'))
.refine((v) => Number(v) >= 1 && Number(v) <= 65535, t('form.validation.portRange')),
user: z.string().min(1, t('form.validation.userRequired')),
authType: z.enum(['password', 'key']),
password: z.string().optional(),
privateKey: z.string().optional(),
keyPhrase: z.string().optional(),
labels: z.string().optional(),
})
.superRefine((data, ctx) => {
if (EDITING_REF.current) return
if (data.authType === 'password' && !data.password?.trim()) {
ctx.addIssue({
code: 'custom',
path: ['password'],
message: t('form.validation.passwordRequired'),
})
}
if (data.authType === 'key' && !data.privateKey?.trim()) {
ctx.addIssue({
code: 'custom',
path: ['privateKey'],
message: t('form.validation.privateKeyRequired'),
})
}
})
type HostFormValues = z.infer<ReturnType<typeof makeHostFormSchema>>
const DEFAULT_VALUES: HostFormValues = {
name: '',
@@ -82,19 +88,18 @@ interface HostFormDialogProps {
editing?: import('@/types').Host | null
}
/** 添加 SSH 主机弹窗 — react-hook-form + zod,自包含表单逻辑,页面只传开关与回调 */
const EDITING_REF = { current: false as boolean | null }
export const HostFormDialog = memo(function HostFormDialog({
open,
onOpenChange,
onCreated,
editing,
}: HostFormDialogProps) {
const { t } = useTranslation('hosts')
const [submitting, setSubmitting] = useState(false)
const schema = useMemo(() => makeHostFormSchema(t), [t])
const form = useForm<HostFormValues>({
resolver: zodResolver(hostFormSchema),
resolver: zodResolver(schema),
defaultValues: DEFAULT_VALUES,
})
const authType = form.watch('authType')
@@ -132,7 +137,7 @@ export const HostFormDialog = memo(function HostFormDialog({
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)
}
@@ -142,7 +147,13 @@ export const HostFormDialog = memo(function HostFormDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? `编辑主机${editing.name ? ` · ${editing.name}` : ''}` : '添加 SSH 主机'}</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">
@@ -152,7 +163,7 @@ export const HostFormDialog = memo(function HostFormDialog({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>名称 *</FormLabel>
<FormLabel>{t('form.nameLabel')}</FormLabel>
<FormControl>
<Input placeholder="loki-server-1" {...field} />
</FormControl>
@@ -165,7 +176,7 @@ export const HostFormDialog = memo(function HostFormDialog({
name="hostIP"
render={({ field }) => (
<FormItem>
<FormLabel>主机 IP *</FormLabel>
<FormLabel>{t('form.hostIpLabel')}</FormLabel>
<FormControl>
<Input placeholder="192.168.1.100" {...field} />
</FormControl>
@@ -180,7 +191,7 @@ export const HostFormDialog = memo(function HostFormDialog({
name="port"
render={({ field }) => (
<FormItem>
<FormLabel>端口</FormLabel>
<FormLabel>{t('form.portLabel')}</FormLabel>
<FormControl>
<Input type="number" placeholder="22" {...field} />
</FormControl>
@@ -193,7 +204,7 @@ export const HostFormDialog = memo(function HostFormDialog({
name="user"
render={({ field }) => (
<FormItem>
<FormLabel>用户名 *</FormLabel>
<FormLabel>{t('form.userLabel')}</FormLabel>
<FormControl>
<Input placeholder="root" {...field} />
</FormControl>
@@ -208,7 +219,7 @@ export const HostFormDialog = memo(function HostFormDialog({
name="authType"
render={({ field }) => (
<FormItem>
<FormLabel>认证方式</FormLabel>
<FormLabel>{t('form.authTypeLabel')}</FormLabel>
<FormControl>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
@@ -220,7 +231,7 @@ export const HostFormDialog = memo(function HostFormDialog({
onChange={() => field.onChange('password')}
/>
<Lock className="w-4 h-4" />
<span className="text-sm">密码</span>
<span className="text-sm">{t('fields.authPassword')}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
@@ -231,7 +242,7 @@ export const HostFormDialog = memo(function HostFormDialog({
onChange={() => field.onChange('key')}
/>
<Key className="w-4 h-4" />
<span className="text-sm">SSH 密钥</span>
<span className="text-sm">{t('fields.authKey')}</span>
</label>
</div>
</FormControl>
@@ -246,9 +257,11 @@ export const HostFormDialog = memo(function HostFormDialog({
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>密码{editing ? '(留空保持不变)' : ' *'}</FormLabel>
<FormLabel>
{editing ? t('form.passwordLabelEdit') : t('form.passwordLabel')}
</FormLabel>
<FormControl>
<PasswordInput placeholder="SSH 登录密码" {...field} />
<PasswordInput placeholder={t('form.passwordPlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -261,7 +274,9 @@ export const HostFormDialog = memo(function HostFormDialog({
name="privateKey"
render={({ field }) => (
<FormItem>
<FormLabel>私钥{editing ? '(留空保持不变)' : ' *'}</FormLabel>
<FormLabel>
{editing ? t('form.privateKeyLabelEdit') : t('form.privateKeyLabel')}
</FormLabel>
<FormControl>
<Textarea
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
@@ -278,9 +293,9 @@ export const HostFormDialog = memo(function HostFormDialog({
name="keyPhrase"
render={({ field }) => (
<FormItem>
<FormLabel>密钥口令(可选)</FormLabel>
<FormLabel>{t('form.keyPhraseLabel')}</FormLabel>
<FormControl>
<PasswordInput placeholder="无口令则留空" {...field} />
<PasswordInput placeholder={t('form.keyPhrasePlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -294,7 +309,7 @@ export const HostFormDialog = memo(function HostFormDialog({
name="labels"
render={({ field }) => (
<FormItem>
<FormLabel>标签(JSON)</FormLabel>
<FormLabel>{t('form.labelsLabel')}</FormLabel>
<FormControl>
<Input placeholder='["loki", "logging"]' {...field} />
</FormControl>
@@ -308,7 +323,13 @@ export const HostFormDialog = memo(function HostFormDialog({
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,86 +0,0 @@
import { memo, useEffect, useState } from 'react'
import { Terminal } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { hostApi } from '@/api'
import type { Host } from '@/types'
interface TerminalDialogProps {
host: Host | null
onClose: () => void
}
/** 命令执行终端弹窗 — 打开即初始化欢迎输出,命令执行与输出渲染自包含 */
export const TerminalDialog = memo(function TerminalDialog({
host,
onClose,
}: TerminalDialogProps) {
const [command, setCommand] = useState('')
const [output, setOutput] = useState('')
const [executing, setExecuting] = useState(false)
// 切换主机/重新打开时重置终端
useEffect(() => {
if (host) {
setCommand('')
setOutput(`已连接到 ${host.name} (${host.hostIP})\n输入命令后按"执行"...\n`)
}
}, [host])
const executeCommand = async () => {
if (!host || !command.trim() || executing) return
setExecuting(true)
setOutput((prev) => prev + `\n$ ${command}\n`)
try {
const res = await hostApi.execute(host.id, command)
setOutput((prev) => prev + (res.stdout || '') + (res.stderr || '') + '\n')
} catch (err) {
const message = err instanceof Error ? err.message : '命令执行失败'
setOutput((prev) => prev + `错误:${message}\n`)
} finally {
setExecuting(false)
setCommand('')
}
}
return (
<Dialog open={host !== null} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Terminal className="w-5 h-5 text-primary" />
终端 - {host?.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="bg-card dark:bg-card text-green-400 font-mono text-xs p-4 rounded-lg h-80 overflow-auto whitespace-pre-wrap">
{output}
</div>
<div className="flex gap-2">
<Input
value={command}
onChange={(e) => setCommand(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && executeCommand()}
placeholder="输入命令..."
className="flex-1 font-mono text-sm"
disabled={executing}
/>
<Button
onClick={executeCommand}
disabled={executing || !command.trim()}
className="bg-primary hover:bg-primary/90"
>
{executing ? '执行中...' : '执行'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
})
+26 -24
View File
@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react'
import { Monitor, Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { hostApi, deploymentApi, collectorApi } from '@/api'
import { PageContainer } from '@/components/PageContainer'
@@ -11,23 +12,29 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'
import { toast } from 'sonner'
import { useListQuery } from '@/hooks/useListQuery'
import type { Host } from '@/types'
import i18n from '@/i18n'
import zhHosts from '@/i18n/locales/zh/hosts'
import enHosts from '@/i18n/locales/en/hosts'
import { HostCard } from './components/HostCard'
import { HostFormDialog } from './components/HostFormDialog'
import { HostDetailDialog } from './components/HostDetailDialog'
import { TerminalDialog } from './components/TerminalDialog'
// 注册本域命名空间(i18n/index.ts 只负责 common,域词典就近注册;页面为懒加载,渲染前必然已注册)
i18n.addResourceBundle('zh', 'hosts', zhHosts)
i18n.addResourceBundle('en', 'hosts', enHosts)
/**
* 主机管理页 — 只做编排:数据走 useListQuery(取消+版本号),
* 卡片/表单/详情/终端/删除确认均为域内子组件。成功操作静默(列表刷新即反馈)。
* 卡片/表单/详情/删除确认均为域内子组件。成功操作静默(列表刷新即反馈)。
*/
const HostsPage = memo(function HostsPage() {
const { t } = useTranslation('hosts')
const query = useListQuery<Host>((params, signal) =>
hostApi.list(params, { signal })
)
const [formOpen, setFormOpen] = useState(false)
const [detailHost, setDetailHost] = useState<Host | null>(null)
const [terminalHost, setTerminalHost] = useState<Host | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Host | null>(null)
const [editingTarget, setEditingTarget] = useState<Host | null>(null)
const [deleting, setDeleting] = useState(false)
@@ -65,11 +72,11 @@ const HostsPage = memo(function HostsPage() {
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) => {
@@ -78,25 +85,22 @@ const HostsPage = memo(function HostsPage() {
await hostApi.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 的 SSH 主机"
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>
}
>
@@ -107,8 +111,8 @@ const HostsPage = memo(function HostsPage() {
) : query.list.length === 0 ? (
<EmptyState
icon={Monitor}
title="暂无主机"
description="添加第一台 SSH 主机开始使用"
title={t('empty.title')}
description={t('empty.description')}
/>
) : (
<>
@@ -119,7 +123,6 @@ const HostsPage = memo(function HostsPage() {
host={host}
validating={validating}
onViewInfo={setDetailHost}
onOpenTerminal={setTerminalHost}
onValidate={handleValidate}
onDelete={setDeleteTarget}
onEdit={setEditingTarget}
@@ -143,7 +146,7 @@ const HostsPage = memo(function HostsPage() {
setEditingTarget(null)
}
}}
onCreated={query.refresh}
onCreated={() => (editingTarget ? query.refresh() : query.reload())}
editing={editingTarget}
/>
@@ -153,11 +156,6 @@ const HostsPage = memo(function HostsPage() {
onClose={() => setDetailHost(null)}
/>
<TerminalDialog
host={terminalHost}
onClose={() => setTerminalHost(null)}
/>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
@@ -167,7 +165,11 @@ const HostsPage = memo(function HostsPage() {
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}
+33 -19
View File
@@ -1,11 +1,13 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation, useNavigate } from 'react-router'
import { Activity, Eye, EyeOff, Shield, ServerOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { authApi, ApiError } from '@/api'
import { saveAuth, isLoggedIn, getUser } from '@/lib/auth-storage'
import { saveAuth, isLoggedIn, getUser, getRememberedUsername, saveRememberedUsername } from '@/lib/auth-storage'
import { readRedirectParam } from '@/lib/redirect'
/**
@@ -15,8 +17,10 @@ import { readRedirectParam } from '@/lib/redirect'
* - 后端探活走 api 层(authApi.health)
*/
const Login = memo(function Login() {
const [username, setUsername] = useState('')
const { t } = useTranslation()
const [username, setUsername] = useState(() => getRememberedUsername() ?? '')
const [password, setPassword] = useState('')
const [rememberUsername, setRememberUsername] = useState(() => getRememberedUsername() !== null)
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
@@ -55,26 +59,27 @@ const Login = memo(function Login() {
try {
const result = await authApi.login(username, password)
saveAuth(result, result.user)
saveRememberedUsername(rememberUsername ? username : null)
navigate(result.user.role === 'admin' ? readRedirectParam(location.search) : '/logs', { replace: true })
} catch (err: unknown) {
if (err instanceof ApiError) {
if (err.status === 0) {
// 网络/超时类错误
setError('无法连接后端服务,请确认后端已启动(端口 8080)。')
setError(t('login.errConn'))
setBackendStatus('offline')
} else if (err.status === 401) {
setError('用户名或密码错误')
setError(t('login.errCredential'))
} else {
setError(err.message || '登录失败')
setError(err.message || t('login.errFallback'))
}
} else {
setError(err instanceof Error ? err.message : '登录失败')
setError(err instanceof Error ? err.message : t('login.errFallback'))
}
} finally {
setLoading(false)
}
},
[username, password, navigate, location.search]
[username, password, rememberUsername, navigate, location.search, t]
)
// 密码显示切换 - useCallback缓存
@@ -90,8 +95,8 @@ const Login = memo(function Login() {
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-primary mb-3">
<Activity className="w-8 h-8 text-primary-foreground" />
</div>
<h1 className="text-2xl font-bold text-foreground">lokistack</h1>
<p className="text-sm text-muted-foreground mt-1">Kubernetes Loki 日志部署平台</p>
<h1 className="text-2xl font-bold text-foreground">LokiStack</h1>
<p className="text-sm text-muted-foreground mt-1">{t('login.subtitle')}</p>
</div>
{/* Backend Status */}
@@ -99,10 +104,8 @@ const Login = memo(function Login() {
<div className="mb-4 p-4 rounded-xl bg-warning/10 border border-warning/30 flex items-start gap-3">
<ServerOff className="w-5 h-5 text-warning flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-warning">后端服务未启动</p>
<p className="text-xs text-warning mt-1">
无法连接 API 服务,请先启动后端:
</p>
<p className="text-sm font-medium text-warning">{t('login.backendDown')}</p>
<p className="text-xs text-warning mt-1">{t('login.backendDownHint')}</p>
<code className="block mt-2 px-3 py-2 bg-warning/20 rounded text-xs font-mono text-warning">
./run.sh
</code>
@@ -114,7 +117,7 @@ const Login = memo(function Login() {
<div className="bg-card rounded-2xl shadow-lg border border-border p-8">
<div className="flex items-center gap-2 mb-6">
<Shield className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold text-foreground">登录</h2>
<h2 className="text-lg font-semibold text-foreground">{t('login.heading')}</h2>
</div>
{error && (
@@ -125,12 +128,12 @@ const Login = memo(function Login() {
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">用户名</Label>
<Label htmlFor="username">{t('login.username')}</Label>
<Input
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
placeholder={t('login.usernamePlaceholder')}
required
className="h-10"
autoComplete="username"
@@ -138,14 +141,14 @@ const Login = memo(function Login() {
</div>
<div className="space-y-2">
<Label htmlFor="password">密码</Label>
<Label htmlFor="password">{t('login.password')}</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
placeholder={t('login.passwordPlaceholder')}
required
className="h-10 pr-10"
autoComplete="current-password"
@@ -165,12 +168,23 @@ const Login = memo(function Login() {
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="remember-username"
checked={rememberUsername}
onCheckedChange={(checked) => setRememberUsername(checked === true)}
/>
<Label htmlFor="remember-username" className="text-sm font-normal text-muted-foreground cursor-pointer">
{t('login.remember')}
</Label>
</div>
<Button
type="submit"
className="w-full h-10 bg-primary hover:bg-primary/90 text-primary-foreground"
disabled={loading}
>
{loading ? '登录中...' : '登录'}
{loading ? t('login.submitting') : t('login.submit')}
</Button>
</form>
</div>
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { logsApi } from '@/api/logs'
import { SearchableValueSelect } from './SearchableValueSelect'
@@ -15,6 +16,7 @@ interface AccessRuleEditorProps {
/** Exact stream-label restrictions for one user's access to a Loki source. */
export function AccessRuleEditor({ sourceId, username, labels, onChange }: AccessRuleEditorProps) {
const { t } = useTranslation('logs')
const [options, setOptions] = useState<Record<PolicyLabel, string[]>>({ namespace: [], app: [], pod: [] })
const [loading, setLoading] = useState<Record<PolicyLabel, boolean>>({ namespace: false, app: false, pod: false })
const namespace = labels.namespace ?? ''
@@ -45,7 +47,7 @@ export function AccessRuleEditor({ sourceId, username, labels, onChange }: Acces
onChange(next)
}
return <div className="grid gap-2 sm:grid-cols-3 flex-1" aria-label={`${username} 查询范围`}>
{policyLabels.map(name => <SearchableValueSelect key={name} label={name === 'namespace' ? '命名空间' : name === 'app' ? 'App' : '副本 / Pod'} values={options[name]} value={labels[name] ?? ''} onChange={value => setLabel(name, value)} allowEmpty loading={loading[name]} disabled={!sourceId} />)}
return <div className="grid gap-2 sm:grid-cols-3 flex-1" aria-label={t('access.ruleAria', { name: username })}>
{policyLabels.map(name => <SearchableValueSelect key={name} label={t(name === 'namespace' ? 'access.nsLabel' : name === 'app' ? 'access.appLabel' : 'access.podLabel')} values={options[name]} value={labels[name] ?? ''} onChange={value => setLabel(name, value)} allowEmpty loading={loading[name]} disabled={!sourceId} />)}
</div>
}
@@ -1,15 +1,17 @@
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { IngressSample, IngressRouteStats } from '../ingress-insights'
import { summarizeIngress } from '../ingress-insights'
type Mode = 'traffic' | 'errors' | 'slow' | 'detail'
type ErrorClass = 'all' | '5xx' | '4xx'
const modes: { id: Mode; label: string }[] = [
{ id: 'traffic', label: '接口 PV / UV' },
{ id: 'errors', label: '异常 Top 10' },
{ id: 'slow', label: '慢请求 Top 10' },
{ id: 'detail', label: '请求明细' },
]
const modeIds: Mode[] = ['traffic', 'errors', 'slow', 'detail']
const modeLabelKeys: Record<Mode, string> = {
traffic: 'ingress.modeTraffic',
errors: 'ingress.modeErrors',
slow: 'ingress.modeSlow',
detail: 'ingress.modeDetail',
}
const heading = 'px-3 py-2 text-left font-medium whitespace-nowrap'
const cell = 'px-3 py-2 tabular-nums'
const formatSeconds = (seconds: number | null) => seconds === null ? '—' : `${seconds.toFixed(3)} s`
@@ -21,6 +23,7 @@ function Route({ route }: { route: IngressRouteStats }) {
}
export function IngressInsightsTable({ samples, loadedCount }: { samples: IngressSample[]; loadedCount: number }) {
const { t } = useTranslation('logs')
const [mode, setMode] = useState<Mode>('traffic')
const [errorClass, setErrorClass] = useState<ErrorClass>('all')
const [slowThreshold, setSlowThreshold] = useState(1)
@@ -34,29 +37,30 @@ export function IngressInsightsTable({ samples, loadedCount }: { samples: Ingres
}).slice(0, 10), [summary.routes, errorClass])
const slow = useMemo(() => [...summary.routes].filter(route => route.slow || route.timeoutSignals)
.sort((a, b) => b.slow - a.slow || b.timeoutSignals - a.timeoutSignals || (b.maxSeconds ?? 0) - (a.maxSeconds ?? 0)).slice(0, 10), [summary.routes])
const modes = useMemo(() => modeIds.map(id => ({ id, label: t(modeLabelKeys[id]) })), [t])
return <div className="space-y-3">
<p className="text-xs text-muted-foreground">仅统计当前页已加载的 {loadedCount} 条日志,其中 {samples.length} 条为 ingress-nginx 访问日志。翻页后重新统计;UV 按非空客户端 IP 去重。</p>
<p className="text-xs text-muted-foreground">{t('ingress.summary', { loaded: loadedCount, parsed: samples.length })}</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
['PV', summary.pv], ['UV(IP)', summary.uv], ['4xx / 5xx', summary.errors], [`慢请求 ≥ ${slowThreshold}s`, summary.slow],
[t('ingress.statPv'), summary.pv], [t('ingress.statUv'), summary.uv], [t('ingress.statErrors'), summary.errors], [t('ingress.statSlow', { threshold: slowThreshold }), summary.slow],
].map(([label, value]) => <div key={label} className="rounded-lg border bg-muted/20 px-3 py-2"><div className="text-xs text-muted-foreground">{label}</div><div className="text-lg font-semibold tabular-nums">{value}</div></div>)}
</div>
<div className="flex flex-wrap items-center gap-2">
<div role="tablist" aria-label="Ingress 统计方式" className="flex flex-wrap gap-1 rounded-md bg-muted/60 p-1">
<div role="tablist" aria-label={t('ingress.modesAria')} className="flex flex-wrap gap-1 rounded-md bg-muted/60 p-1">
{modes.map(item => <button key={item.id} type="button" role="tab" aria-selected={mode === item.id} onClick={() => setMode(item.id)} className={`rounded px-2.5 py-1.5 text-xs ${mode === item.id ? 'bg-card font-medium shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}>{item.label}</button>)}
</div>
{mode === 'errors' && <select aria-label="异常状态码范围" value={errorClass} onChange={event => setErrorClass(event.target.value as ErrorClass)} className="h-8 rounded border bg-background px-2 text-xs"><option value="all">全部 4xx / 5xx</option><option value="5xx">只看 5xx</option><option value="4xx">只看 4xx</option></select>}
{mode === 'slow' && <label className="flex items-center gap-1 text-xs text-muted-foreground">慢请求阈值 <select aria-label="慢请求阈值" value={slowThreshold} onChange={event => setSlowThreshold(Number(event.target.value))} className="h-8 rounded border bg-background px-2 text-foreground"><option value={0.5}>0.5 秒</option><option value={1}>1 秒</option><option value={3}>3 秒</option><option value={5}>5 秒</option><option value={10}>10 秒</option></select></label>}
{mode === 'errors' && <select aria-label={t('ingress.errorClassAria')} value={errorClass} onChange={event => setErrorClass(event.target.value as ErrorClass)} className="h-8 rounded border bg-background px-2 text-xs"><option value="all">{t('ingress.errorAll')}</option><option value="5xx">{t('ingress.error5xx')}</option><option value="4xx">{t('ingress.error4xx')}</option></select>}
{mode === 'slow' && <label className="flex items-center gap-1 text-xs text-muted-foreground">{t('ingress.slowThreshold')} <select aria-label={t('ingress.slowThreshold')} value={slowThreshold} onChange={event => setSlowThreshold(Number(event.target.value))} className="h-8 rounded border bg-background px-2 text-foreground"><option value={0.5}>{t('ingress.seconds', { count: 0.5 })}</option><option value={1}>{t('ingress.seconds', { count: 1 })}</option><option value={3}>{t('ingress.seconds', { count: 3 })}</option><option value={5}>{t('ingress.seconds', { count: 5 })}</option><option value={10}>{t('ingress.seconds', { count: 10 })}</option></select></label>}
</div>
{mode === 'slow' && <p className="text-xs text-muted-foreground">依据 access log 的 request_time 排序;408 / 499 / 504 仅作超时线索,不能单凭状态码断定后端超时。{summary.timed < summary.pv && ` ${summary.pv - summary.timed} 条缺少有效耗时。`}</p>}
{mode === 'traffic' && <p className="text-xs text-muted-foreground">访问成功率按 2xx / 3xx 请求数 ÷ PV 计算;路径已去掉查询参数。</p>}
{mode === 'slow' && <p className="text-xs text-muted-foreground">{t('ingress.slowHint')}{summary.timed < summary.pv && ` ${t('ingress.slowMissing', { count: summary.pv - summary.timed })}`}</p>}
{mode === 'traffic' && <p className="text-xs text-muted-foreground">{t('ingress.trafficHint')}</p>}
<div className="max-h-[60vh] overflow-auto rounded-lg border">
{mode === 'traffic' && <table className="w-full min-w-[680px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['请求 Host', '接口路径', 'PV', 'UV(IP)', '成功率', '4xx', '5xx'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{traffic.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.pv}</td><td className={cell}>{route.uv}</td><td className={cell}>{(route.success / route.pv * 100).toFixed(1)}%</td><td className={cell}>{route.error4xx}</td><td className={cell}>{route.error5xx}</td></tr>)}</tbody></table>}
{mode === 'errors' && <table className="w-full min-w-[700px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['请求 Host', '接口路径', '4xx', '5xx', '异常率', '状态码'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{errors.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.error4xx}</td><td className={cell}>{route.error5xx}</td><td className={cell}>{((route.error4xx + route.error5xx) / route.pv * 100).toFixed(1)}%</td><td className={cell}>{route.statusCodes}</td></tr>)}</tbody></table>}
{mode === 'slow' && <table className="w-full min-w-[700px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['请求 Host', '接口路径', '慢请求', '最长耗时', '平均耗时', '408 / 499 / 504'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{slow.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.slow}</td><td className={cell}>{formatSeconds(route.maxSeconds)}</td><td className={cell}>{formatSeconds(route.averageSeconds)}</td><td className={cell}>{route.timeoutSignals}</td></tr>)}</tbody></table>}
{mode === 'detail' && <table className="w-full min-w-[800px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['时间', '请求 Host', '接口路径', '客户端 IP', '状态', '耗时'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{samples.map(({ access, timestamp }, index) => <tr key={`${timestamp}-${index}`} className="hover:bg-muted/40"><td className={`${cell} whitespace-nowrap`}>{new Date(timestamp).toLocaleString()}</td><td className={`${cell} max-w-48 truncate`}>{access.domain || '—'}</td><td className={`${cell} max-w-96 truncate font-mono`} title={`${access.method} ${access.path}`}>{access.method} {access.path}</td><td className={cell}>{access.clientIp || '—'}</td><td className={cell}>{access.status}</td><td className={cell}>{access.requestTime ? `${access.requestTime} s` : '—'}</td></tr>)}</tbody></table>}
{((mode === 'traffic' && !traffic.length) || (mode === 'errors' && !errors.length) || (mode === 'slow' && !slow.length)) && <p className="px-3 py-10 text-center text-sm text-muted-foreground">当前页没有符合条件的请求</p>}
{mode === 'traffic' && <table className="w-full min-w-[680px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('ingress.thHost'), t('ingress.thPath'), 'PV', t('ingress.statUv'), t('ingress.thSuccess'), '4xx', '5xx'].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{traffic.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.pv}</td><td className={cell}>{route.uv}</td><td className={cell}>{(route.success / route.pv * 100).toFixed(1)}%</td><td className={cell}>{route.error4xx}</td><td className={cell}>{route.error5xx}</td></tr>)}</tbody></table>}
{mode === 'errors' && <table className="w-full min-w-[700px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('ingress.thHost'), t('ingress.thPath'), '4xx', '5xx', t('ingress.thErrorRate'), t('ingress.thStatus')].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{errors.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.error4xx}</td><td className={cell}>{route.error5xx}</td><td className={cell}>{((route.error4xx + route.error5xx) / route.pv * 100).toFixed(1)}%</td><td className={cell}>{route.statusCodes}</td></tr>)}</tbody></table>}
{mode === 'slow' && <table className="w-full min-w-[700px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('ingress.thHost'), t('ingress.thPath'), t('ingress.thSlow'), t('ingress.thMaxLatency'), t('ingress.thAvgLatency'), t('ingress.thTimeout')].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{slow.map(route => <tr key={routeKey(route)} className="hover:bg-muted/40"><Route route={route} /><td className={cell}>{route.slow}</td><td className={cell}>{formatSeconds(route.maxSeconds)}</td><td className={cell}>{formatSeconds(route.averageSeconds)}</td><td className={cell}>{route.timeoutSignals}</td></tr>)}</tbody></table>}
{mode === 'detail' && <table className="w-full min-w-[800px] text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('ingress.thTime'), t('ingress.thHost'), t('ingress.thPath'), t('ingress.thClientIp'), t('ingress.thStatusShort'), t('ingress.thLatency')].map(title => <th key={title} className={heading}>{title}</th>)}</tr></thead><tbody className="divide-y">{samples.map(({ access, timestamp }, index) => <tr key={`${timestamp}-${index}`} className="hover:bg-muted/40"><td className={`${cell} whitespace-nowrap`}>{new Date(timestamp).toLocaleString()}</td><td className={`${cell} max-w-48 truncate`}>{access.domain || '—'}</td><td className={`${cell} max-w-96 truncate font-mono`} title={`${access.method} ${access.path}`}>{access.method} {access.path}</td><td className={cell}>{access.clientIp || '—'}</td><td className={cell}>{access.status}</td><td className={cell}>{access.requestTime ? `${access.requestTime} s` : '—'}</td></tr>)}</tbody></table>}
{((mode === 'traffic' && !traffic.length) || (mode === 'errors' && !errors.length) || (mode === 'slow' && !slow.length)) && <p className="px-3 py-10 text-center text-sm text-muted-foreground">{t('ingress.empty')}</p>}
</div>
</div>
}
@@ -1,4 +1,5 @@
import { Crosshair } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import type { LogEntry } from '@/api/logs'
import { HighlightedLogLine } from './HighlightedLogLine'
@@ -6,10 +7,11 @@ import { HighlightedLogLine } from './HighlightedLogLine'
export type LogContext = { row: LogEntry; before: LogEntry[]; after: LogEntry[] }
function ContextRow({ row, current, terms }: { row: LogEntry; current?: boolean; terms: string[] }) {
const { t } = useTranslation('logs')
const match = row.message.match(/^\d{4}-\d\d-\d\dT\S+\s+(stdout|stderr)\s+[FP]\s+([\s\S]*)$/)
return <article className={`border-l-[3px] px-3 py-2 ${current ? 'border-l-warning bg-warning/15' : 'border-l-transparent bg-card'}`}>
<div className="mb-1 flex flex-wrap items-center gap-2 text-xs">
{current && <span className="inline-flex items-center gap-1 rounded bg-warning px-2 py-0.5 font-semibold text-warning-foreground"><Crosshair className="size-3" />当前日志</span>}
{current && <span className="inline-flex items-center gap-1 rounded bg-warning px-2 py-0.5 font-semibold text-warning-foreground"><Crosshair className="size-3" />{t('context.current')}</span>}
<time className="font-mono tabular-nums text-muted-foreground">{new Date(row.timestamp).toLocaleString()}</time>
{match && <span className="rounded bg-muted px-1.5 py-0.5 font-mono text-muted-foreground">{match[1]}</span>}
{row.level && <span className="rounded bg-muted px-1.5 py-0.5 text-muted-foreground">{row.level}</span>}
@@ -19,25 +21,26 @@ function ContextRow({ row, current, terms }: { row: LogEntry; current?: boolean;
}
export function LogContextDialog({ context, terms, onClose }: { context: LogContext | null; terms: string[]; onClose: () => void }) {
const { t } = useTranslation('logs')
return <Dialog open={context !== null} onOpenChange={open => { if (!open) onClose() }}>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 !overflow-hidden p-0 sm:max-w-4xl">
<DialogHeader className="border-b px-5 py-4 pr-12">
<DialogTitle>同一日志流的前后文</DialogTitle>
<DialogDescription>{context?.row.labels.filename ? '同一文件' : '同一日志流'} · 前后各最多 5 条 · 前后 5 分钟</DialogDescription>
<DialogTitle>{t('context.title')}</DialogTitle>
<DialogDescription>{context?.row.labels.filename ? t('context.sameFile') : t('context.sameStream')} · {t('context.meta')}</DialogDescription>
{context && <div className="flex flex-wrap gap-1.5 pt-1 text-xs">
{['namespace', 'app', 'pod'].filter(key => context.row.labels[key]).map(key => <span key={key} className="rounded bg-muted px-2 py-1 font-mono text-foreground">{key}={context.row.labels[key]}</span>)}
{context.row.labels.filename && <span title={context.row.labels.filename} className="max-w-full truncate rounded bg-muted px-2 py-1 font-mono text-foreground">文件: {context.row.labels.filename.split('/').slice(-2).join('/')}</span>}
{context.row.labels.filename && <span title={context.row.labels.filename} className="max-w-full truncate rounded bg-muted px-2 py-1 font-mono text-foreground">{t('context.file', { name: context.row.labels.filename.split('/').slice(-2).join('/') })}</span>}
</div>}
</DialogHeader>
{context && <div className="space-y-3 overflow-y-auto bg-muted/20 px-5 py-4">
<section aria-label="之前的日志">
<h3 className="mb-1.5 text-xs font-semibold text-muted-foreground">之前 · {context.before.length} 条</h3>
{context.before.length ? <div className="divide-y overflow-hidden rounded-lg border">{context.before.map((row, index) => <ContextRow key={`${row.timestampNs}-${index}`} row={row} terms={terms} />)}</div> : <p className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">此前 5 分钟内无日志</p>}
<section aria-label={t('context.beforeAria')}>
<h3 className="mb-1.5 text-xs font-semibold text-muted-foreground">{t('context.beforeTitle', { count: context.before.length })}</h3>
{context.before.length ? <div className="divide-y overflow-hidden rounded-lg border">{context.before.map((row, index) => <ContextRow key={`${row.timestampNs}-${index}`} row={row} terms={terms} />)}</div> : <p className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">{t('context.beforeEmpty')}</p>}
</section>
<section aria-label="当前日志" className="overflow-hidden rounded-lg border border-warning/70 shadow-sm"><ContextRow row={context.row} current terms={terms} /></section>
<section aria-label="之后的日志">
<h3 className="mb-1.5 text-xs font-semibold text-muted-foreground">之后 · {context.after.length} 条</h3>
{context.after.length ? <div className="divide-y overflow-hidden rounded-lg border">{context.after.map((row, index) => <ContextRow key={`${row.timestampNs}-${index}`} row={row} terms={terms} />)}</div> : <p className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">此后 5 分钟内无日志</p>}
<section aria-label={t('context.currentAria')} className="overflow-hidden rounded-lg border border-warning/70 shadow-sm"><ContextRow row={context.row} current terms={terms} /></section>
<section aria-label={t('context.afterAria')}>
<h3 className="mb-1.5 text-xs font-semibold text-muted-foreground">{t('context.afterTitle', { count: context.after.length })}</h3>
{context.after.length ? <div className="divide-y overflow-hidden rounded-lg border">{context.after.map((row, index) => <ContextRow key={`${row.timestampNs}-${index}`} row={row} terms={terms} />)}</div> : <p className="rounded-lg border border-dashed p-3 text-xs text-muted-foreground">{t('context.afterEmpty')}</p>}
</section>
</div>}
</DialogContent>
@@ -1,5 +1,6 @@
import { memo } from 'react'
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts'
import { useTranslation } from 'react-i18next'
import type { HistogramBucket } from '@/api/logs'
interface LogHistogramProps {
@@ -11,6 +12,7 @@ interface LogHistogramProps {
/** 日志量直方图(服务端 count_over_time 聚合);点击柱体缩放时间范围 */
export const LogHistogram = memo(function LogHistogram({ buckets, loading, onZoomRange }: LogHistogramProps) {
const { t } = useTranslation('logs')
const data = buckets.map((b) => ({
ts: b.timestamp.slice(11, 16),
count: b.count,
@@ -18,10 +20,10 @@ export const LogHistogram = memo(function LogHistogram({ buckets, loading, onZoo
}))
if (loading && data.length === 0) {
return <div className="h-20 flex items-center justify-center text-xs text-muted-foreground">统计中…</div>
return <div className="h-20 flex items-center justify-center text-xs text-muted-foreground">{t('volume.histogramLoading')}</div>
}
if (data.length === 0) {
return <div className="h-20 flex items-center justify-center text-xs text-muted-foreground">暂无日志量数据</div>
return <div className="h-20 flex items-center justify-center text-xs text-muted-foreground">{t('volume.histogramEmpty')}</div>
}
const handleClick = (idx: number) => {
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react'
import { Area, AreaChart, CartesianGrid, Cell, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
import { useTranslation } from 'react-i18next'
import type { LogEntry } from '@/api/logs'
import { httpStatusClass, parseIngressAccessLog, pathWithoutQuery, stripLogEnvelope } from '../ingress-access-log'
import { IngressInsightsTable } from './IngressInsightsTable'
@@ -20,6 +21,13 @@ export function LogResultViews({ view, entries, buckets, start, end, domainFilte
end: string
domainFiltered?: boolean
}) {
const { t } = useTranslation('logs')
// 聚合占位名(无域名/其他域名等):同时用作数据列名与图例展示,统一从词典取,保证语言切换后聚合与展示一致
const noDomain = t('views.noDomain')
const otherDomain = t('views.otherDomain')
const unknownName = t('views.unknown')
const untaggedName = t('views.untagged')
const otherName = t('views.other')
const [groupBy, setGroupBy] = useState<'domain' | 'status' | 'method' | 'path' | 'upstream' | 'app' | 'namespace' | 'pod' | 'level'>('domain')
const [lineBy, setLineBy] = useState<'domain' | 'total'>('domain')
const [streamBy, setStreamBy] = useState<'domain' | 'status'>('domain')
@@ -32,11 +40,11 @@ export function LogResultViews({ view, entries, buckets, start, end, domainFilte
const streamDomains = useMemo(() => {
const counts = new Map<string, number>()
for (const row of parsedRows) if (row) {
const domain = row.domain || '未记录域名'
const domain = row.domain || noDomain
counts.set(domain, (counts.get(domain) ?? 0) + 1)
}
return [...counts].sort((a, b) => b[1] - a[1]).slice(0, 4).map(([domain]) => domain)
}, [parsedRows])
}, [parsedRows, noDomain])
const effectiveGroupBy = ingressCount ? groupBy : ['domain', 'status', 'method', 'path', 'upstream'].includes(groupBy) ? 'app' : groupBy
const lineData = useMemo(() => {
if (!domainFiltered && (lineBy === 'total' || !ingressCount)) return buckets.map(bucket => ({ time: new Date(bucket.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), count: bucket.count }))
@@ -50,13 +58,13 @@ export function LogResultViews({ view, entries, buckets, start, end, domainFilte
if (lineBy === 'domain' && ingressCount) {
const row = parsedRows[rowIndex]
if (!row) continue
const domain = row.domain || '未记录域名'
const key = streamDomains.includes(domain) ? domain : '其他域名'
const domain = row.domain || noDomain
const key = streamDomains.includes(domain) ? domain : otherDomain
counts[index][key] = Number(counts[index][key] ?? 0) + 1
} else counts[index].count = Number(counts[index].count) + 1
}
return counts
}, [buckets, domainFiltered, entries, ingressCount, lineBy, parsedRows, start, end, streamDomains])
}, [buckets, domainFiltered, entries, ingressCount, lineBy, parsedRows, start, end, streamDomains, noDomain, otherDomain])
const streamData = useMemo(() => {
const from = new Date(start).getTime(), to = new Date(end).getTime()
if (!(from < to)) return []
@@ -69,7 +77,7 @@ export function LogResultViews({ view, entries, buckets, start, end, domainFilte
const ingress = parsedRows[rowIndex]
if (ingressCount) {
if (ingress) {
const key = streamBy === 'domain' ? (streamDomains.includes(ingress.domain || '未记录域名') ? ingress.domain || '未记录域名' : '其他域名') : httpStatusClass(ingress.status)
const key = streamBy === 'domain' ? (streamDomains.includes(ingress.domain || noDomain) ? ingress.domain || noDomain : otherDomain) : httpStatusClass(ingress.status)
rows[index][key] = Number(rows[index][key] ?? 0) + 1
}
continue
@@ -79,47 +87,47 @@ export function LogResultViews({ view, entries, buckets, start, end, domainFilte
rows[index][key] = Number(rows[index][key] ?? 0) + 1
}
return rows
}, [entries, parsedRows, ingressCount, streamBy, streamDomains, start, end])
}, [entries, parsedRows, ingressCount, streamBy, streamDomains, start, end, noDomain, otherDomain])
const pieData = useMemo(() => {
const counts = new Map<string, number>()
for (const [index, entry] of entries.entries()) {
const ingress = parsedRows[index]
if (['domain', 'status', 'method', 'path', 'upstream'].includes(effectiveGroupBy) && !ingress) continue
const name = effectiveGroupBy === 'domain' ? ingress?.domain || '未记录域名'
: effectiveGroupBy === 'status' ? ingress?.status || '未知'
: effectiveGroupBy === 'method' ? ingress?.method || '未知'
const name = effectiveGroupBy === 'domain' ? ingress?.domain || noDomain
: effectiveGroupBy === 'status' ? ingress?.status || unknownName
: effectiveGroupBy === 'method' ? ingress?.method || unknownName
: effectiveGroupBy === 'path' ? pathWithoutQuery(ingress?.path || '/')
: effectiveGroupBy === 'upstream' ? ingress?.upstreamName || '未标记'
: effectiveGroupBy === 'upstream' ? ingress?.upstreamName || untaggedName
: effectiveGroupBy === 'level' ? entry.level || 'unknown'
: entry.labels[effectiveGroupBy] || '未标记'
: entry.labels[effectiveGroupBy] || untaggedName
counts.set(name, (counts.get(name) ?? 0) + 1)
}
const sorted = [...counts].sort((a, b) => b[1] - a[1])
const top = sorted.slice(0, 7).map(([name, count]) => ({ name, count }))
const other = sorted.slice(7).reduce((sum, [, count]) => sum + count, 0)
if (other) top.push({ name: '其他', count: other })
if (other) top.push({ name: otherName, count: other })
return top
}, [entries, parsedRows, effectiveGroupBy])
}, [entries, parsedRows, effectiveGroupBy, noDomain, unknownName, untaggedName, otherName])
if (view === 'table') return ingressCount > 0
? <IngressInsightsTable samples={ingressSamples} loadedCount={entries.length} />
: <div>
<p className="mb-2 text-xs text-muted-foreground">当前页已加载 {entries.length} 条日志。</p>
{entries.length === 0 ? <EmptyChart message="暂无可展示的日志" /> : <div className="max-h-[60vh] overflow-auto rounded-lg border"><table className="w-full text-left text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{['时间', 'namespace / pod', '级别', '日志'].map(title => <th key={title} className="border-b px-3 py-2 font-medium">{title}</th>)}</tr></thead><tbody className="divide-y">{entries.map((entry, index) => <tr key={`${entry.timestampNs}-${index}`} className="hover:bg-muted/40"><td className="whitespace-nowrap px-3 py-2 tabular-nums">{new Date(entry.timestamp).toLocaleString()}</td><td className="px-3 py-2">{entry.labels.namespace || '—'} / {entry.labels.pod || '—'}</td><td className="px-3 py-2">{entry.level}</td><td className="max-w-xl truncate px-3 py-2 font-mono" title={stripLogEnvelope(entry.message)}>{stripLogEnvelope(entry.message)}</td></tr>)}</tbody></table></div>}
<p className="mb-2 text-xs text-muted-foreground">{t('views.tableLoaded', { count: entries.length })}</p>
{entries.length === 0 ? <EmptyChart message={t('views.tableEmpty')} /> : <div className="max-h-[60vh] overflow-auto rounded-lg border"><table className="w-full text-left text-xs"><thead className="sticky top-0 bg-muted text-muted-foreground"><tr>{[t('views.thTime'), t('views.thNsPod'), t('views.thLevel'), t('views.thLog')].map(title => <th key={title} className="border-b px-3 py-2 font-medium">{title}</th>)}</tr></thead><tbody className="divide-y">{entries.map((entry, index) => <tr key={`${entry.timestampNs}-${index}`} className="hover:bg-muted/40"><td className="whitespace-nowrap px-3 py-2 tabular-nums">{new Date(entry.timestamp).toLocaleString()}</td><td className="px-3 py-2">{entry.labels.namespace || '—'} / {entry.labels.pod || '—'}</td><td className="px-3 py-2">{entry.level}</td><td className="max-w-xl truncate px-3 py-2 font-mono" title={stripLogEnvelope(entry.message)}>{stripLogEnvelope(entry.message)}</td></tr>)}</tbody></table></div>}
</div>
if (view === 'line') return <div>
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{(lineBy === 'domain' && ingressCount) || domainFiltered ? '按当前已加载日志统计;更多结果请继续加载。' : '按查询时间范围统计的日志量,来自 Loki 时间聚合。'}</p>{ingressCount > 0 && <select aria-label="折线图分组字段" className="h-8 rounded border bg-background px-2 text-xs" value={lineBy} onChange={event => setLineBy(event.target.value as typeof lineBy)}><option value="domain">按域名</option><option value="total">总量</option></select>}</div>
{lineData.length === 0 ? <EmptyChart message="当前查询没有可用的时间统计" /> : <div className="h-72 w-full"><ResponsiveContainer width="100%" height="100%"><LineChart data={lineData} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}><CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} /><YAxis allowDecimals={false} tick={{ fontSize: 11 }} /><Tooltip />{lineBy === 'domain' && ingressCount ? [...streamDomains, '其他域名'].map((domain, index) => <Line key={domain} type="monotone" dataKey={domain} name={domain} stroke={colors[index % colors.length]} strokeWidth={2} dot={false} />) : <Line type="monotone" dataKey="count" name="日志条数" stroke={colors[0]} strokeWidth={2} dot={false} />}</LineChart></ResponsiveContainer></div>}
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{(lineBy === 'domain' && ingressCount) || domainFiltered ? t('views.lineByLoaded') : t('views.lineByRange')}</p>{ingressCount > 0 && <select aria-label={t('views.lineGroupAria')} className="h-8 rounded border bg-background px-2 text-xs" value={lineBy} onChange={event => setLineBy(event.target.value as typeof lineBy)}><option value="domain">{t('views.lineByDomain')}</option><option value="total">{t('views.lineByTotal')}</option></select>}</div>
{lineData.length === 0 ? <EmptyChart message={t('views.lineEmpty')} /> : <div className="h-72 w-full"><ResponsiveContainer width="100%" height="100%"><LineChart data={lineData} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}><CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} /><YAxis allowDecimals={false} tick={{ fontSize: 11 }} /><Tooltip />{lineBy === 'domain' && ingressCount ? [...streamDomains, otherDomain].map((domain, index) => <Line key={domain} type="monotone" dataKey={domain} name={domain} stroke={colors[index % colors.length]} strokeWidth={2} dot={false} />) : <Line type="monotone" dataKey="count" name={t('views.lineSeriesTotal')} stroke={colors[0]} strokeWidth={2} dot={false} />}</LineChart></ResponsiveContainer></div>}
</div>
if (view === 'stream') return <div>
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{ingressCount ? `展示 ${ingressCount} 条已解析日志` : `按级别展示当前已加载的 ${entries.length} 条日志`},基于当前已加载结果。</p>{ingressCount > 0 && <select aria-label="流图分组字段" className="h-8 rounded border bg-background px-2 text-xs" value={streamBy} onChange={event => setStreamBy(event.target.value as typeof streamBy)}><option value="domain">按域名</option><option value="status">按 HTTP 状态段</option></select>}</div>
{entries.length === 0 ? <EmptyChart message="暂无可展示的日志流" /> : <div className="h-72 w-full"><ResponsiveContainer width="100%" height="100%"><AreaChart data={streamData} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}><CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} /><YAxis allowDecimals={false} tick={{ fontSize: 11 }} /><Tooltip />{(ingressCount ? streamBy === 'domain' ? [...streamDomains, '其他域名'] : ['2xx', '3xx', '4xx', '5xx', 'other'] : ['error', 'warn', 'info', 'other']).map((level, index) => <Area key={level} type="monotone" dataKey={level} name={level} stackId="logs" stroke={colors[level === '4xx' || level === 'error' ? 4 : index % colors.length]} fill={colors[level === '4xx' || level === 'error' ? 4 : index % colors.length]} fillOpacity={0.65} />)}</AreaChart></ResponsiveContainer></div>}
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{ingressCount ? t('views.streamParsed', { count: ingressCount }) : t('views.streamByLevel', { count: entries.length })}{t('views.streamSuffix')}</p>{ingressCount > 0 && <select aria-label={t('views.streamGroupAria')} className="h-8 rounded border bg-background px-2 text-xs" value={streamBy} onChange={event => setStreamBy(event.target.value as typeof streamBy)}><option value="domain">{t('views.lineByDomain')}</option><option value="status">{t('views.streamByStatus')}</option></select>}</div>
{entries.length === 0 ? <EmptyChart message={t('views.streamEmpty')} /> : <div className="h-72 w-full"><ResponsiveContainer width="100%" height="100%"><AreaChart data={streamData} margin={{ top: 12, right: 16, bottom: 4, left: 0 }}><CartesianGrid stroke="hsl(var(--border))" strokeDasharray="3 3" /><XAxis dataKey="time" tick={{ fontSize: 11 }} minTickGap={30} /><YAxis allowDecimals={false} tick={{ fontSize: 11 }} /><Tooltip />{(ingressCount ? streamBy === 'domain' ? [...streamDomains, otherDomain] : ['2xx', '3xx', '4xx', '5xx', 'other'] : ['error', 'warn', 'info', 'other']).map((level, index) => <Area key={level} type="monotone" dataKey={level} name={level} stackId="logs" stroke={colors[level === '4xx' || level === 'error' ? 4 : index % colors.length]} fill={colors[level === '4xx' || level === 'error' ? 4 : index % colors.length]} fillOpacity={0.65} />)}</AreaChart></ResponsiveContainer></div>}
</div>
return <div>
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{['domain', 'status', 'method', 'path', 'upstream'].includes(effectiveGroupBy) ? `按访问字段统计已解析的 ${ingressCount} 条日志` : `按标签统计当前已加载的 ${entries.length} 条日志`}</p><select aria-label="饼图分组字段" className="h-8 rounded border bg-background px-2 text-xs" value={effectiveGroupBy} onChange={event => setGroupBy(event.target.value as typeof groupBy)}>{ingressCount > 0 && <><option value="domain">域名(请求 Host)</option><option value="status">HTTP 状态码</option><option value="method">请求方法</option><option value="path">请求路径</option><option value="upstream">上游服务</option></>}<option value="app">app</option><option value="namespace">namespace</option><option value="pod">pod</option><option value="level">级别</option></select></div>
{pieData.length === 0 ? <EmptyChart message="暂无可展示的标签分布" /> : <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]"><div className="h-72"><ResponsiveContainer width="100%" height="100%"><PieChart><Pie data={pieData} dataKey="count" nameKey="name" outerRadius="75%" innerRadius="42%">{pieData.map((item, index) => <Cell key={item.name} fill={colors[index % colors.length]} />)}</Pie><Tooltip /></PieChart></ResponsiveContainer></div><div className="flex flex-col justify-center gap-2 text-xs">{pieData.map((item, index) => <div key={item.name} className="flex items-center gap-2"><span className="size-2.5 shrink-0 rounded-sm" style={{ background: colors[index % colors.length] }} /><span className="min-w-0 flex-1 truncate" title={item.name}>{item.name}</span><strong className="tabular-nums">{item.count}</strong></div>)}</div></div>}
<div className="mb-2 flex flex-wrap items-center gap-2"><p className="text-xs text-muted-foreground">{['domain', 'status', 'method', 'path', 'upstream'].includes(effectiveGroupBy) ? t('views.pieByAccess', { count: ingressCount }) : t('views.pieByLabel', { count: entries.length })}</p><select aria-label={t('views.pieGroupAria')} className="h-8 rounded border bg-background px-2 text-xs" value={effectiveGroupBy} onChange={event => setGroupBy(event.target.value as typeof groupBy)}>{ingressCount > 0 && <><option value="domain">{t('views.pieDomain')}</option><option value="status">{t('views.pieStatus')}</option><option value="method">{t('views.pieMethod')}</option><option value="path">{t('views.piePath')}</option><option value="upstream">{t('views.pieUpstream')}</option></>}<option value="app">app</option><option value="namespace">namespace</option><option value="pod">pod</option><option value="level">{t('views.pieLevel')}</option></select></div>
{pieData.length === 0 ? <EmptyChart message={t('views.pieEmpty')} /> : <div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]"><div className="h-72"><ResponsiveContainer width="100%" height="100%"><PieChart><Pie data={pieData} dataKey="count" nameKey="name" outerRadius="75%" innerRadius="42%">{pieData.map((item, index) => <Cell key={item.name} fill={colors[index % colors.length]} />)}</Pie><Tooltip /></PieChart></ResponsiveContainer></div><div className="flex flex-col justify-center gap-2 text-xs">{pieData.map((item, index) => <div key={item.name} className="flex items-center gap-2"><span className="size-2.5 shrink-0 rounded-sm" style={{ background: colors[index % colors.length] }} /><span className="min-w-0 flex-1 truncate" title={item.name}>{item.name}</span><strong className="tabular-nums">{item.count}</strong></div>)}</div></div>}
</div>
}
@@ -1,5 +1,6 @@
import { memo, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import type { LogEntry } from '@/api/logs'
import { applyLabelFilter } from '../query-utils'
@@ -59,6 +60,7 @@ export const LogResults = memo(function LogResults({
onFilter,
query,
}: LogResultsProps) {
const { t } = useTranslation('logs')
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [dedup, setDedup] = useState(false)
const listRef = useRef<HTMLDivElement>(null)
@@ -94,14 +96,15 @@ export const LogResults = memo(function LogResults({
}, [entries])
if (loading && entries.length === 0) {
return <div className="h-40 flex items-center justify-center text-sm text-muted-foreground">查询中…</div>
return <div className="h-40 flex items-center justify-center text-sm text-muted-foreground">{t('raw.searching')}</div>
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{display.length} 条{dedup && `(去重后,原 ${entries.length} 条)`}
{t('raw.count', { count: display.length })}
{dedup && t('raw.dedupSuffix', { count: entries.length })}
{commonLabels.length > 0 && (
<span className="ml-2 inline-flex flex-wrap gap-1">
{commonLabels.slice(0, 6).map((k) => (
@@ -114,13 +117,13 @@ export const LogResults = memo(function LogResults({
</span>
<label className="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={dedup} onChange={(e) => setDedup(e.target.checked)} className="accent-primary" />
去重
{t('raw.dedup')}
</label>
</div>
<div ref={listRef} className="rounded-lg border border-border divide-y divide-border max-h-[52vh] overflow-y-auto bg-card">
{display.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">没有匹配的日志</div>
<div className="py-12 text-center text-sm text-muted-foreground">{t('raw.empty')}</div>
) : (
display.map((e, idx) => {
const key = `${e.timestamp}-${idx}`
@@ -147,18 +150,18 @@ export const LogResults = memo(function LogResults({
{open && (
<div className="px-3 pb-3 pl-10 space-y-2">
<div>
<p className="text-[11px] font-semibold text-muted-foreground mb-1">日志原文</p>
<p className="text-[11px] font-semibold text-muted-foreground mb-1">{t('raw.original')}</p>
<pre className="p-2 rounded bg-muted/60 font-mono whitespace-pre-wrap break-all">{e.message}</pre>
</div>
<div>
<p className="text-[11px] font-semibold text-muted-foreground mb-1">标签(点击过滤)</p>
<p className="text-[11px] font-semibold text-muted-foreground mb-1">{t('raw.labelsTitle')}</p>
<div className="flex flex-wrap gap-1">
{Object.entries(e.labels).map(([k, v]) => (
<span key={k} className="group inline-flex items-center rounded bg-muted px-1.5 py-0.5">
<button
type="button"
className="hover:text-primary"
title="过滤此值"
title={t('raw.filterValue')}
onClick={() => onFilter(applyLabelFilter(query, k, v))}
>
{k}={v}
@@ -166,7 +169,7 @@ export const LogResults = memo(function LogResults({
<button
type="button"
className="ml-1 opacity-0 group-hover:opacity-100 text-destructive"
title="排除此值"
title={t('raw.excludeValue')}
onClick={() => onFilter(applyLabelFilter(query, k, v, true))}
>
×
@@ -177,7 +180,7 @@ export const LogResults = memo(function LogResults({
</div>
{fields.length > 0 && (
<div>
<p className="text-[11px] font-semibold text-muted-foreground mb-1">提取字段</p>
<p className="text-[11px] font-semibold text-muted-foreground mb-1">{t('raw.fields')}</p>
<div className="flex flex-wrap gap-1">
{fields.map(([k, v]) => (
<span key={k} className="inline-flex items-center rounded bg-info/10 text-info px-1.5 py-0.5">
@@ -204,7 +207,7 @@ export const LogResults = memo(function LogResults({
className="w-full py-2 text-xs text-primary hover:bg-primary/5 rounded-lg border border-border"
disabled={loading}
>
{loading ? '加载中…' : '加载更多'}
{loading ? t('raw.loadingMore') : t('raw.loadMore')}
</button>
)}
</div>
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { LogEntry } from '@/api/logs'
@@ -10,16 +11,21 @@ import { LogResultViews, type ResultView } from './LogResultViews'
import { QueryEmptyState } from './QueryEmptyState'
type Bucket = { timestamp: string; count: number }
const views: { id: ResultView; label: string }[] = [
{ id: 'raw', label: '原始日志' }, { id: 'table', label: '表格' },
{ id: 'line', label: '折线图' }, { id: 'stream', label: '流图' }, { id: 'pie', label: '饼图' },
]
const viewIds: ResultView[] = ['raw', 'table', 'line', 'stream', 'pie']
const viewLabelKeys: Record<ResultView, string> = {
raw: 'results.viewRaw',
table: 'results.viewTable',
line: 'results.viewLine',
stream: 'results.viewStream',
pie: 'results.viewPie',
}
export function LogResultsPanel({ entries, buckets, start, end, searched, hasSource, pageIndex, hasNextPage, loading, loadingStartedAt, query, terms, onQueryChange, onApplyDomain, onShowContext, onSearch, onNextPage, onPreviousPage }: {
entries: LogEntry[]; buckets: Bucket[]; start: string; end: string; searched: boolean; hasSource: boolean
pageIndex: number; hasNextPage: boolean; loading: boolean; loadingStartedAt: number | null; query: string; terms: string[]
onQueryChange: (query: string) => void; onApplyDomain: (domain: string) => void; onShowContext: (row: LogEntry) => void; onSearch: () => void; onNextPage: () => void; onPreviousPage: () => void
}) {
const { t } = useTranslation('logs')
const [view, setView] = useState<ResultView>('raw')
const [domainFilter, setDomainFilter] = useState('')
const [clock, setClock] = useState(0)
@@ -28,45 +34,49 @@ export function LogResultsPanel({ entries, buckets, start, end, searched, hasSou
const timer = window.setInterval(() => setClock(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [loading])
const views = useMemo(
() => viewIds.map(id => ({ id, label: t(viewLabelKeys[id]) })),
[t],
)
const domains = useMemo(() => [...new Set(entries.map(entry => parseIngressAccessLog(entry.message)?.domain).filter((domain): domain is string => Boolean(domain)))].sort(), [entries])
const visibleEntries = useMemo(() => {
const needle = domainFilter.trim().toLowerCase()
return needle ? entries.filter(entry => parseIngressAccessLog(entry.message)?.domain.includes(needle)) : entries
}, [entries, domainFilter])
return <section className="rounded-xl border bg-card p-4" aria-label="日志结果">
return <section className="rounded-xl border bg-card p-4" aria-label={t('results.aria')}>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="font-semibold">日志结果 <span className="text-sm font-normal text-muted-foreground">第 {pageIndex + 1} 页 · 本页 {entries.length} 条{hasNextPage && ' · 还有更多'}</span></h2>
<div role="tablist" aria-label="日志展示方式" className="flex flex-wrap gap-1 rounded-lg bg-muted/60 p-1">
<h2 className="font-semibold">{t('results.title')} <span className="text-sm font-normal text-muted-foreground">{hasNextPage ? t('results.summaryMore', { page: pageIndex + 1, count: entries.length }) : t('results.summary', { page: pageIndex + 1, count: entries.length })}</span></h2>
<div role="tablist" aria-label={t('results.viewAria')} className="flex flex-wrap gap-1 rounded-lg bg-muted/60 p-1">
{views.map(item => <button key={item.id} role="tab" aria-selected={view === item.id} type="button" className={`rounded px-2.5 py-1.5 text-xs transition-colors ${view === item.id ? 'bg-card font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`} onClick={() => setView(item.id)}>{item.label}</button>)}
</div>
</div>
{loading && <div role="status" aria-live="polite" className="mb-3 flex items-center gap-2 rounded-md border border-info/30 bg-info/5 px-3 py-2 text-sm text-foreground">
<Loader2 className="size-4 shrink-0 animate-spin text-info" />
<span>{entries.length ? '正在加载日志页' : '正在查询 Loki'} · 已等待 {clock && loadingStartedAt ? Math.max(0, Math.floor((clock - loadingStartedAt) / 1000)) : 0} 秒。大范围查询可能需要较长时间。</span>
<span>{entries.length ? t('results.loadingPage') : t('results.loadingQuery')} · {t('results.loadingWait', { seconds: clock && loadingStartedAt ? Math.max(0, Math.floor((clock - loadingStartedAt) / 1000)) : 0 })}</span>
</div>}
{pageIndex > 0 && <p className="mb-2 text-xs text-muted-foreground">日志按时间从新到旧翻页;图表展示当前页,顶部时间线展示整个查询范围。</p>}
{pageIndex > 0 && <p className="mb-2 text-xs text-muted-foreground">{t('results.pagingHint')}</p>}
{domains.length > 0 && <div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
<label htmlFor="log-domain-filter" className="shrink-0 text-muted-foreground">域名筛选</label>
<Input id="log-domain-filter" list="log-domain-options" type="search" className="h-8 w-60" value={domainFilter} onChange={event => setDomainFilter(event.target.value)} placeholder="输入或选择域名" />
<label htmlFor="log-domain-filter" className="shrink-0 text-muted-foreground">{t('results.domainFilter')}</label>
<Input id="log-domain-filter" list="log-domain-options" type="search" className="h-8 w-60" value={domainFilter} onChange={event => setDomainFilter(event.target.value)} placeholder={t('results.domainPlaceholder')} />
<datalist id="log-domain-options">{domains.map(domain => <option key={domain} value={domain} />)}</datalist>
<Button type="button" size="sm" variant="outline" disabled={!domainFilter.trim() || loading} onClick={() => onApplyDomain(domainFilter.trim())}>在 Loki 中查询</Button>
<span className="text-muted-foreground">仅匹配日志记录的请求 Host。当前 {visibleEntries.length} / {entries.length} 条。</span>
<Button type="button" size="sm" variant="outline" disabled={!domainFilter.trim() || loading} onClick={() => onApplyDomain(domainFilter.trim())}>{t('results.domainQuery')}</Button>
<span className="text-muted-foreground">{t('results.domainHint', { shown: visibleEntries.length, total: entries.length })}</span>
</div>}
{view === 'raw' ? entries.length === 0 ? <QueryEmptyState searched={searched} hasSource={hasSource} onRetry={onSearch} /> : visibleEntries.length === 0 ? <p className="py-10 text-center text-sm text-muted-foreground">当前域名筛选没有匹配的日志</p> : <div className="divide-y">
{view === 'raw' ? entries.length === 0 ? <QueryEmptyState searched={searched} hasSource={hasSource} onRetry={onSearch} /> : visibleEntries.length === 0 ? <p className="py-10 text-center text-sm text-muted-foreground">{t('results.domainNoMatch')}</p> : <div className="divide-y">
{visibleEntries.map((row, index) => <div key={`${row.timestampNs}-${index}`} className="py-2 text-sm">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<time className="font-mono tabular-nums">{new Date(row.timestamp).toLocaleString()}</time>
<span className="rounded bg-muted px-1">{row.level}</span>
{Object.entries(row.labels).filter(([key]) => ['namespace', 'app', 'pod'].includes(key)).map(([key, value]) => <button key={key} title="筛选此标签" className="hover:text-primary" onClick={() => onQueryChange(applyLabelFilter(query, key, value))}>{key}={value}</button>)}
<button className="text-primary hover:underline" onClick={() => onShowContext(row)}>查看前后各 5 条</button>
{Object.entries(row.labels).filter(([key]) => ['namespace', 'app', 'pod'].includes(key)).map(([key, value]) => <button key={key} title={t('results.filterLabel')} className="hover:text-primary" onClick={() => onQueryChange(applyLabelFilter(query, key, value))}>{key}={value}</button>)}
<button className="text-primary hover:underline" onClick={() => onShowContext(row)}>{t('results.showContext')}</button>
</div>
<HighlightedLogLine message={row.message} terms={terms} className="mt-1 whitespace-pre-wrap break-all font-mono" />
</div>)}
</div> : <LogResultViews view={view} entries={visibleEntries} buckets={buckets} start={start} end={end} domainFiltered={Boolean(domainFilter.trim())} />}
{searched && entries.length > 0 && <div className="mt-3 flex items-center gap-2">
<Button variant="outline" disabled={loading || pageIndex === 0} onClick={onPreviousPage}>上一页</Button>
<span className="text-xs tabular-nums text-muted-foreground">第 {pageIndex + 1} 页</span>
<Button variant="outline" disabled={loading || !hasNextPage} onClick={onNextPage}>下一页</Button>
<Button variant="outline" disabled={loading || pageIndex === 0} onClick={onPreviousPage}>{t('results.prevPage')}</Button>
<span className="text-xs tabular-nums text-muted-foreground">{t('results.pageOf', { page: pageIndex + 1 })}</span>
<Button variant="outline" disabled={loading || !hasNextPage} onClick={onNextPage}>{t('results.nextPage')}</Button>
</div>}
</section>
}
@@ -1,4 +1,5 @@
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
type Bucket = { timestamp: string; count: number }
@@ -9,6 +10,7 @@ export function LogVolumeTimeline({ buckets, onSelect }: {
buckets: Bucket[]
onSelect: (start: number, end: number) => void
}) {
const { t } = useTranslation('logs')
const [hover, setHover] = useState<number | null>(null)
const [selection, setSelection] = useState<[number, number] | null>(null)
const startIndex = useRef<number | null>(null)
@@ -33,10 +35,10 @@ export function LogVolumeTimeline({ buckets, onSelect }: {
if (buckets.length === 0) return null
return <section className="rounded-xl border bg-card p-4" aria-label="日志量时间线">
return <section className="rounded-xl border bg-card p-4" aria-label={t('volume.timelineAria')}>
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
<div><h2 className="text-sm font-semibold">日志量时间线</h2><p className="text-xs text-muted-foreground">悬停查看数量,按住任意高度拖选时间;选择后自动查询。</p></div>
<span className="text-sm tabular-nums">时间桶合计 <strong>{formatCount(total)}</strong> 条</span>
<div><h2 className="text-sm font-semibold">{t('volume.timelineTitle')}</h2><p className="text-xs text-muted-foreground">{t('volume.timelineHint')}</p></div>
<span className="text-sm tabular-nums">{t('volume.timelineTotal', { count: formatCount(total) })}</span>
</div>
<div className="flex gap-2">
<div className="relative h-44 min-w-0 flex-1 select-none touch-none cursor-crosshair"
@@ -53,7 +55,7 @@ export function LogVolumeTimeline({ buckets, onSelect }: {
</div>)}
</div>
{hover !== null && <div className="pointer-events-none absolute z-10 top-2 rounded-md border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-sm" style={{ left: `${Math.min(78, Math.max(2, (hover + 0.5) / buckets.length * 100))}%` }}>
{new Date(buckets[hover].timestamp).toLocaleString()} · <strong>{formatCount(buckets[hover].count)} 条</strong>
{new Date(buckets[hover].timestamp).toLocaleString()} · <strong>{t('volume.timelineBucket', { count: formatCount(buckets[hover].count) })}</strong>
</div>}
</div>
<div className="flex h-44 w-14 flex-col justify-between text-right text-xs tabular-nums text-muted-foreground"><span>{formatCount(max)}</span><span>{formatCount(Math.round(max / 2))}</span><span>0</span></div>
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { ChevronDown, ChevronRight, Tags } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Input } from '@/components/ui/input'
import { logsApi } from '@/api/logs'
import { applyLabelFilter, extractSelector } from '../query-utils'
@@ -17,6 +18,7 @@ function streamSelector(labels: Record<string, string>) {
}
export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }: { sourceId: number; query: string; onQueryChange: (query: string) => void; start: string; end: string }) {
const { t } = useTranslation('logs')
const [open, setOpen] = useState(false)
const [labels, setLabels] = useState<string[]>([])
const [activeLabel, setActiveLabel] = useState('')
@@ -74,22 +76,22 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }:
}
}
return <section className="overflow-hidden rounded-lg border bg-card" aria-label="Label 浏览器">
return <section className="overflow-hidden rounded-lg border bg-card" aria-label={t('labels.browser')}>
<button type="button" className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-medium hover:bg-muted/50" aria-expanded={open} onClick={() => { setOpen(!open); if (!open) setLoadingLabels(true) }}>
{open ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
<Tags className="size-4 text-info" />Label 浏览器
{Object.keys(selected).length > 0 && <span className="rounded bg-info/10 px-2 py-0.5 text-xs text-info">已选 {Object.keys(selected).length}</span>}
<Tags className="size-4 text-info" />{t('labels.browser')}
{Object.keys(selected).length > 0 && <span className="rounded bg-info/10 px-2 py-0.5 text-xs text-info">{t('labels.selectedCount', { count: Object.keys(selected).length })}</span>}
</button>
{open && <div className="grid min-h-44 border-t sm:grid-cols-[minmax(150px,220px)_1fr]">
<div className="max-h-64 overflow-auto border-b p-1 sm:border-b-0 sm:border-r">
{loadingLabels ? <p className="px-3 py-4 text-sm text-muted-foreground">加载标签中…</p> : labels.length === 0 ? <p className="px-3 py-4 text-sm text-muted-foreground">暂无可用标签</p> : labels.map(label => <button key={label} type="button" className={`block w-full rounded px-3 py-2 text-left font-mono text-sm ${activeLabel === label ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`} onClick={() => { if (label !== activeLabel) { setActiveLabel(label); setValues([]); setLoadingValues(true) } setFilter('') }}>{label}{selected[label] && <span className="ml-1 text-xs text-success">✓</span>}</button>)}
{loadingLabels ? <p className="px-3 py-4 text-sm text-muted-foreground">{t('labels.loadingLabels')}</p> : labels.length === 0 ? <p className="px-3 py-4 text-sm text-muted-foreground">{t('labels.noLabels')}</p> : labels.map(label => <button key={label} type="button" className={`block w-full rounded px-3 py-2 text-left font-mono text-sm ${activeLabel === label ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`} onClick={() => { if (label !== activeLabel) { setActiveLabel(label); setValues([]); setLoadingValues(true) } setFilter('') }}>{label}{selected[label] && <span className="ml-1 text-xs text-success">✓</span>}</button>)}
</div>
<div className="min-w-0 p-3">
{!activeLabel ? <p className="py-12 text-center text-sm text-muted-foreground">选择左侧标签,再搜索对应的值</p> : <><div className="mb-3 flex flex-wrap items-center gap-2"><strong className="text-sm">{activeLabel} 的值</strong><Input aria-label={`搜索 ${activeLabel} 的值`} className="h-8 max-w-sm" placeholder="搜索或输入标签值" value={filter} onChange={event => setFilter(event.target.value)} /></div>
<label className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground"><input type="checkbox" checked={scoped} onChange={event => setScoped(event.target.checked)} />按当前查询条件缩小候选</label>
<div className="grid max-h-48 gap-1 overflow-auto sm:grid-cols-2 xl:grid-cols-3">{loadingValues ? <p className="p-2 text-sm text-muted-foreground">加载中…</p> : shown.length === 0 ? <p className="p-2 text-sm text-muted-foreground">没有匹配值</p> : shown.map(value => <button key={value} type="button" title={value} onClick={() => choose(activeLabel, value)} className={`truncate rounded px-2 py-1.5 text-left font-mono text-sm ${selected[activeLabel] === value ? 'bg-success/10 text-success' : 'hover:bg-muted'}`}>{value}</button>)}</div>
{filter.trim() && !values.includes(filter.trim()) && <button type="button" className="mt-2 rounded border px-2 py-1 text-xs text-primary hover:bg-muted" onClick={() => choose(activeLabel, filter.trim())}>使用“{filter.trim()}”作为值</button>}
<p className="mt-2 text-xs text-muted-foreground">先选 namespace 可查该命名空间全部副本;再选 app / service_name 可缩小到某个服务(无 app 标签的工作负载用 namespace 即可)。候选来自当前时间范围内已写入 Loki 的日志;授权范围仍由服务端限制。</p>
{!activeLabel ? <p className="py-12 text-center text-sm text-muted-foreground">{t('labels.pickHint')}</p> : <><div className="mb-3 flex flex-wrap items-center gap-2"><strong className="text-sm">{t('labels.valuesOf', { label: activeLabel })}</strong><Input aria-label={t('labels.searchAria', { label: activeLabel })} className="h-8 max-w-sm" placeholder={t('labels.searchPlaceholder')} value={filter} onChange={event => setFilter(event.target.value)} /></div>
<label className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground"><input type="checkbox" checked={scoped} onChange={event => setScoped(event.target.checked)} />{t('labels.scoped')}</label>
<div className="grid max-h-48 gap-1 overflow-auto sm:grid-cols-2 xl:grid-cols-3">{loadingValues ? <p className="p-2 text-sm text-muted-foreground">{t('labels.loading')}</p> : shown.length === 0 ? <p className="p-2 text-sm text-muted-foreground">{t('labels.noValues')}</p> : shown.map(value => <button key={value} type="button" title={value} onClick={() => choose(activeLabel, value)} className={`truncate rounded px-2 py-1.5 text-left font-mono text-sm ${selected[activeLabel] === value ? 'bg-success/10 text-success' : 'hover:bg-muted'}`}>{value}</button>)}</div>
{filter.trim() && !values.includes(filter.trim()) && <button type="button" className="mt-2 rounded border px-2 py-1 text-xs text-primary hover:bg-muted" onClick={() => choose(activeLabel, filter.trim())}>{t('labels.useAsValue', { value: filter.trim() })}</button>}
<p className="mt-2 text-xs text-muted-foreground">{t('labels.hint')}</p>
</>}
</div>
</div>}
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ConfirmDialog } from '@/components/ConfirmDialog'
@@ -20,6 +21,7 @@ const emptyForm = (sourceId: number): PolicyForm => ({ name: '', sourceId, label
const ANYONE = 0
export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
const { t } = useTranslation('logs')
const [groups, setGroups] = useState<LogPolicyGroup[]>([])
const [users, setUsers] = useState<User[]>([])
const [form, setForm] = useState<PolicyForm>(() => emptyForm(sources[0]?.id ?? 0))
@@ -42,7 +44,7 @@ export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
setGroups(policies)
setUsers(allUsers.filter(user => user.role !== 'admin'))
setError('')
} catch (err) { setError(err instanceof Error ? err.message : '加载查询策略失败') }
} catch (err) { setError(err instanceof Error ? err.message : t('access.loadFailed')) }
finally { setLoading(false) }
}
useEffect(() => { void reload() }, [])
@@ -50,7 +52,7 @@ export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
async function save() {
if (!form.name.trim() || !form.sourceId || form.userIds.length === 0) {
toast.error('请填写策略组名称,选择数据源,并选择「所有人」或至少一位用户')
toast.error(t('access.validate'))
return
}
setSaving(true)
@@ -60,7 +62,7 @@ export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
else await logsApi.createPolicyGroup(body)
await reload()
setForm(emptyForm(form.sourceId))
} catch (err) { toast.error(err instanceof Error ? err.message : '保存策略组失败') }
} catch (err) { toast.error(err instanceof Error ? err.message : t('access.saveFailed')) }
finally { setSaving(false) }
}
@@ -72,32 +74,32 @@ export function PolicyGroupsPage({ sources }: { sources: LogSource[] }) {
setDeleteTarget(null)
if (form.id === deleteTarget.id) setForm(emptyForm(form.sourceId))
await reload()
} catch (err) { toast.error(err instanceof Error ? err.message : '删除策略组失败') }
} catch (err) { toast.error(err instanceof Error ? err.message : t('access.deleteFailed')) }
finally { setSaving(false) }
}
const visibleUsers = users.filter(user => user.username.toLowerCase().includes(filter.toLowerCase()) || (user.nickname ?? '').toLowerCase().includes(filter.toLowerCase()))
const assignedElsewhere = new Set(groups.filter(group => group.sourceId === form.sourceId && group.id !== form.id).flatMap(group => group.userIds))
const sourceName = (id: number) => sources.find(source => source.id === id)?.name ?? `数据源 #${id}`
const sourceName = (id: number) => sources.find(source => source.id === id)?.name ?? t('access.sourceRef', { id })
return <div className="grid gap-6 xl:grid-cols-[minmax(240px,0.85fr)_minmax(0,2fr)]">
<aside className="rounded-xl border bg-card p-5 space-y-4">
<div className="flex items-center justify-between gap-2"><h2 className="font-semibold">查询策略组</h2><Button size="sm" onClick={() => setForm(emptyForm(sources[0]?.id ?? 0))}>新建</Button></div>
<p className="text-sm text-muted-foreground">一套日志范围可关联多个用户,后续修改会同时生效。</p>
{loading && <p className="text-sm text-muted-foreground">加载中…</p>}
<div className="flex items-center justify-between gap-2"><h2 className="font-semibold">{t('access.listTitle')}</h2><Button size="sm" onClick={() => setForm(emptyForm(sources[0]?.id ?? 0))}>{t('access.create')}</Button></div>
<p className="text-sm text-muted-foreground">{t('access.listHint')}</p>
{loading && <p className="text-sm text-muted-foreground">{t('access.loading')}</p>}
{error && <ErrorState message={error} onRetry={() => void reload()} />}
{!loading && !error && groups.length === 0 && <p className="text-sm text-muted-foreground">还没有策略组</p>}
{!loading && !error && groups.length === 0 && <p className="text-sm text-muted-foreground">{t('access.empty')}</p>}
<div className="space-y-2">{groups.map(group => <button key={group.id} type="button" onClick={() => setForm({ ...group, labels: { ...group.labels }, userIds: [...group.userIds] })} className={`w-full rounded-lg border p-3 text-left transition-colors ${form.id === group.id ? 'border-primary bg-primary/5' : 'hover:bg-muted'}`}>
<strong className="block text-sm">{group.name}</strong><span className="block mt-1 text-xs text-muted-foreground">{sourceName(group.sourceId)} · {group.userIds.includes(ANYONE) ? '所有人' : `${group.userIds.length} 位用户`}</span><span className="block mt-1 truncate font-mono text-xs text-muted-foreground">{JSON.stringify(group.labels)}</span>
<strong className="block text-sm">{group.name}</strong><span className="block mt-1 text-xs text-muted-foreground">{sourceName(group.sourceId)} · {group.userIds.includes(ANYONE) ? t('access.anyone') : t('access.userCount', { count: group.userIds.length })}</span><span className="block mt-1 truncate font-mono text-xs text-muted-foreground">{JSON.stringify(group.labels)}</span>
</button>)}</div>
</aside>
<section className="rounded-xl border bg-card p-5 space-y-6">
<div className="flex items-start justify-between gap-3"><div><h2 className="font-semibold">{form.id ? '编辑策略组' : '新建策略组'}</h2><p className="mt-1 text-sm text-muted-foreground">选择数据源及允许的日志范围,再关联需要复用此策略的用户。</p></div>{form.id && <Button variant="outline" size="sm" className="text-destructive" onClick={() => setDeleteTarget(groups.find(group => group.id === form.id) ?? null)}>删除</Button>}</div>
<div className="grid gap-4 sm:grid-cols-2"><label className="text-sm font-medium">策略组名称<Input className="mt-1" value={form.name} onChange={event => setForm(current => ({ ...current, name: event.target.value }))} placeholder="例如:生产环境 Agent 日志" /></label><label className="text-sm font-medium">数据源<select className="mt-1 block h-9 w-full rounded-md border bg-background px-3" value={form.sourceId} onChange={event => setForm(current => ({ ...current, sourceId: Number(event.target.value), labels: {}, userIds: [] }))}>{sources.length === 0 && <option value={0}>请先添加数据源</option>}{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select></label></div>
<div><h3 className="mb-2 text-sm font-medium">允许查询的日志范围</h3><AccessRuleEditor sourceId={form.sourceId} username={form.name || '策略组'} labels={form.labels} onChange={labels => setForm(current => ({ ...current, labels }))} /><p className="mt-2 text-xs text-muted-foreground">可按命名空间、App、副本 / Pod 精确限制。未选择的标签不作限制。</p></div>
<div className="space-y-3"><div className="flex items-center justify-between gap-2"><h3 className="text-sm font-medium">关联用户 <span className="text-muted-foreground">已选 {form.userIds.includes(ANYONE) ? '所有人(anyone)' : `${form.userIds.length} 位`}</span></h3><div className="flex gap-2"><Button size="sm" variant={form.userIds.includes(ANYONE) ? 'default' : 'outline'} onClick={() => setForm(current => current.userIds.includes(ANYONE) ? { ...current, userIds: [] } : { ...current, userIds: [ANYONE] })}>{form.userIds.includes(ANYONE) ? '✓ 所有人(anyone)' : '所有人(anyone)'}</Button><Button size="sm" variant="outline" disabled={form.userIds.includes(ANYONE)} onClick={() => setForm(current => ({ ...current, userIds: Array.from(new Set([...current.userIds, ...visibleUsers.filter(user => !assignedElsewhere.has(user.id)).map(user => user.id)])) }))}>选择当前筛选</Button><Button size="sm" variant="ghost" onClick={() => setForm(current => ({ ...current, userIds: [] }))}>清空</Button></div></div><p className="text-xs text-muted-foreground">同一用户在同一数据源只能属于一个策略组;已在其他组的用户会置灰。</p><Input aria-label="搜索用户" value={filter} onChange={event => setFilter(event.target.value)} placeholder="搜索用户名或昵称" /><div className="max-h-60 overflow-auto rounded-md border p-2 grid gap-1 sm:grid-cols-2 lg:grid-cols-3">{visibleUsers.map(user => <label key={user.id} className={`flex items-center gap-2 rounded px-2 py-1.5 text-sm ${assignedElsewhere.has(user.id) ? 'text-muted-foreground' : 'hover:bg-muted'}`}><input type="checkbox" disabled={assignedElsewhere.has(user.id) || form.userIds.includes(ANYONE)} checked={form.userIds.includes(user.id)} onChange={event => setForm(current => ({ ...current, userIds: event.target.checked ? [...current.userIds, user.id] : current.userIds.filter(id => id !== user.id) }))} /><span className="truncate">{user.username}</span>{assignedElsewhere.has(user.id) && <span className="text-xs">已分组</span>}</label>)}{visibleUsers.length === 0 && <p className="p-2 text-sm text-muted-foreground">没有匹配用户</p>}</div></div>
<div className="flex justify-end"><Button disabled={saving || !sources.length} onClick={() => void save()}>{saving ? '保存中…' : '保存策略组'}</Button></div>
<div className="flex items-start justify-between gap-3"><div><h2 className="font-semibold">{form.id ? t('access.editTitle') : t('access.createTitle')}</h2><p className="mt-1 text-sm text-muted-foreground">{t('access.formHint')}</p></div>{form.id && <Button variant="outline" size="sm" className="text-destructive" onClick={() => setDeleteTarget(groups.find(group => group.id === form.id) ?? null)}>{t('access.remove')}</Button>}</div>
<div className="grid gap-4 sm:grid-cols-2"><label className="text-sm font-medium">{t('access.nameLabel')}<Input className="mt-1" value={form.name} onChange={event => setForm(current => ({ ...current, name: event.target.value }))} placeholder={t('access.namePlaceholder')} /></label><label className="text-sm font-medium">{t('access.sourceLabel')}<select className="mt-1 block h-9 w-full rounded-md border bg-background px-3" value={form.sourceId} onChange={event => setForm(current => ({ ...current, sourceId: Number(event.target.value), labels: {}, userIds: [] }))}>{sources.length === 0 && <option value={0}>{t('access.sourceEmpty')}</option>}{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select></label></div>
<div><h3 className="mb-2 text-sm font-medium">{t('access.scopeTitle')}</h3><AccessRuleEditor sourceId={form.sourceId} username={form.name || t('access.ownerFallback')} labels={form.labels} onChange={labels => setForm(current => ({ ...current, labels }))} /><p className="mt-2 text-xs text-muted-foreground">{t('access.scopeHint')}</p></div>
<div className="space-y-3"><div className="flex items-center justify-between gap-2"><h3 className="text-sm font-medium">{t('access.usersTitle')} <span className="text-muted-foreground">{form.userIds.includes(ANYONE) ? t('access.selectedAnyone') : t('access.selectedCount', { count: form.userIds.length })}</span></h3><div className="flex gap-2"><Button size="sm" variant={form.userIds.includes(ANYONE) ? 'default' : 'outline'} onClick={() => setForm(current => current.userIds.includes(ANYONE) ? { ...current, userIds: [] } : { ...current, userIds: [ANYONE] })}>{form.userIds.includes(ANYONE) ? t('access.anyoneActive') : t('access.anyoneToggle')}</Button><Button size="sm" variant="outline" disabled={form.userIds.includes(ANYONE)} onClick={() => setForm(current => ({ ...current, userIds: Array.from(new Set([...current.userIds, ...visibleUsers.filter(user => !assignedElsewhere.has(user.id)).map(user => user.id)])) }))}>{t('access.selectFiltered')}</Button><Button size="sm" variant="ghost" onClick={() => setForm(current => ({ ...current, userIds: [] }))}>{t('access.clear')}</Button></div></div><p className="text-xs text-muted-foreground">{t('access.usersHint')}</p><Input aria-label={t('access.searchAria')} value={filter} onChange={event => setFilter(event.target.value)} placeholder={t('access.searchPlaceholder')} /><div className="max-h-60 overflow-auto rounded-md border p-2 grid gap-1 sm:grid-cols-2 lg:grid-cols-3">{visibleUsers.map(user => <label key={user.id} className={`flex items-center gap-2 rounded px-2 py-1.5 text-sm ${assignedElsewhere.has(user.id) ? 'text-muted-foreground' : 'hover:bg-muted'}`}><input type="checkbox" disabled={assignedElsewhere.has(user.id) || form.userIds.includes(ANYONE)} checked={form.userIds.includes(user.id)} onChange={event => setForm(current => ({ ...current, userIds: event.target.checked ? [...current.userIds, user.id] : current.userIds.filter(id => id !== user.id) }))} /><span className="truncate">{user.username}</span>{assignedElsewhere.has(user.id) && <span className="text-xs">{t('access.grouped')}</span>}</label>)}{visibleUsers.length === 0 && <p className="p-2 text-sm text-muted-foreground">{t('access.noMatch')}</p>}</div></div>
<div className="flex justify-end"><Button disabled={saving || !sources.length} onClick={() => void save()}>{saving ? t('access.saving') : t('access.save')}</Button></div>
</section>
<ConfirmDialog open={deleteTarget !== null} onOpenChange={open => { if (!open) setDeleteTarget(null) }} name={deleteTarget?.name} title="删除查询策略组" description={`删除「${deleteTarget?.name ?? ''}」后,关联用户将失去由该组授予的数据源访问权限。`} loading={saving} onConfirm={() => void remove()} />
<ConfirmDialog open={deleteTarget !== null} onOpenChange={open => { if (!open) setDeleteTarget(null) }} name={deleteTarget?.name} title={t('access.deleteTitle')} description={t('access.deleteDesc', { name: deleteTarget?.name ?? '' })} loading={saving} onConfirm={() => void remove()} />
</div>
}
@@ -1,11 +1,13 @@
import { Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
export function QueryEmptyState({ searched, hasSource, onRetry }: { searched: boolean; hasSource: boolean; onRetry: () => void }) {
const { t } = useTranslation('logs')
return <div className="flex min-h-56 flex-col items-center justify-center gap-3 rounded-lg bg-muted/20 px-4 text-center">
<div className="rounded-full bg-muted p-3"><Search className="size-6 text-muted-foreground" /></div>
<p className="font-medium">{!hasSource ? '暂无可查询的数据源' : searched ? '未找到匹配的日志记录' : '输入 LogQL 并运行查询'}</p>
<p className="text-sm text-muted-foreground">{!hasSource ? '请联系管理员添加或授权数据源' : searched ? '当前时间范围和筛选条件下没有匹配结果' : '也可以使用 Label 浏览器快速构建查询'}</p>
{hasSource && searched && <Button size="sm" variant="outline" onClick={onRetry}>重新查询</Button>}
<p className="font-medium">{!hasSource ? t('results.noSourceTitle') : searched ? t('results.noMatchTitle') : t('results.idleTitle')}</p>
<p className="text-sm text-muted-foreground">{!hasSource ? t('results.noSourceHint') : searched ? t('results.noMatchHint') : t('results.idleHint')}</p>
{hasSource && searched && <Button size="sm" variant="outline" onClick={onRetry}>{t('results.retry')}</Button>}
</div>
}
@@ -1,4 +1,5 @@
import { memo, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { logsApi } from '@/api/logs'
import { QuickQueryTemplates } from './QuickQueryTemplates'
@@ -29,6 +30,7 @@ interface QueryInputProps {
/** LogQL editor with syntax colors and label/operator suggestions. */
export const QueryInput = memo(function QueryInput({ value, onChange, onSubmit, sourceId, history }: QueryInputProps) {
const { t } = useTranslation('logs')
const [labels, setLabels] = useState<string[]>([])
const [hints, setHints] = useState<string[]>([])
const [active, setActive] = useState(0)
@@ -125,7 +127,7 @@ export const QueryInput = memo(function QueryInput({ value, onChange, onSubmit,
<div className="relative h-10 overflow-hidden rounded-md border border-input bg-background focus-within:ring-2 focus-within:ring-ring">
<pre ref={overlay} aria-hidden className="pointer-events-none absolute inset-0 m-0 overflow-hidden whitespace-pre p-2.5 font-mono text-sm leading-5 text-foreground">{colorizedLogQL(value)}{'\u200b'}</pre>
{!value && <span aria-hidden className="pointer-events-none absolute inset-0 flex items-center px-2.5 font-mono text-sm text-muted-foreground">{'{app="service"} |= "error"'}</span>}
<textarea ref={editor} aria-label="LogQL 查询语句" placeholder={'{app="service"} |= "error"'} value={value} rows={1} wrap="off" spellCheck={false} autoComplete="off"
<textarea ref={editor} aria-label={t('editor.aria')} placeholder={'{app="service"} |= "error"'} value={value} rows={1} wrap="off" spellCheck={false} autoComplete="off"
onFocus={() => setAssistantOpen(true)}
onChange={event => { onChange(event.target.value); computeHints(event.target.value, event.target.selectionStart) }}
onClick={event => computeHints(event.currentTarget.value, event.currentTarget.selectionStart)}
@@ -141,18 +143,18 @@ export const QueryInput = memo(function QueryInput({ value, onChange, onSubmit,
/>
</div>
{hints.length > 0 && <div role="listbox" className="absolute left-0 right-0 top-full z-40 mt-1 max-h-56 overflow-auto rounded-md border bg-popover p-1 shadow-lg">
{hintKind === 'value' && <p className="px-2 py-1 text-xs text-muted-foreground">{currentLabel} 的值</p>}
{hintKind === 'value' && <p className="px-2 py-1 text-xs text-muted-foreground">{t('editor.valuesOf', { label: currentLabel })}</p>}
{hints.map((hint, index) => <button role="option" aria-selected={index === active} type="button" key={`${hint}-${index}`} onMouseDown={event => { event.preventDefault(); applyHint(hint) }} className={`block w-full rounded px-2 py-1.5 text-left font-mono text-xs ${index === active ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`}>{hint}</button>)}
</div>}
{assistantOpen && hints.length === 0 && <div className="absolute left-0 top-full z-30 mt-1 w-[min(90vw,760px)] max-w-full rounded-lg border bg-popover p-3 shadow-xl">
<div className="mb-2 flex gap-1 border-b pb-2">
<button type="button" className={`rounded px-3 py-1.5 text-xs ${assistantTab === 'quick' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted'}`} onClick={() => setAssistantTab('quick')}>快捷 LogQL</button>
<button type="button" className={`rounded px-3 py-1.5 text-xs ${assistantTab === 'history' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted'}`} onClick={() => setAssistantTab('history')}>查询历史 {history.length > 0 && `(${history.length})`}</button>
<span className="ml-auto self-center text-xs text-muted-foreground">Esc 关闭</span>
<button type="button" className={`rounded px-3 py-1.5 text-xs ${assistantTab === 'quick' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted'}`} onClick={() => setAssistantTab('quick')}>{t('editor.quickTab')}</button>
<button type="button" className={`rounded px-3 py-1.5 text-xs ${assistantTab === 'history' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted'}`} onClick={() => setAssistantTab('history')}>{history.length > 0 ? t('editor.historyTabCount', { count: history.length }) : t('editor.historyTab')}</button>
<span className="ml-auto self-center text-xs text-muted-foreground">{t('editor.escClose')}</span>
</div>
{assistantTab === 'quick' ? <QuickQueryTemplates compact sourceId={sourceId} query={value} onQueryChange={chooseQuery} /> :
<div className="max-h-64 overflow-y-auto">
{history.length === 0 ? <p className="px-2 py-5 text-center text-xs text-muted-foreground">运行查询后,常用 LogQL 会出现在这里</p> : history.map((item, index) => <button key={`${item}-${index}`} type="button" className="block w-full truncate rounded px-2 py-2 text-left font-mono text-xs hover:bg-muted" title={item} onClick={() => chooseQuery(item)}>{item}</button>)}
{history.length === 0 ? <p className="px-2 py-5 text-center text-xs text-muted-foreground">{t('editor.historyEmpty')}</p> : history.map((item, index) => <button key={`${item}-${index}`} type="button" className="block w-full truncate rounded px-2 py-2 text-left font-mono text-xs hover:bg-muted" title={item} onClick={() => chooseQuery(item)}>{item}</button>)}
</div>}
</div>}
</div>
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { logsApi } from '@/api/logs'
import { SearchableValueSelect } from './SearchableValueSelect'
@@ -21,6 +22,7 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
onQueryChange: (query: string) => void
compact?: boolean
}) {
const { t } = useTranslation('logs')
const [namespace, setNamespace] = useState('')
const [app, setApp] = useState('')
const [pod, setPod] = useState('')
@@ -30,7 +32,7 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
/**
* 实际用来标识「应用」的标签名。
*
* K8s 惯例是 app 标签,但很多集群(如本环境)Deployment 模板没打 app,
* Kubernetes 惯例是 app 标签,但很多集群(如本环境)Deployment 模板没打 app,
* 只有 Kuboard 注入的 k8s.kuboard.cn/name,此时 Loki 里 app 只剩零星几个值。
* Promtail 会从容器名生成 service_name / container,覆盖度更高,
* 因此按 app → service_name → container 逐级回退。
@@ -153,47 +155,47 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
const hasSelector = extractSelector(query).replace(/[{}\s]/g, '').length > 0
const hasScope = Boolean(app.trim() || namespace.trim() || pod.trim() || hasSelector)
return <section className="space-y-2" aria-label="快捷查询模板">
{!compact && <h2 className="text-sm font-medium">快捷生成 LogQL</h2>}
{!compact && <p className="text-xs text-muted-foreground">先选 namespace,再选 app,最后选副本 pod;app 候选只来自所选命名空间。</p>}
return <section className="space-y-2" aria-label={t('templates.aria')}>
{!compact && <h2 className="text-sm font-medium">{t('templates.title')}</h2>}
{!compact && <p className="text-xs text-muted-foreground">{t('templates.hint')}</p>}
<div className="flex flex-wrap items-end gap-2">
{/* 用 SearchableValueSelect(点击即展开列表 + 可搜索),
原生 datalist 只在输入时才提示、样式弱,容易让人以为「下拉是空的」 */}
<SearchableValueSelect
label="命名空间"
label={t('templates.namespace')}
values={namespaceOptions}
value={namespace}
onChange={chooseNamespace}
placeholder={`请选择(共 ${namespaceOptions.length} 个)`}
placeholder={t('templates.selectCount', { count: namespaceOptions.length })}
allowEmpty
loading={!namespaceOptions.length}
/>
<SearchableValueSelect
label={appLabel === 'app' ? 'App' : `App(${appLabel})`}
label={appLabel === 'app' ? t('templates.app') : t('templates.appWithKey', { key: appLabel })}
values={apps}
value={app}
onChange={chooseApp}
placeholder={apps.length ? `请选择(共 ${apps.length} 个)` : namespace ? '该范围内无 app' : '先选命名空间可缩小范围'}
placeholder={apps.length ? t('templates.selectCount', { count: apps.length }) : namespace ? t('templates.noApp') : t('templates.pickNsFirst')}
allowEmpty
disabled={!sourceId}
/>
<SearchableValueSelect
label="副本 / Pod"
label={t('templates.pod')}
values={pods}
value={pod}
onChange={setPod}
placeholder={pods.length ? `请选择(共 ${pods.length} 个)` : app ? '该 app 下无副本' : '可选'}
placeholder={pods.length ? t('templates.selectCount', { count: pods.length }) : app ? t('templates.noPod') : t('templates.optional')}
allowEmpty
disabled={!sourceId}
/>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('app')}>查询该范围日志</Button>
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('error')}>错误 error</Button>
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('warn')}>告警 warn</Button>
<Button size="sm" variant="outline" disabled={!pod.trim()} onClick={() => generate('pod')}>指定副本日志</Button>
<Button size="sm" variant="outline" onClick={() => generate('ingress')}>Ingress 访问日志</Button>
{!hasScope && <span className="self-center text-xs text-muted-foreground">先选 namespace,再选 app 或 pod,也可在 Label 浏览器选值</span>}
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('app')}>{t('templates.scopeLogs')}</Button>
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('error')}>{t('templates.errorLogs')}</Button>
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('warn')}>{t('templates.warnLogs')}</Button>
<Button size="sm" variant="outline" disabled={!pod.trim()} onClick={() => generate('pod')}>{t('templates.podLogs')}</Button>
<Button size="sm" variant="outline" onClick={() => generate('ingress')}>{t('templates.ingressLogs')}</Button>
{!hasScope && <span className="self-center text-xs text-muted-foreground">{t('templates.scopeHint')}</span>}
</div>
</section>
}
@@ -1,4 +1,5 @@
import { useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Input } from '@/components/ui/input'
interface SearchableValueSelectProps {
@@ -14,6 +15,7 @@ interface SearchableValueSelectProps {
/** A searchable single-value combobox for Loki label values. */
export function SearchableValueSelect({ label, values, value, onChange, placeholder, allowEmpty = false, loading = false, disabled = false }: SearchableValueSelectProps) {
const { t } = useTranslation('logs')
const [open, setOpen] = useState(false)
const [filter, setFilter] = useState('')
const [active, setActive] = useState(0)
@@ -46,7 +48,7 @@ export function SearchableValueSelect({ label, values, value, onChange, placehol
aria-controls={`${id}-options`}
disabled={disabled}
value={open ? filter : value}
placeholder={placeholder ?? '搜索并选择'}
placeholder={placeholder ?? t('select.placeholder')}
onFocus={() => { setFilter(''); setOpen(true) }}
onChange={event => { setFilter(event.target.value); setActive(0); setOpen(true) }}
onKeyDown={event => {
@@ -57,8 +59,8 @@ export function SearchableValueSelect({ label, values, value, onChange, placehol
}}
/>
{open && <div id={`${id}-options`} role="listbox" className="absolute z-30 mt-1 max-h-56 w-full overflow-auto rounded-md border bg-popover p-1 shadow-lg">
{allowEmpty && <button type="button" role="option" aria-selected={!value} className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-muted" onMouseDown={event => event.preventDefault()} onClick={() => choose('')}>不限</button>}
{loading ? <p className="px-2 py-1.5 text-sm text-muted-foreground">加载中…</p> : choices.length === 0 ? <p className="px-2 py-1.5 text-sm text-muted-foreground">无匹配值</p> : choices.slice(0, 200).map((item, index) =>
{allowEmpty && <button type="button" role="option" aria-selected={!value} className="block w-full rounded px-2 py-1.5 text-left text-sm hover:bg-muted" onMouseDown={event => event.preventDefault()} onClick={() => choose('')}>{t('select.any')}</button>}
{loading ? <p className="px-2 py-1.5 text-sm text-muted-foreground">{t('select.loading')}</p> : choices.length === 0 ? <p className="px-2 py-1.5 text-sm text-muted-foreground">{t('select.noMatch')}</p> : choices.slice(0, 200).map((item, index) =>
<button key={item} type="button" role="option" aria-selected={item === value} className={`block w-full rounded px-2 py-1.5 text-left text-sm ${index === active ? 'bg-primary/10 text-primary' : 'hover:bg-muted'}`} onMouseDown={event => event.preventDefault()} onClick={() => choose(item)}>{item}</button>
)}
</div>}
@@ -1,18 +1,20 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CalendarRange, ChevronDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { getUser } from '@/lib/auth-storage'
// 预设时间范围:unit 决定文案键(minutes/hours),count 用于插值与复数
const presets = [
{ label: '最近 5 分钟', ms: 300000 },
{ label: '最近 15 分钟', ms: 900000 },
{ label: '最近 1 小时', ms: 3600000 },
{ label: '最近 6 小时', ms: 21600000 },
{ label: '最近 24 小时', ms: 86400000 },
]
{ unit: 'minutes', count: 5, ms: 300000 },
{ unit: 'minutes', count: 15, ms: 900000 },
{ unit: 'hours', count: 1, ms: 3600000 },
{ unit: 'hours', count: 6, ms: 21600000 },
{ unit: 'hours', count: 24, ms: 86400000 },
] as const
const localTime = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
const historyKey = () => `opsxloki.logs.range-history.${getUser()?.id ?? 'anonymous'}`
const historyKey = () => `lokistack.logs.range-history.${getUser()?.id ?? 'anonymous'}`
type SavedRange = { start: string; end: string }
function readHistory(): SavedRange[] {
@@ -23,6 +25,7 @@ function readHistory(): SavedRange[] {
}
export function TimeRangePicker({ start, end, onChange }: { start: string; end: string; onChange: (start: string, end: string) => void }) {
const { t } = useTranslation('logs')
const [open, setOpen] = useState(false)
const [draftStart, setDraftStart] = useState(start)
const [draftEnd, setDraftEnd] = useState(end)
@@ -46,8 +49,11 @@ export function TimeRangePicker({ start, end, onChange }: { start: string; end:
try { localStorage.setItem(historyKey(), JSON.stringify(next)) } catch { /* Storage may be unavailable. */ }
}
const presetLabel = (item: (typeof presets)[number]) => item.unit === 'minutes'
? t('range.lastMinutes', { count: item.count })
: t('range.lastHours', { count: item.count })
const activePreset = selection.start === start && selection.end === end ? presets.find(item => item.ms === selection.ms) : undefined
const label = activePreset?.label ?? '自定义时间'
const label = activePreset ? presetLabel(activePreset) : t('range.custom')
const rangeText = `${new Date(start).toLocaleString()} ~ ${new Date(end).toLocaleString()}`
return <div ref={root} className="relative min-w-0">
@@ -55,14 +61,14 @@ export function TimeRangePicker({ start, end, onChange }: { start: string; end:
<CalendarRange className="size-4 shrink-0" /><span className="shrink-0 font-medium">{label}</span><span className="hidden truncate font-normal text-muted-foreground xl:inline">{rangeText}</span><ChevronDown className="size-4 shrink-0" />
</Button>
{open && <div className="absolute left-0 top-full z-40 mt-1 w-[min(92vw,520px)] rounded-lg border bg-popover p-4 shadow-xl">
<h2 className="text-sm font-medium">选择时间范围</h2>
<div className="mt-3 grid grid-cols-2 gap-1 sm:grid-cols-3">{presets.map(item => <Button key={item.ms} type="button" size="sm" variant={activePreset?.ms === item.ms ? 'secondary' : 'ghost'} className="justify-start" onClick={() => { const now = Date.now(); choose(localTime(new Date(now - item.ms)), localTime(new Date(now)), item.ms) }}>{item.label}</Button>)}</div>
<h2 className="text-sm font-medium">{t('range.title')}</h2>
<div className="mt-3 grid grid-cols-2 gap-1 sm:grid-cols-3">{presets.map(item => <Button key={item.ms} type="button" size="sm" variant={activePreset?.ms === item.ms ? 'secondary' : 'ghost'} className="justify-start" onClick={() => { const now = Date.now(); choose(localTime(new Date(now - item.ms)), localTime(new Date(now)), item.ms) }}>{presetLabel(item)}</Button>)}</div>
<div className="mt-3 grid gap-2 border-t pt-3 sm:grid-cols-2">
<label className="text-xs text-muted-foreground">开始时间<Input className="mt-1 h-8" type="datetime-local" step={1} value={draftStart} onChange={event => setDraftStart(event.target.value)} /></label>
<label className="text-xs text-muted-foreground">结束时间<Input className="mt-1 h-8" type="datetime-local" step={1} value={draftEnd} onChange={event => setDraftEnd(event.target.value)} /></label>
<label className="text-xs text-muted-foreground">{t('range.start')}<Input className="mt-1 h-8" type="datetime-local" step={1} value={draftStart} onChange={event => setDraftStart(event.target.value)} /></label>
<label className="text-xs text-muted-foreground">{t('range.end')}<Input className="mt-1 h-8" type="datetime-local" step={1} value={draftEnd} onChange={event => setDraftEnd(event.target.value)} /></label>
</div>
<div className="mt-2 flex justify-end"><Button type="button" size="sm" disabled={!(new Date(draftStart).getTime() < new Date(draftEnd).getTime())} onClick={() => choose(draftStart, draftEnd)}>应用时间</Button></div>
{history.length > 0 && <div className="mt-3 border-t pt-3"><h3 className="mb-1 text-xs font-medium text-muted-foreground">最近使用</h3><div className="max-h-32 space-y-0.5 overflow-y-auto">{history.map(item => <button type="button" key={`${item.start}-${item.end}`} className="block w-full truncate rounded px-2 py-1.5 text-left text-xs hover:bg-muted" onClick={() => choose(item.start, item.end)}>{new Date(item.start).toLocaleString()} ~ {new Date(item.end).toLocaleString()}</button>)}</div></div>}
<div className="mt-2 flex justify-end"><Button type="button" size="sm" disabled={!(new Date(draftStart).getTime() < new Date(draftEnd).getTime())} onClick={() => choose(draftStart, draftEnd)}>{t('range.apply')}</Button></div>
{history.length > 0 && <div className="mt-3 border-t pt-3"><h3 className="mb-1 text-xs font-medium text-muted-foreground">{t('range.recent')}</h3><div className="max-h-32 space-y-0.5 overflow-y-auto">{history.map(item => <button type="button" key={`${item.start}-${item.end}`} className="block w-full truncate rounded px-2 py-1.5 text-left text-xs hover:bg-muted" onClick={() => choose(item.start, item.end)}>{new Date(item.start).toLocaleString()} ~ {new Date(item.end).toLocaleString()}</button>)}</div></div>}
</div>}
</div>
}
+56 -53
View File
@@ -1,10 +1,14 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { PageContainer } from '@/components/PageContainer'
import { ErrorState } from '@/components/ErrorState'
import { getUser } from '@/lib/auth-storage'
import i18n from '@/i18n'
import zhLogs from '@/i18n/locales/zh/logs'
import enLogs from '@/i18n/locales/en/logs'
import { logsApi, type LogEntry, type LogQuery, type LogSource } from '@/api/logs'
import { clusterApi } from '@/api'
import type { Cluster } from '@/types'
@@ -19,12 +23,17 @@ import { LogResultsPanel } from './components/LogResultsPanel'
import { literalFilters } from './query-utils'
import { readQueryHistory, rememberQuery } from './query-history'
// 注册 logs 命名空间(域词典集中在页面入口挂载,域内组件统一 useTranslation('logs'))
i18n.addResourceBundle('zh', 'logs', zhLogs)
i18n.addResourceBundle('en', 'logs', enLogs)
const toLocal = (date: Date) => new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19)
const ns = (ms: number) => Math.round(ms).toString() + '000000'
type Bucket = { timestamp: string; count: number }
export default function LogsPage() {
const { t } = useTranslation('logs')
const admin = getUser()?.role === 'admin'
const [sources, setSources] = useState<LogSource[]>([])
const [sourceId, setSourceId] = useState(0)
@@ -55,20 +64,20 @@ export default function LogsPage() {
const [clusters, setClusters] = useState<Cluster[]>([])
useEffect(() => { if (admin) clusterApi.list({ page: 1, pageSize: 100 }).then(r => setClusters(r.list)).catch(() => setClusters([])) }, [admin])
async function reloadSources() {
const reloadSources = useCallback(async () => {
try {
const result = await logsApi.sources()
setSources(result.list)
setSourceId(current => result.list.some(s => s.id === current) ? current : (result.list.find(s => s.isDefault)?.id ?? result.list[0]?.id ?? 0))
} catch (e) { setError(e instanceof Error ? e.message : '加载数据源失败') }
}
useEffect(() => { void reloadSources() }, [])
} catch (e) { setError(e instanceof Error ? e.message : t('source.loadFailed')) }
}, [t])
useEffect(() => { void reloadSources() }, [reloadSources])
useEffect(() => { requestVersion.current++; setEntries([]); setBuckets([]); setHistogramLoading(false); setHistogramError(false); setCursor(''); setPageIndex(0); setPageCursors(['']); setSearched(false); setLoading(false) }, [sourceId])
function range(): { start: number; end: number } | null {
const a = new Date(start).getTime(), b = new Date(end).getTime()
if (!Number.isFinite(a) || !Number.isFinite(b) || a >= b) { setError('请选择有效的起止时间'); return null }
if (!Number.isFinite(a) || !Number.isFinite(b) || a >= b) { setError(t('toolbar.rangeInvalid')); return null }
return { start: Number(ns(a)), end: Number(ns(b)) }
}
async function search(nextCursor = '', quiet = false, selectedRange?: { start: number; end: number }, overrideQuery?: string, targetPage?: number) {
@@ -102,7 +111,7 @@ export default function LogsPage() {
}).catch(() => { if (version === requestVersion.current) setHistogramError(true) })
.finally(() => { if (version === requestVersion.current) setHistogramLoading(false) })
}
} catch (e) { if (!quiet && version === requestVersion.current) setError(e instanceof Error ? e.message : '查询失败') }
} catch (e) { if (!quiet && version === requestVersion.current) setError(e instanceof Error ? e.message : t('toolbar.queryFailed')) }
finally { if (version === requestVersion.current) { setLoading(false); setLoadingStartedAt(null) } }
}
useEffect(() => {
@@ -130,12 +139,12 @@ export default function LogsPage() {
try {
const result = await logsApi.context({ sourceId, timestampNs: row.timestampNs, labels: row.labels })
setContext({ row, before: result.before.slice().reverse(), after: result.after })
} catch (e) { toast.error(e instanceof Error ? e.message : '读取上下文失败') }
} catch (e) { toast.error(e instanceof Error ? e.message : t('context.loadFailed')) }
}
function saveSource() {
setSaving(true)
const action = sourceForm.id ? logsApi.updateSource(sourceForm.id, sourceForm) : logsApi.createSource(sourceForm)
action.then(() => { setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] }); void reloadSources() }).catch(e => toast.error(e instanceof Error ? e.message : '保存失败')).finally(() => setSaving(false))
action.then(() => { setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] }); void reloadSources() }).catch(e => toast.error(e instanceof Error ? e.message : t('source.saveFailed'))).finally(() => setSaving(false))
}
const highlightTerms = useMemo(() => literalFilters(executedQuery), [executedQuery])
const applyDomainFilter = (domain: string) => {
@@ -148,15 +157,15 @@ export default function LogsPage() {
void search('', false, { start: Number(ns(from)), end: Number(ns(to)) })
}
const pageTitle = tab === 'query' ? 'Loki 查询' : tab === 'access' ? '查询授权' : '数据源管理'
const description = tab === 'query' ? '按日志流和时间范围检索;拖动时间线缩小范围,点日志查看前后文。' : tab === 'access' ? '用策略组复用日志范围,并一次关联多个用户。' : '配置 Loki 连接与认证方式。'
const pageTitle = tab === 'query' ? t('tabs.query') : tab === 'access' ? t('tabs.access') : t('tabs.sources')
const description = tab === 'query' ? t('tabs.descQuery') : tab === 'access' ? t('tabs.descAccess') : t('tabs.descSources')
return (
<div className="-m-4 md:-m-6">
<nav aria-label="日志中心导航" className="flex flex-wrap gap-1 border-b bg-card px-4 py-3 md:px-6">
<Button variant={tab === 'query' ? 'default' : 'ghost'} onClick={() => setTab('query')}>Loki 查询</Button>
{admin && <Button variant={tab === 'access' ? 'default' : 'ghost'} onClick={() => setTab('access')}>查询授权</Button>}
{admin && <Button variant={tab === 'sources' ? 'default' : 'ghost'} onClick={() => setTab('sources')}>数据源管理</Button>}
<nav aria-label={t('nav.aria')} className="flex flex-wrap gap-1 border-b bg-card px-4 py-3 md:px-6">
<Button variant={tab === 'query' ? 'default' : 'ghost'} onClick={() => setTab('query')}>{t('tabs.query')}</Button>
{admin && <Button variant={tab === 'access' ? 'default' : 'ghost'} onClick={() => setTab('access')}>{t('tabs.access')}</Button>}
{admin && <Button variant={tab === 'sources' ? 'default' : 'ghost'} onClick={() => setTab('sources')}>{t('tabs.sources')}</Button>}
</nav>
<div className="p-4 md:p-6">
<PageContainer title={tab === 'query' ? undefined : pageTitle} description={description}>
@@ -165,14 +174,11 @@ export default function LogsPage() {
{tab === 'query' && <section className="space-y-4">
{sources.length === 0 && (
<div className="rounded-xl border border-warning/40 bg-warning/10 p-4 text-sm">
<p className="font-medium text-warning">还没有可用的 Loki 数据源</p>
<p className="mt-1 text-muted-foreground">
请先到「数据源管理」关联 Loki:集群内可直接选「集群服务」(命名空间 / Service / 端口,无需 Ingress);
跨集群或公网查询,请先给 Loki 配置 Ingress 域名,再用「URL 直连」填写该域名。未关联数据源无法查询。
</p>
<p className="font-medium text-warning">{t('source.missingTitle')}</p>
<p className="mt-1 text-muted-foreground">{t('source.missingBody')}</p>
{admin && (
<Button size="sm" className="mt-2" variant="outline" onClick={() => setTab('sources')}>
去配置数据源
{t('source.goConfigure')}
</Button>
)}
</div>
@@ -180,24 +186,21 @@ export default function LogsPage() {
<div className="flex flex-wrap items-center gap-2">
<span className="flex h-10 items-center gap-2 rounded-md border bg-muted/40 px-3 text-sm font-semibold text-info"><span className="size-2 rounded-full bg-info" />LogQL</span>
<div className="min-w-[260px] flex-1"><QueryInput key={sourceId} value={query} onChange={setQuery} onSubmit={() => void search()} sourceId={sourceId} history={queryHistory} /></div>
<Button className="h-10 bg-success text-success-foreground hover:bg-success/90" disabled={!sourceId || !query.trim() || loading} onClick={() => void search()}>{loading ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Play className="mr-1 size-4" />}{loading ? '查询中…' : '查询'}</Button>
<Button className="h-10" variant={tail ? 'destructive' : 'outline'} onClick={() => setTail(!tail)} disabled={!sourceId || (!tail && !query.trim())}><Radio className="mr-1 size-4" />{tail ? '停止 Tail' : 'Tail'}</Button>
<Button className="h-10 bg-success text-success-foreground hover:bg-success/90" disabled={!sourceId || !query.trim() || loading} onClick={() => void search()}>{loading ? <Loader2 className="mr-1 size-4 animate-spin" /> : <Play className="mr-1 size-4" />}{loading ? t('toolbar.running') : t('toolbar.run')}</Button>
<Button className="h-10" variant={tail ? 'destructive' : 'outline'} onClick={() => setTail(!tail)} disabled={!sourceId || (!tail && !query.trim())}><Radio className="mr-1 size-4" />{tail ? t('toolbar.tailStop') : t('toolbar.tail')}</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<TimeRangePicker start={start} end={end} onChange={(nextStart, nextEnd) => { setStart(nextStart); setEnd(nextEnd); setTail(false) }} />
<select aria-label="数据源" className="h-9 min-w-40 rounded-md border bg-background px-3 text-sm text-foreground" value={sourceId} onChange={event => setSourceId(Number(event.target.value))}>{sources.length === 0 && <option value={0}>暂无可用数据源</option>}{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select>
<label className="flex items-center gap-1 text-xs text-muted-foreground">每页条数<Input type="number" min={1} max={1000} value={limit} onChange={event => setLimit(Math.min(1000, Math.max(1, Number(event.target.value))))} className="h-9 w-24" /></label>
<span className="text-xs text-muted-foreground">每页最多 1000 条;翻页只保留当前页。大范围先看时间线,再拖选高峰时段。</span>
<select aria-label={t('toolbar.sourceAria')} className="h-9 min-w-40 rounded-md border bg-background px-3 text-sm text-foreground" value={sourceId} onChange={event => setSourceId(Number(event.target.value))}>{sources.length === 0 && <option value={0}>{t('toolbar.sourceEmptyOption')}</option>}{sources.map(source => <option key={source.id} value={source.id}>{source.name}</option>)}</select>
<label className="flex items-center gap-1 text-xs text-muted-foreground">{t('toolbar.limitLabel')}<Input type="number" min={1} max={1000} value={limit} onChange={event => setLimit(Math.min(1000, Math.max(1, Number(event.target.value))))} className="h-9 w-24" /></label>
<span className="text-xs text-muted-foreground">{t('toolbar.limitHint')}</span>
</div>
<LokiLabelBrowser key={sourceId} sourceId={sourceId} query={query} onQueryChange={setQuery} start={start} end={end} />
</section>}
{admin && tab === 'access' && <PolicyGroupsPage sources={sources} />}
{admin && tab === 'sources' && <section className="rounded-xl border bg-card p-4 space-y-4" aria-label="数据源管理">
<h2 className="font-semibold">Loki 数据源</h2>
<p className="text-xs text-muted-foreground">
提示:Loki 部署在集群内时,可直接添加「集群服务」数据源(无需 Ingress);跨集群 / 公网查询需先为 Loki 配置
Ingress 域名,再用「URL 直连」关联。未关联数据源时,查询页无法使用。
</p>
{admin && tab === 'sources' && <section className="rounded-xl border bg-card p-4 space-y-4" aria-label={t('source.sectionAria')}>
<h2 className="font-semibold">{t('source.heading')}</h2>
<p className="text-xs text-muted-foreground">{t('source.hint')}</p>
{/* 列表:平台部署自动注册的固定展示(🔒 不可编辑);手动添加的可编辑 */}
<div className="space-y-2">
@@ -209,9 +212,9 @@ export default function LogsPage() {
<Lock className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<span className="font-medium">{source.name}</span>
<span className="text-muted-foreground truncate">
{cluster ? `集群 ${cluster.name}` : ''}{source.namespace ? ` · ${source.namespace}` : ''} · {source.service || 'loki'}:{source.port || 3100}
{cluster ? t('source.managedCluster', { name: cluster.name }) : ''}{source.namespace ? ` · ${source.namespace}` : ''} · {source.service || 'loki'}:{source.port || 3100}
</span>
<span className="ml-auto text-xs text-muted-foreground flex-shrink-0">平台部署 · 随部署管理</span>
<span className="ml-auto text-xs text-muted-foreground flex-shrink-0">{t('source.managedBadge')}</span>
</div>
) : (
<Button key={source.id} size="sm" variant="outline" onClick={() => setSourceForm({ ...source, headers: Object.fromEntries((source.headerNames ?? []).map(name => [name, ''])) })}>
@@ -219,38 +222,38 @@ export default function LogsPage() {
</Button>
)
})}
<Button size="sm" variant="outline" onClick={() => setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] })}>+ 手动添加</Button>
<Button size="sm" variant="outline" onClick={() => setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] })}>{t('source.add')}</Button>
</div>
{/* 表单:URL 直连 或 集群服务(集群名下拉,服务默认 loki) */}
<div className="grid gap-2 sm:grid-cols-2">
<label>名称<Input value={sourceForm.name ?? ''} onChange={e => setSourceForm({ ...sourceForm, name: e.target.value })} /></label>
<label>{t('source.nameLabel')}<Input value={sourceForm.name ?? ''} onChange={e => setSourceForm({ ...sourceForm, name: e.target.value })} /></label>
<div className="flex gap-2 items-end">
<button type="button" className={`px-3 py-1.5 rounded-md text-sm border ${sourceForm.targetType === 'url' ? 'border-primary bg-primary/10 text-primary' : 'border-border'}`} onClick={() => setSourceForm({ ...sourceForm, targetType: 'url' })}>URL 直连</button>
<button type="button" className={`px-3 py-1.5 rounded-md text-sm border ${sourceForm.targetType === 'cluster-svc' ? 'border-primary bg-primary/10 text-primary' : 'border-border'}`} onClick={() => setSourceForm({ ...sourceForm, targetType: 'cluster-svc', service: sourceForm.service || 'loki', port: sourceForm.port || 3100 })}>集群服务</button>
<button type="button" className={`px-3 py-1.5 rounded-md text-sm border ${sourceForm.targetType === 'url' ? 'border-primary bg-primary/10 text-primary' : 'border-border'}`} onClick={() => setSourceForm({ ...sourceForm, targetType: 'url' })}>{t('source.targetUrl')}</button>
<button type="button" className={`px-3 py-1.5 rounded-md text-sm border ${sourceForm.targetType === 'cluster-svc' ? 'border-primary bg-primary/10 text-primary' : 'border-border'}`} onClick={() => setSourceForm({ ...sourceForm, targetType: 'cluster-svc', service: sourceForm.service || 'loki', port: sourceForm.port || 3100 })}>{t('source.targetClusterSvc')}</button>
</div>
{sourceForm.targetType === 'url'
? <label className="sm:col-span-2">Loki 地址<Input value={sourceForm.url ?? ''} onChange={e => setSourceForm({ ...sourceForm, url: e.target.value })} placeholder="http://10.0.0.8:3100" /></label>
? <label className="sm:col-span-2">{t('source.addressLabel')}<Input value={sourceForm.url ?? ''} onChange={e => setSourceForm({ ...sourceForm, url: e.target.value })} placeholder="http://10.0.0.8:3100" /></label>
: <>
<label>集群<select className="block h-9 w-full rounded-md border bg-background px-3" value={sourceForm.clusterID ?? ''} onChange={e => setSourceForm({ ...sourceForm, clusterID: Number(e.target.value) })}>
<option value="">选择集群…</option>
<label>{t('source.clusterLabel')}<select className="block h-9 w-full rounded-md border bg-background px-3" value={sourceForm.clusterID ?? ''} onChange={e => setSourceForm({ ...sourceForm, clusterID: Number(e.target.value) })}>
<option value="">{t('source.clusterPlaceholder')}</option>
{clusters.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select></label>
<label>命名空间<Input value={sourceForm.namespace ?? ''} onChange={e => setSourceForm({ ...sourceForm, namespace: e.target.value })} placeholder="log" /></label>
<label>Service<Input value={sourceForm.service ?? ''} onChange={e => setSourceForm({ ...sourceForm, service: e.target.value })} placeholder="loki" /></label>
<label>端口<Input type="number" value={sourceForm.port ?? 3100} onChange={e => setSourceForm({ ...sourceForm, port: Number(e.target.value) })} /></label>
<label>{t('source.namespaceLabel')}<Input value={sourceForm.namespace ?? ''} onChange={e => setSourceForm({ ...sourceForm, namespace: e.target.value })} placeholder="log" /></label>
<label>{t('source.serviceLabel')}<Input value={sourceForm.service ?? ''} onChange={e => setSourceForm({ ...sourceForm, service: e.target.value })} placeholder="loki" /></label>
<label>{t('source.portLabel')}<Input type="number" value={sourceForm.port ?? 3100} onChange={e => setSourceForm({ ...sourceForm, port: Number(e.target.value) })} /></label>
</>}
<label>认证方式<select className="block h-9 w-full rounded-md border bg-background px-3" value={sourceForm.authType ?? 'none'} onChange={e => setSourceForm({ ...sourceForm, authType: e.target.value })}><option value="none">无认证</option><option value="basic">Basic Auth</option><option value="bearer">Bearer Token</option><option value="headers">自定义 Headers</option></select></label>
{sourceForm.authType === 'basic' && <><label>用户名<Input value={sourceForm.basicUser ?? ''} onChange={e => setSourceForm({ ...sourceForm, basicUser: e.target.value })} /></label><label>密码<Input type="password" value={sourceForm.basicPass ?? ''} onChange={e => setSourceForm({ ...sourceForm, basicPass: e.target.value })} /></label></>}
{sourceForm.authType === 'bearer' && <label>令牌<Input type="password" value={sourceForm.bearerToken ?? ''} onChange={e => setSourceForm({ ...sourceForm, bearerToken: e.target.value })} /></label>}
{sourceForm.authType === 'headers' && <label className="sm:col-span-2">自定义 Headers(JSON 对象)<textarea key={sourceForm.id ?? 'new'} className="block w-full rounded-md border bg-background p-2 font-mono text-sm" rows={3} defaultValue={JSON.stringify(sourceForm.headers ?? {}, null, 2)} onBlur={e => { try { const headers = JSON.parse(e.target.value) as Record<string, string>; if (!headers || Array.isArray(headers) || typeof headers !== 'object' || Object.values(headers).some(value => typeof value !== 'string')) throw Error(); setSourceForm(current => ({ ...current, headers })) } catch { toast.error('Headers 必须是字符串键值 JSON 对象') } }} placeholder='{"X-Scope-OrgID":"tenant"}' /></label>}
<label className="flex items-center gap-2"><input type="checkbox" checked={!!sourceForm.skipTLSVerify} onChange={e => setSourceForm({ ...sourceForm, skipTLSVerify: e.target.checked })} />跳过 TLS 校验</label>
<label className="flex items-center gap-2"><input type="checkbox" checked={!!sourceForm.isDefault} onChange={e => setSourceForm({ ...sourceForm, isDefault: e.target.checked })} />设为默认</label>
<label>{t('source.authLabel')}<select className="block h-9 w-full rounded-md border bg-background px-3" value={sourceForm.authType ?? 'none'} onChange={e => setSourceForm({ ...sourceForm, authType: e.target.value })}><option value="none">{t('source.authNone')}</option><option value="basic">{t('source.authBasic')}</option><option value="bearer">{t('source.authBearer')}</option><option value="headers">{t('source.authHeaders')}</option></select></label>
{sourceForm.authType === 'basic' && <><label>{t('source.usernameLabel')}<Input value={sourceForm.basicUser ?? ''} onChange={e => setSourceForm({ ...sourceForm, basicUser: e.target.value })} /></label><label>{t('source.passwordLabel')}<Input type="password" value={sourceForm.basicPass ?? ''} onChange={e => setSourceForm({ ...sourceForm, basicPass: e.target.value })} /></label></>}
{sourceForm.authType === 'bearer' && <label>{t('source.tokenLabel')}<Input type="password" value={sourceForm.bearerToken ?? ''} onChange={e => setSourceForm({ ...sourceForm, bearerToken: e.target.value })} /></label>}
{sourceForm.authType === 'headers' && <label className="sm:col-span-2">{t('source.headersLabel')}<textarea key={sourceForm.id ?? 'new'} className="block w-full rounded-md border bg-background p-2 font-mono text-sm" rows={3} defaultValue={JSON.stringify(sourceForm.headers ?? {}, null, 2)} onBlur={e => { try { const headers = JSON.parse(e.target.value) as Record<string, string>; if (!headers || Array.isArray(headers) || typeof headers !== 'object' || Object.values(headers).some(value => typeof value !== 'string')) throw Error(); setSourceForm(current => ({ ...current, headers })) } catch { toast.error(t('source.headersInvalid')) } }} placeholder='{"X-Scope-OrgID":"tenant"}' /></label>}
<label className="flex items-center gap-2"><input type="checkbox" checked={!!sourceForm.skipTLSVerify} onChange={e => setSourceForm({ ...sourceForm, skipTLSVerify: e.target.checked })} />{t('source.skipTLS')}</label>
<label className="flex items-center gap-2"><input type="checkbox" checked={!!sourceForm.isDefault} onChange={e => setSourceForm({ ...sourceForm, isDefault: e.target.checked })} />{t('source.setDefault')}</label>
</div>
<div className="flex gap-2"><Button variant="outline" onClick={() => logsApi.testSource(sourceForm).then(result => result.success ? toast.info(result.message) : toast.error(result.message)).catch(e => toast.error(e.message))}>测试连接</Button><Button disabled={saving || !sourceForm.name} onClick={saveSource}>保存数据源</Button>{sourceForm.id && <Button variant="outline" onClick={() => logsApi.deleteSource(sourceForm.id!).then(() => { void reloadSources(); setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] }) }).catch(e => toast.error(e.message))}>删除</Button>}</div>
<div className="flex gap-2"><Button variant="outline" onClick={() => logsApi.testSource(sourceForm).then(result => result.success ? toast.info(result.message) : toast.error(result.message)).catch(e => toast.error(e.message))}>{t('source.test')}</Button><Button disabled={saving || !sourceForm.name} onClick={saveSource}>{t('source.save')}</Button>{sourceForm.id && <Button variant="outline" onClick={() => logsApi.deleteSource(sourceForm.id!).then(() => { void reloadSources(); setSourceForm({ name: '', targetType: 'url', url: '', authType: 'none', access: [] }) }).catch(e => toast.error(e.message))}>{t('source.remove')}</Button>}</div>
</section>}
{tab === 'query' && histogramLoading && <div role="status" className="flex items-center gap-2 rounded-xl border bg-card p-4 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin text-info" />正在统计整个时间范围的日志量,日志页可继续浏览。</div>}
{tab === 'query' && histogramError && <p className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">日志量统计超时或失败;日志页仍可使用。可缩小时间范围后重新查询。</p>}
{tab === 'query' && histogramLoading && <div role="status" className="flex items-center gap-2 rounded-xl border bg-card p-4 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin text-info" />{t('volume.statsLoading')}</div>}
{tab === 'query' && histogramError && <p className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">{t('volume.statsError')}</p>}
{tab === 'query' && buckets.length > 0 && <LogVolumeTimeline key={`${buckets[0].timestamp}-${buckets.length}`} buckets={buckets} onSelect={selectTimeRange} />}
{tab === 'query' && <LogResultsPanel key={`${sourceId}:${executedQuery}:${executedRange?.start}:${executedRange?.end}`} entries={entries} buckets={buckets} start={executedRange ? toLocal(new Date(executedRange.start / 1e6)) : start} end={executedRange ? toLocal(new Date(executedRange.end / 1e6)) : end} searched={searched} hasSource={sources.length > 0} pageIndex={pageIndex} hasNextPage={Boolean(cursor)} loading={loading} loadingStartedAt={loadingStartedAt} query={query} terms={highlightTerms} onQueryChange={setQuery} onApplyDomain={applyDomainFilter} onShowContext={row => void showContext(row)} onSearch={() => void search()} onNextPage={() => void search(cursor, false, undefined, undefined, pageIndex + 1)} onPreviousPage={() => void search(pageCursors[pageIndex - 1] ?? '', false, undefined, undefined, pageIndex - 1)} />}
<LogContextDialog context={context} terms={highlightTerms} onClose={() => setContext(null)} />
+2 -1
View File
@@ -1,3 +1,4 @@
import i18n from '@/i18n'
import type { IngressAccessLog } from './ingress-access-log'
import { pathWithoutQuery } from './ingress-access-log'
@@ -36,7 +37,7 @@ export function summarizeIngress(samples: IngressSample[], slowThreshold: number
const allIps = new Set<string>()
let errors = 0, slow = 0, timed = 0
for (const { access } of samples) {
const domain = access.domain || '未记录 Host'
const domain = access.domain || i18n.t('ingress.noDomain', { ns: 'logs' })
const path = pathWithoutQuery(access.path)
const key = `${domain}\u0000${path}`
let route = routes.get(key)
+1 -1
View File
@@ -1,6 +1,6 @@
import { getUser } from '@/lib/auth-storage'
const key = () => `opsxloki.logs.query-history.${getUser()?.id ?? 'anonymous'}`
const key = () => `lokistack.logs.query-history.${getUser()?.id ?? 'anonymous'}`
export function readQueryHistory(): string[] {
try {
+4 -2
View File
@@ -1,9 +1,11 @@
import { FileQuestion, Home } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router'
import { Button } from '@/components/ui/button'
/** 404 页面 — 通配路由的显式落点,不静默跳首页 */
export default function NotFound() {
const { t } = useTranslation()
return (
<div className="min-h-screen bg-muted dark:bg-slate-950 flex items-center justify-center p-6">
<div className="max-w-md w-full text-center">
@@ -11,11 +13,11 @@ export default function NotFound() {
<FileQuestion className="w-8 h-8 text-muted-foreground" />
</div>
<h1 className="text-5xl font-bold text-foreground mb-2">404</h1>
<p className="text-muted-foreground mb-8">页面不存在或已被移除</p>
<p className="text-muted-foreground mb-8">{t('notFound.hint')}</p>
<Button asChild className="bg-primary hover:bg-primary/90 text-primary-foreground">
<Link to="/">
<Home className="w-4 h-4 mr-2" />
返回首页
{t('notFound.backHome')}
</Link>
</Button>
</div>
@@ -1,4 +1,5 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Globe, Save } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -8,28 +9,31 @@ import { cn } from '@/lib/utils'
import { settingsApi, type ImageRegistryConfig } from '@/api/settings'
import { toast } from 'sonner'
const MODES = [
{ value: 'overseas', label: '海外官方', desc: 'docker.io,海外网络环境' },
{ value: 'cn', label: '国内加速', desc: 'docker.m.daocloud.io 镜像,国内环境防被墙' },
{ value: 'custom', label: '私有仓库', desc: '自有 registry 前缀(如华为云 SWR)' },
] as const
const COMPONENTS = [
{ key: 'loki', label: 'Loki', repo: 'grafana/loki', tagHint: '跟随部署版本' },
{ key: 'grafana', label: 'Grafana', repo: 'grafana/grafana', tagHint: '跟随部署版本' },
{ key: 'promtail', label: 'Promtail', repo: 'grafana/promtail', tagHint: '3.4.0' },
{ key: 'fluentbit', label: 'Fluent Bit', repo: 'fluent/fluent-bit', tagHint: '5.1.5' },
] as const
/**
* 镜像源设置 — 影响平台拉取的全部镜像(Loki/Grafana/Promtail/Fluent Bit),
* 参照 kuboard 的国内/海外选择;实时预览四类镜像的生效地址。
*/
export const ImageRegistrySection = memo(function ImageRegistrySection() {
const { t } = useTranslation('settings')
const [cfg, setCfg] = useState<ImageRegistryConfig | null>(null)
const [customHost, setCustomHost] = useState('')
const [saving, setSaving] = useState(false)
// 源选项(文案走词典;value 保持字面量类型以匹配 cfg.mode)
const MODES = [
{ value: 'overseas', label: t('registry.modeOverseasLabel'), desc: t('registry.modeOverseasDesc') },
{ value: 'cn', label: t('registry.modeCnLabel'), desc: t('registry.modeCnDesc') },
{ value: 'custom', label: t('registry.modeCustomLabel'), desc: t('registry.modeCustomDesc') },
] as const
// 组件镜像行(repo 为 placeholder 示例;tagHint 区分「跟随部署版本」与内置版本号)
const COMPONENTS = [
{ key: 'loki', label: 'Loki', repo: 'grafana/loki', tagHint: t('registry.tagFollowVersion') },
{ key: 'grafana', label: 'Grafana', repo: 'grafana/grafana', tagHint: t('registry.tagFollowVersion') },
{ key: 'promtail', label: 'Promtail', repo: 'grafana/promtail', tagHint: '3.4.0' },
{ key: 'fluentbit', label: 'Fluent Bit', repo: 'fluent/fluent-bit', tagHint: '5.1.5' },
] as const
useEffect(() => {
settingsApi
.getImageRegistry()
@@ -37,7 +41,9 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() {
setCfg(c)
setCustomHost(c.customHost || '')
})
.catch(() => toast.error('加载镜像源配置失败'))
.catch(() => toast.error(t('registry.errLoad')))
// 仅首载拉取配置;t 只影响失败 toast,不随语言切换重跑(避免覆盖未保存输入)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleSave = useCallback(async () => {
@@ -50,13 +56,13 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() {
images: cfg.images,
})
setCfg(saved)
toast.success('镜像源已保存,后续部署/采集将使用新镜像地址')
toast.success(t('registry.savedToast'))
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存失败')
toast.error(err instanceof Error ? err.message : t('notify.errSave'))
} finally {
setSaving(false)
}
}, [cfg, customHost])
}, [cfg, customHost, t])
if (!cfg) return null
@@ -65,7 +71,7 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() {
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Globe className="w-4 h-4" />
镜像仓库源
{t('registry.cardTitle')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
@@ -89,19 +95,21 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() {
{cfg.mode === 'custom' && (
<div className="space-y-2">
<Label>私有仓库前缀 *</Label>
<Label>{t('registry.customPrefixLabel')}</Label>
<Input
placeholder="swr.cn-east-2.myhuaweicloud.com/your-namespace"
value={customHost}
onChange={(e) => setCustomHost(e.target.value)}
/>
<p className="text-xs text-muted-foreground">平台会在镜像名前拼接此前缀,如 {customHost || 'your-registry'}/grafana/loki:3.4.0</p>
<p className="text-xs text-muted-foreground">
{t('registry.customPrefixHint', { example: customHost || 'your-registry' })}
</p>
</div>
)}
{/* 各服务镜像自定义 */}
<div className="space-y-2">
<Label>各服务镜像(可单独覆盖仓库 / Tag)</Label>
<Label>{t('registry.imagesLabel')}</Label>
<div className="space-y-2">
{COMPONENTS.map(({ key, label, repo, tagHint }) => {
const img = cfg.images?.[key] || { repository: '', tag: '' }
@@ -114,14 +122,14 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() {
<div key={key} className="grid items-center gap-2 sm:grid-cols-[110px_1fr_150px]">
<span className="text-sm font-medium">{label}</span>
<Input
aria-label={`${label} 镜像仓库`}
aria-label={t('registry.repoAria', { label })}
value={img.repository}
placeholder={repo}
onChange={(e) => update({ repository: e.target.value })}
className="font-mono text-xs"
/>
<Input
aria-label={`${label} 镜像 Tag`}
aria-label={t('registry.tagAria', { label })}
value={img.tag}
placeholder={tagHint}
onChange={(e) => update({ tag: e.target.value })}
@@ -132,13 +140,15 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() {
})}
</div>
<p className="text-xs text-muted-foreground">
仓库填镜像路径(如 <code>grafana/loki</code>);Tag 留空时 Loki / Grafana 跟随部署版本,Promtail / Fluent Bit 用内置默认。
{t('registry.imagesHintBefore')}
<code>grafana/loki</code>
{t('registry.imagesHintAfter')}
</p>
</div>
{/* 生效镜像预览 */}
<div className="rounded-lg bg-muted/50 p-3 space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">生效镜像预览</p>
<p className="text-xs font-medium text-muted-foreground">{t('registry.previewTitle')}</p>
{([
['Loki', cfg.sampleLoki],
['Grafana', cfg.sampleGrafana],
@@ -155,7 +165,7 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() {
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
<Save className="w-4 h-4 mr-1" />
{saving ? '保存中...' : '保存镜像源'}
{saving ? t('actions.saving') : t('registry.saveButton')}
</Button>
</div>
</CardContent>
@@ -1,4 +1,5 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Building2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -29,6 +30,7 @@ const EMPTY: LdapConfig = {
* 用户管理页才会出现「从 LDAP 导入」。
*/
export const LdapSection = memo(function LdapSection() {
const { t } = useTranslation('settings')
const [cfg, setCfg] = useState<LdapConfig>(EMPTY)
const [loaded, setLoaded] = useState(false)
const [saving, setSaving] = useState(false)
@@ -42,7 +44,9 @@ export const LdapSection = memo(function LdapSection() {
setCfg({ ...EMPTY, ...c, bindPassword: '' })
setLoaded(true)
})
.catch(() => toast.error('加载 LDAP 配置失败'))
.catch(() => toast.error(t('ldap.errLoad')))
// 仅首载拉取配置;t 只影响失败 toast,不随语言切换重跑(避免覆盖未保存表单)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const set = useCallback(<K extends keyof LdapConfig>(k: K, v: LdapConfig[K]) => {
@@ -51,7 +55,7 @@ export const LdapSection = memo(function LdapSection() {
const handleTest = async () => {
if (!cfg.host || !cfg.bindDN || !cfg.baseDN) {
toast.error('请先填写服务器地址 / Bind DN / Base DN')
toast.error(t('ldap.errMissingFields'))
return
}
setTesting(true)
@@ -60,7 +64,7 @@ export const LdapSection = memo(function LdapSection() {
const result = await ldapApi.test(cfg)
setTestResult(result)
} catch (err) {
toast.error(err instanceof Error ? err.message : '测试失败')
toast.error(err instanceof Error ? err.message : t('ldap.errTest'))
} finally {
setTesting(false)
}
@@ -71,9 +75,9 @@ export const LdapSection = memo(function LdapSection() {
try {
const saved = await ldapApi.saveConfig(cfg)
setCfg({ ...EMPTY, ...saved, bindPassword: '' })
toast.success('LDAP 配置已保存')
toast.success(t('ldap.savedToast'))
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存失败')
toast.error(err instanceof Error ? err.message : t('notify.errSave'))
} finally {
setSaving(false)
}
@@ -85,24 +89,21 @@ export const LdapSection = memo(function LdapSection() {
<CardTitle className="text-base flex items-center justify-between">
<span className="flex items-center gap-2">
<Building2 className="w-4 h-4" />
LDAP 接入
{t('ldap.cardTitle')}
</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">启用后支持 LDAP 登录与批量导入</span>
<span className="text-xs text-muted-foreground">{t('ldap.headerHint')}</span>
<Switch checked={cfg.enabled} onCheckedChange={(v) => set('enabled', v)} />
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{loaded && !cfg.enabled ? (
<p className="text-sm text-muted-foreground py-2 text-center">
LDAP 未启用 —— 打开开关填写目录服务信息(服务器 / Bind DN / Base DN),
保存后用户管理页将出现「从 LDAP 导入」
</p>
<p className="text-sm text-muted-foreground py-2 text-center">{t('ldap.disabledHint')}</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>服务器地址 *</Label>
<Label>{t('ldap.hostLabel')}</Label>
<Input
placeholder="8.217.205.92"
value={cfg.host}
@@ -111,7 +112,7 @@ export const LdapSection = memo(function LdapSection() {
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-2 col-span-2">
<Label>端口</Label>
<Label>{t('ldap.portLabel')}</Label>
<Input
type="number"
value={cfg.port || 389}
@@ -119,14 +120,14 @@ export const LdapSection = memo(function LdapSection() {
/>
</div>
<div className="space-y-2 flex flex-col">
<Label>LDAPS</Label>
<Label>{t('ldap.ldapsLabel')}</Label>
<div className="pt-2">
<Switch checked={cfg.useSSL} onCheckedChange={(v) => set('useSSL', v)} />
</div>
</div>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Bind DN *</Label>
<Label>{t('ldap.bindDnLabel')}</Label>
<Input
placeholder="cn=admin,dc=example,dc=com"
value={cfg.bindDN}
@@ -134,16 +135,16 @@ export const LdapSection = memo(function LdapSection() {
/>
</div>
<div className="space-y-2">
<Label>Bind 密码 *</Label>
<Label>{t('ldap.bindPasswordLabel')}</Label>
<PasswordInput
placeholder={cfg.hasPassword ? '已保存,输入新密码以修改' : '管理员密码'}
placeholder={cfg.hasPassword ? t('ldap.bindPasswordSavedPlaceholder') : t('ldap.bindPasswordPlaceholder')}
value={cfg.bindPassword}
onChange={(e) => set('bindPassword', e.target.value)}
autoComplete="new-password"
/>
</div>
<div className="space-y-2">
<Label>Base DN *</Label>
<Label>{t('ldap.baseDnLabel')}</Label>
<Input
placeholder="dc=example,dc=com"
value={cfg.baseDN}
@@ -151,20 +152,20 @@ export const LdapSection = memo(function LdapSection() {
/>
</div>
<div className="space-y-2">
<Label>用户过滤器</Label>
<Label>{t('ldap.userFilterLabel')}</Label>
<Input value={cfg.userFilter} onChange={(e) => set('userFilter', e.target.value)} />
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-2">
<Label>用户名</Label>
<Label>{t('ldap.usernameLabel')}</Label>
<Input value={cfg.usernameAttr} onChange={(e) => set('usernameAttr', e.target.value)} />
</div>
<div className="space-y-2">
<Label>姓名</Label>
<Label>{t('ldap.nameLabel')}</Label>
<Input value={cfg.nameAttr} onChange={(e) => set('nameAttr', e.target.value)} />
</div>
<div className="space-y-2">
<Label>邮箱</Label>
<Label>{t('ldap.emailLabel')}</Label>
<Input value={cfg.emailAttr} onChange={(e) => set('emailAttr', e.target.value)} />
</div>
</div>
@@ -183,7 +184,7 @@ export const LdapSection = memo(function LdapSection() {
{testResult.message}
{testResult.userInfo && (
<span className="block text-xs mt-1 opacity-80">
{testResult.userInfo.displayName} · {testResult.userInfo.email || '无邮箱'}
{testResult.userInfo.displayName} · {testResult.userInfo.email || t('ldap.noEmail')}
</span>
)}
</div>
@@ -191,10 +192,10 @@ export const LdapSection = memo(function LdapSection() {
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={handleTest} disabled={testing || !cfg.enabled}>
{testing ? '测试中...' : '测试连接'}
{testing ? t('actions.testing') : t('ldap.testButton')}
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? '保存中...' : '保存配置'}
{saving ? t('actions.saving') : t('ldap.saveButton')}
</Button>
</div>
</CardContent>
@@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router'
import { useTranslation } from 'react-i18next'
import { BellRing, Plus, Pencil, Trash2, Send } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -26,6 +27,7 @@ interface ChannelForm {
* webhook 加密存储(留空=保留旧值),每通道可独立测试/禁用/删除(有关联规则时禁删)。
*/
export const NotifySection = memo(function NotifySection() {
const { t } = useTranslation('settings')
const [channels, setChannels] = useState<NotifyChannel[]>([])
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<NotifyChannel | null>(null)
@@ -40,9 +42,9 @@ export const NotifySection = memo(function NotifySection() {
const list = await alertsApi.listChannels()
setChannels(Array.isArray(list) ? list : [])
} catch (err) {
toast.error(err instanceof Error ? err.message : '加载通知通道失败')
toast.error(err instanceof Error ? err.message : t('notify.errLoad'))
}
}, [])
}, [t])
useEffect(() => {
void refresh()
@@ -62,11 +64,11 @@ export const NotifySection = memo(function NotifySection() {
const handleSave = async () => {
if (!form.name.trim()) {
toast.error('请填写通道名称')
toast.error(t('notify.errNameRequired'))
return
}
if (!editing && !form.webhook) {
toast.error('新建通道需要填写 Webhook 地址')
toast.error(t('notify.errWebhookRequired'))
return
}
setSaving(true)
@@ -89,7 +91,7 @@ export const NotifySection = memo(function NotifySection() {
setOpen(false)
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存失败')
toast.error(err instanceof Error ? err.message : t('notify.errSave'))
} finally {
setSaving(false)
}
@@ -99,9 +101,9 @@ export const NotifySection = memo(function NotifySection() {
setTesting(true)
try {
await alertsApi.testChannel({ id: ch.id })
toast.success(`测试卡片已发送到「${ch.name}」`)
toast.success(t('notify.testSent', { name: ch.name }))
} catch (err) {
toast.error(err instanceof Error ? err.message : '发送失败')
toast.error(err instanceof Error ? err.message : t('notify.errSend'))
} finally {
setTesting(false)
}
@@ -112,7 +114,7 @@ export const NotifySection = memo(function NotifySection() {
await alertsApi.updateChannel(ch.id, { name: ch.name, enabled })
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '切换失败')
toast.error(err instanceof Error ? err.message : t('notify.errToggle'))
}
}
@@ -124,7 +126,7 @@ export const NotifySection = memo(function NotifySection() {
setDeleteTarget(null)
void refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '删除失败')
toast.error(err instanceof Error ? err.message : t('notify.errDelete'))
} finally {
setDeleting(false)
}
@@ -136,21 +138,20 @@ export const NotifySection = memo(function NotifySection() {
<CardTitle className="text-base flex items-center justify-between">
<span className="flex items-center gap-2">
<BellRing className="w-4 h-4" />
告警通知通道(飞书/Lark)
{t('notify.cardTitle')}
</span>
<Button size="sm" variant="outline" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1" />
新建通道
{t('notify.create')}
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{channels.length === 0 ? (
<p className="text-sm text-muted-foreground py-2 text-center">
尚无通知通道 —— 每个告警群创建一个通道(如「应用告警群」「Nginx 告警群」),
在
<Link to="/alerts" className="text-primary mx-1">日志告警</Link>
页把规则关联到对应通道
{t('notify.emptyBefore')}
<Link to="/alerts" className="text-primary mx-1">{t('notify.emptyLink')}</Link>
{t('notify.emptyAfter')}
</p>
) : (
channels.map((ch) => (
@@ -159,19 +160,19 @@ export const NotifySection = memo(function NotifySection() {
<div className="min-w-0 flex-1">
<p className="font-medium text-sm">{ch.name}</p>
<p className="text-xs text-muted-foreground">
{ch.hasWebhook ? 'Webhook 已配置' : '未配置'}
{ch.hasSecret ? ' · 签名已配置' : ''}
{ch.hasWebhook ? t('notify.webhookConfigured') : t('notify.webhookMissing')}
{ch.hasSecret ? t('notify.secretConfigured') : ''}
</p>
</div>
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm" aria-label={`测试 ${ch.name}`} disabled={testing} onClick={() => void handleTest(ch)}>
<Button variant="outline" size="sm" aria-label={t('notify.testAria', { name: ch.name })} disabled={testing} onClick={() => void handleTest(ch)}>
<Send className="w-3.5 h-3.5" />
</Button>
<Button variant="outline" size="sm" aria-label={`编辑 ${ch.name}`} onClick={() => openEdit(ch)}>
<Button variant="outline" size="sm" aria-label={t('notify.editAria', { name: ch.name })} onClick={() => openEdit(ch)}>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
variant="outline" size="sm" aria-label={`删除 ${ch.name}`}
variant="outline" size="sm" aria-label={t('notify.deleteAria', { name: ch.name })}
className="text-destructive hover:bg-destructive/10"
onClick={() => setDeleteTarget(ch)}
>
@@ -186,41 +187,46 @@ export const NotifySection = memo(function NotifySection() {
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? `编辑通道 · ${editing.name}` : '新建通知通道'}</DialogTitle>
<DialogTitle>
{editing ? t('notify.editTitle', { name: editing.name }) : t('notify.createTitle')}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>通道名称 *</Label>
<Label>{t('notify.nameLabel')}</Label>
<Input
placeholder="如:应用告警群 / Nginx 告警群"
placeholder={t('notify.namePlaceholder')}
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Webhook 地址{editing ? '(留空保持不变)' : ' *'}</Label>
<Label>
{t('notify.webhookLabel')}
{editing ? t('notify.webhookKeepHint') : ' *'}
</Label>
<PasswordInput
placeholder={editing?.hasWebhook ? '已保存,输入新地址以修改' : 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx'}
placeholder={editing?.hasWebhook ? t('notify.webhookSavedPlaceholder') : 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx'}
value={form.webhook}
onChange={(e) => setForm((f) => ({ ...f, webhook: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>签名校验密钥(可选)</Label>
<Label>{t('notify.signingLabel')}</Label>
<PasswordInput
placeholder={editing?.hasSecret ? '已保存,留空保持不变' : '飞书机器人「签名校验」密钥'}
placeholder={editing?.hasSecret ? t('notify.signingSavedPlaceholder') : t('notify.signingPlaceholder')}
value={form.signingSecret}
onChange={(e) => setForm((f) => ({ ...f, signingSecret: e.target.value }))}
/>
</div>
<div className="flex items-center gap-2">
<Switch checked={form.enabled} onCheckedChange={(v) => setForm((f) => ({ ...f, enabled: v }))} />
<Label>启用通道</Label>
<Label>{t('notify.enable')}</Label>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)}>取消</Button>
<Button variant="outline" onClick={() => setOpen(false)}>{t('actions.cancel')}</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? '保存中...' : editing ? '保存修改' : '创建通道'}
{saving ? t('actions.saving') : editing ? t('notify.saveChanges') : t('notify.createChannel')}
</Button>
</div>
</div>
@@ -231,7 +237,7 @@ export const NotifySection = memo(function NotifySection() {
open={deleteTarget !== null}
onOpenChange={(o) => { if (!o) setDeleteTarget(null) }}
name={deleteTarget?.name}
title="确认删除通知通道"
title={t('notify.deleteTitle')}
loading={deleting}
onConfirm={handleDelete}
/>
@@ -1,41 +1,103 @@
import { memo } from 'react'
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useTheme } from 'next-themes'
import { Sun, Moon, Monitor } from 'lucide-react'
import { Sun, Moon, Monitor, Clock } from 'lucide-react'
import { cn } from '@/lib/utils'
import {
DAY_START_HOUR,
DAY_END_HOUR,
setThemeAuto,
setThemeTimezone,
listTimezones,
} from '@/lib/theme-schedule'
import { useScheduledTheme } from '@/hooks/useScheduledTheme'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const OPTIONS = [
{ value: 'light', label: '浅色', icon: Sun },
{ value: 'dark', label: '深色', icon: Moon },
{ value: 'system', label: '跟随系统', icon: Monitor },
] as const
type ThemeMode = 'light' | 'dark' | 'system' | 'auto'
const OPTIONS: Array<{ value: ThemeMode; labelKey: string; icon: typeof Sun }> = [
{ value: 'light', labelKey: 'themeCard.light', icon: Sun },
{ value: 'dark', labelKey: 'themeCard.dark', icon: Moon },
{ value: 'system', labelKey: 'themeCard.system', icon: Monitor },
{ value: 'auto', labelKey: 'themeCard.auto', icon: Clock },
]
/**
* 主题设置 — 三态单选卡片(浅色/深色/跟随系统),next-themes 驱动,
* key `opsxloki.theme.mode`。
* 主题设置 — 紧凑分段选择器(浅色/深色/跟随系统/定时切换),next-themes 驱动,
* key `lokistack.theme.mode`;「定时切换」由调度器按时区白天浅色、夜间深色,
* 时区下拉仅在选中定时切换时内联出现。
*/
export const ThemeSection = memo(function ThemeSection() {
const { theme, setTheme } = useTheme()
const { t } = useTranslation('settings')
const { auto, timezone } = useScheduledTheme()
const timezones = useMemo(() => listTimezones(), [])
const activeValue: ThemeMode = auto ? 'auto' : theme === 'system' ? 'system' : theme === 'dark' ? 'dark' : 'light'
const selectTheme = (value: ThemeMode) => {
if (value === 'auto') {
setThemeAuto(true) // 调度器立即解析并应用
return
}
setThemeAuto(false)
setTheme(value)
}
return (
<div className="grid grid-cols-3 gap-3">
{OPTIONS.map((opt) => {
const Icon = opt.icon
const active = theme === opt.value
return (
<button
key={opt.value}
type="button"
aria-pressed={active}
onClick={() => setTheme(opt.value)}
className={cn(
'flex flex-col items-center gap-2 rounded-xl border-2 p-4 transition-all',
active ? 'border-primary bg-primary/5 text-primary' : 'border-border bg-card text-muted-foreground hover:border-input'
)}
>
<Icon className="w-5 h-5" />
<span className="text-sm font-medium">{opt.label}</span>
</button>
)
})}
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<span className="text-sm text-muted-foreground min-w-14 shrink-0">{t('themeCard.title')}</span>
<div role="group" aria-label={t('themeCard.title')} className="inline-flex items-center rounded-lg border border-border bg-card p-0.5">
{OPTIONS.map((opt) => {
const Icon = opt.icon
const active = activeValue === opt.value
return (
<button
key={opt.value}
type="button"
aria-pressed={active}
onClick={() => selectTheme(opt.value)}
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs transition-colors',
active
? 'bg-primary/10 font-medium text-primary'
: 'text-muted-foreground hover:text-foreground'
)}
>
<Icon className="size-3.5" />
{t(opt.labelKey)}
</button>
)
})}
</div>
{auto && (
<Select value={timezone || 'browser'} onValueChange={(v) => setThemeTimezone(v === 'browser' ? '' : v)}>
<SelectTrigger className="h-8 w-44" aria-label={t('themeCard.timezoneAria')}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="browser">{t('themeCard.browser')}</SelectItem>
{timezones.map((tz) => (
<SelectItem key={tz} value={tz}>{tz}</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{auto && (
<p className="text-xs text-muted-foreground">
{t('themeCard.autoHint', {
start: String(DAY_START_HOUR).padStart(2, '0'),
end: String(DAY_END_HOUR).padStart(2, '0'),
})}
</p>
)}
</div>
)
})
+36 -32
View File
@@ -1,5 +1,6 @@
import { memo, useCallback, useState } from 'react'
import { useNavigate } from 'react-router'
import { useTranslation } from 'react-i18next'
import { KeyRound, Settings as SettingsIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
@@ -11,6 +12,7 @@ import { toast } from 'sonner'
import { PageContainer } from '@/components/PageContainer'
import { LdapSection } from './components/LdapSection'
import { ThemeSection } from './components/ThemeSection'
import { LanguageSection } from '@/i18n/LanguageSection'
import { Palette } from 'lucide-react'
import { NotifySection } from './components/NotifySection'
import { ImageRegistrySection } from './components/ImageRegistrySection'
@@ -20,35 +22,36 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
const APP_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'
const COMMIT_HASH = typeof __COMMIT_HASH__ !== 'undefined' ? __COMMIT_HASH__ : '-'
// 系统信息常量
const SYSTEM_INFO = [
{ label: '版本', value: `${APP_VERSION} (${COMMIT_HASH})` },
{ label: '后端', value: 'Go + Gin + JSON 文件存储' },
{ label: '前端', value: 'React 19 + TypeScript + Tailwind CSS' },
{ label: '部署工具', value: 'kubectl + helm(镜像内置)' },
{ label: '简介', value: '轻量级 K8s Loki 部署平台' },
]
/**
* 系统设置 — 通用配置(主题/密码/关于)/ 告警通知 / LDAP 接入。
* 表单保持手写(字段少、校验已达标准);成功后清空表单即反馈(成功静默)。
*/
const SettingsPage = memo(function SettingsPage() {
const { t } = useTranslation('settings')
const navigate = useNavigate()
const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [changing, setChanging] = useState(false)
// 系统信息列表(文案走 settings 词典;版本值为构建指纹动态拼接)
const systemInfo = [
{ label: t('about.versionLabel'), value: `${APP_VERSION} (${COMMIT_HASH})` },
{ label: t('about.backendLabel'), value: t('about.backendValue') },
{ label: t('about.frontendLabel'), value: t('about.frontendValue') },
{ label: t('about.k8sLabel'), value: t('about.k8sValue') },
{ label: t('about.summaryLabel'), value: t('about.summaryValue') },
]
const handleChangePassword = useCallback(
async (e: React.FormEvent) => {
e.preventDefault()
if (newPassword !== confirmPassword) {
toast.error('两次输入的密码不一致')
toast.error(t('password.errMismatch'))
return
}
if (newPassword.length < 6) {
toast.error('新密码至少需要 6 位')
toast.error(t('password.errTooShort'))
return
}
setChanging(true)
@@ -58,36 +61,37 @@ const SettingsPage = memo(function SettingsPage() {
clearAuth()
navigate('/login', { replace: true })
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '密码修改失败'
const message = err instanceof Error ? err.message : t('password.errFailed')
toast.error(message)
} finally {
setChanging(false)
}
},
[oldPassword, newPassword, confirmPassword, navigate]
[oldPassword, newPassword, confirmPassword, navigate, t]
)
return (
<PageContainer title="系统设置" description="通用配置、告警通知、LDAP 接入与默认镜像源">
<PageContainer title={t('title')} description={t('description')}>
<Tabs defaultValue="general" className="space-y-6">
<TabsList>
<TabsTrigger value="general">通用配置</TabsTrigger>
<TabsTrigger value="notify">告警通知</TabsTrigger>
<TabsTrigger value="ldap">LDAP 接入</TabsTrigger>
<TabsTrigger value="registry">默认镜像源</TabsTrigger>
<TabsTrigger value="general">{t('tabs.general')}</TabsTrigger>
<TabsTrigger value="notify">{t('tabs.notify')}</TabsTrigger>
<TabsTrigger value="ldap">{t('tabs.ldap')}</TabsTrigger>
<TabsTrigger value="registry">{t('tabs.registry')}</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-6">
{/* 主题 */}
{/* 外观与语言(紧凑单卡:主题分段器 + 语言分段器) */}
<Card className="border-border">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<Palette className="w-4 h-4" />
主题
{t('prefsCard.title')}
</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="space-y-3">
<ThemeSection />
<LanguageSection />
</CardContent>
</Card>
@@ -97,41 +101,41 @@ const SettingsPage = memo(function SettingsPage() {
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<KeyRound className="w-4 h-4" />
修改密码
{t('password.title')}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="old-pwd">当前密码</Label>
<Label htmlFor="old-pwd">{t('password.oldLabel')}</Label>
<PasswordInput
id="old-pwd"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder="请输入当前密码"
placeholder={t('password.oldPlaceholder')}
required
autoComplete="current-password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-pwd">新密码</Label>
<Label htmlFor="new-pwd">{t('password.newLabel')}</Label>
<PasswordInput
id="new-pwd"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="请输入新密码(至少 6 位)"
placeholder={t('password.newPlaceholder')}
required
minLength={6}
autoComplete="new-password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-pwd">确认新密码</Label>
<Label htmlFor="confirm-pwd">{t('password.confirmLabel')}</Label>
<PasswordInput
id="confirm-pwd"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="请再次输入新密码"
placeholder={t('password.confirmPlaceholder')}
required
autoComplete="new-password"
/>
@@ -141,7 +145,7 @@ const SettingsPage = memo(function SettingsPage() {
className="bg-primary hover:bg-primary/90"
disabled={changing}
>
{changing ? '修改中...' : '修改密码'}
{changing ? t('password.submitting') : t('password.submit')}
</Button>
</form>
</CardContent>
@@ -152,16 +156,16 @@ const SettingsPage = memo(function SettingsPage() {
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<SettingsIcon className="w-4 h-4" />
关于 lokistack
{t('about.title')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-0">
{SYSTEM_INFO.map((item, index) => (
{systemInfo.map((item, index) => (
<div
key={item.label}
className={`flex justify-between py-2.5 ${
index < SYSTEM_INFO.length - 1 ? 'border-b border-border' : ''
index < systemInfo.length - 1 ? 'border-b border-border' : ''
}`}
>
<span className="text-sm text-muted-foreground">{item.label}</span>
@@ -1,7 +1,9 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { HardDrive, Star, Trash2, Pencil } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ResourceCard } from '@/components/ResourceCard'
import { CardActionButton } from '@/components/CardActionButton'
import type { StorageConfig } from '@/types'
import { STORAGE_TYPE_ICONS, STORAGE_TYPE_ICON_CLASS } from '../constants'
@@ -12,10 +14,11 @@ interface StorageCardProps {
onEdit: (s: StorageConfig) => void
}
/** 存储配置卡片 — 统一 ResourceCard 样式 */
/** 存储配置卡片 — 统一 ResourceCard 样式(徽章与 StatusBadge 同为胶囊形) */
export const StorageCard = memo(function StorageCard({
storage, onSetDefault, onDelete, onEdit,
}: StorageCardProps) {
const { t } = useTranslation('storage')
const Icon = STORAGE_TYPE_ICONS[storage.storageType] || HardDrive
const cls = STORAGE_TYPE_ICON_CLASS[storage.storageType] || 'text-muted-foreground'
return (
@@ -25,33 +28,30 @@ export const StorageCard = memo(function StorageCard({
title={storage.name}
badges={
<>
<span className="px-2 py-0.5 rounded-full bg-muted text-muted-foreground text-xs font-medium uppercase">
{storage.storageType}
</span>
<Badge variant="secondary" className="rounded-full font-medium uppercase">{storage.storageType}</Badge>
{storage.isDefault && (
<span className="px-2 py-0.5 rounded-full bg-success/10 text-success text-xs font-medium inline-flex items-center gap-0.5">
<Star className="size-3" />默认
</span>
<Badge className="rounded-full font-medium bg-success/10 text-success border-transparent hover:bg-success/10">
<Star className="size-3" />{t('card.default')}
</Badge>
)}
</>
}
fields={[
{ label: '访问地址', value: storage.endpoint || '默认', title: storage.endpoint },
{ label: t('card.endpoint'), value: storage.endpoint || t('card.default'), title: storage.endpoint },
{ label: 'Bucket', value: storage.bucket },
{ label: '路径前缀', value: storage.pathPrefix || '无' },
{ label: '连接', value: storage.insecure ? 'HTTP(不加密)' : 'HTTPS' },
{ label: t('card.pathPrefix'), value: storage.pathPrefix || t('card.none') },
{ label: t('card.connection'), value: storage.insecure ? t('card.insecureHttp') : 'HTTPS' },
]}
actions={
<>
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => onSetDefault(storage.id)} disabled={storage.isDefault}>
<Star className="size-3 mr-1" />{storage.isDefault ? '默认' : '设为默认'}
</Button>
<Button variant="outline" size="sm" aria-label={`编辑存储配置 ${storage.name}`} onClick={() => onEdit(storage)}>
<Pencil className="size-3.5" />
</Button>
<Button variant="outline" size="sm" aria-label={`删除存储配置 ${storage.name}`} className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() => onDelete(storage)}>
<Trash2 className="size-3.5" />
</Button>
<CardActionButton
icon={Star}
label={storage.isDefault ? t('card.default') : t('card.setDefault')}
disabled={storage.isDefault}
onClick={() => onSetDefault(storage.id)}
/>
<CardActionButton icon={Pencil} ariaLabel={t('card.editAria', { name: storage.name })} onClick={() => onEdit(storage)} />
<CardActionButton icon={Trash2} ariaLabel={t('card.deleteAria', { name: storage.name })} destructive onClick={() => onDelete(storage)} />
</>
}
/>
@@ -1,7 +1,9 @@
import { memo, useEffect, useState } from 'react'
import { memo, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import type { TFunction } from 'i18next'
import { HardDrive, PlugZap } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -26,21 +28,23 @@ import { PasswordInput } from '@/components/PasswordInput'
import { toast } from 'sonner'
import { STORAGE_TYPE_ICONS, STORAGE_TYPE_ICON_CLASS, STORAGE_TYPES } from '../constants'
// 表单 schema:zod 为唯一校验源,消息中文且具体
const storageFormSchema = z.object({
name: z.string().min(1, '请输入存储配置名称'),
storageType: z.enum(['s3', 'oss', 'cos', 'obs', 'minio', 'rustfs']),
endpoint: z.string().optional(),
bucket: z.string().min(1, '请填写 Bucket 名称'),
region: z.string().optional(),
accessKey: z.string().optional(),
secretKey: z.string().optional(),
pathPrefix: z.string().optional(),
insecure: z.boolean(),
isDefault: z.boolean(),
})
// 表单 schema 工厂:zod 为唯一校验源,校验消息经 t 取词典
// (t 随语言切换变化,组件内 useMemo 按 t 重建 schema,RHF 每次渲染刷新 resolver)
const buildStorageFormSchema = (t: TFunction) =>
z.object({
name: z.string().min(1, t('form.nameRequired')),
storageType: z.enum(['s3', 'oss', 'cos', 'obs', 'minio', 'rustfs']),
endpoint: z.string().optional(),
bucket: z.string().min(1, t('form.bucketRequired')),
region: z.string().optional(),
accessKey: z.string().optional(),
secretKey: z.string().optional(),
pathPrefix: z.string().optional(),
insecure: z.boolean(),
isDefault: z.boolean(),
})
type StorageFormValues = z.infer<typeof storageFormSchema>
type StorageFormValues = z.infer<ReturnType<typeof buildStorageFormSchema>>
const DEFAULT_VALUES: StorageFormValues = {
name: '',
@@ -71,11 +75,14 @@ export const StorageFormDialog = memo(function StorageFormDialog({
onCreated,
editing,
}: StorageFormDialogProps) {
const { t } = useTranslation('storage')
const [submitting, setSubmitting] = useState(false)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<StorageTestResult | null>(null)
const [createIfMissing, setCreateIfMissing] = useState(true)
// schema 随语言重建(校验消息跟随语言切换)
const storageFormSchema = useMemo(() => buildStorageFormSchema(t), [t])
const form = useForm<StorageFormValues>({
resolver: zodResolver(storageFormSchema),
defaultValues: DEFAULT_VALUES,
@@ -116,8 +123,8 @@ export const StorageFormDialog = memo(function StorageFormDialog({
bucketExists: false,
bucketCreated: false,
message: hasStoredSecret
? '请先填写 Bucket 与 Access Key(Secret Key 已保存,留空即可)'
: '请先填写 Bucket 与 Access Key / Secret Key',
? t('form.testHintSaved')
: t('form.testHintMissing'),
})
return
}
@@ -138,7 +145,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
})
setTestResult(result)
} catch (err) {
toast.error(err instanceof Error ? err.message : '测试连接失败')
toast.error(err instanceof Error ? err.message : t('form.errTest'))
} finally {
setTesting(false)
}
@@ -163,7 +170,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
onOpenChange(false)
onCreated()
} catch (err) {
toast.error(err instanceof Error ? err.message : '创建存储配置失败')
toast.error(err instanceof Error ? err.message : t('form.errSubmit'))
} finally {
setSubmitting(false)
}
@@ -173,7 +180,11 @@ export const StorageFormDialog = memo(function StorageFormDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? `编辑存储配置${editing.name ? ` · ${editing.name}` : ''}` : '添加存储配置'}</DialogTitle>
<DialogTitle>
{editing
? `${t('form.editTitle')}${editing.name ? ` · ${editing.name}` : ''}`
: t('form.addTitle')}
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
@@ -182,7 +193,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>名称 *</FormLabel>
<FormLabel>{t('form.name')}</FormLabel>
<FormControl>
<Input placeholder="production-minio" {...field} />
</FormControl>
@@ -196,7 +207,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
name="storageType"
render={({ field }) => (
<FormItem>
<FormLabel>存储类型</FormLabel>
<FormLabel>{t('form.storageType')}</FormLabel>
<FormControl>
<div className="grid grid-cols-4 gap-2">
{STORAGE_TYPES.map((type) => (
@@ -229,7 +240,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>访问地址</FormLabel>
<FormLabel>{t('form.endpoint')}</FormLabel>
<FormControl>
<Input
placeholder={
@@ -257,7 +268,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
name="bucket"
render={({ field }) => (
<FormItem>
<FormLabel>Bucket *</FormLabel>
<FormLabel>{t('form.bucket')}</FormLabel>
<FormControl>
<Input
placeholder={
@@ -276,7 +287,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
name="region"
render={({ field }) => (
<FormItem>
<FormLabel>区域</FormLabel>
<FormLabel>{t('form.region')}</FormLabel>
<FormControl>
<Input
placeholder={
@@ -322,7 +333,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
<FormLabel>Secret Key</FormLabel>
<FormControl>
<PasswordInput
placeholder={editing?.hasSecretKey ? '已保存 · 留空则不修改' : '请输入 Secret Key'}
placeholder={editing?.hasSecretKey ? t('form.secretPlaceholderSaved') : t('form.secretPlaceholder')}
autoComplete="new-password"
{...field}
/>
@@ -346,7 +357,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
onCheckedChange={field.onChange}
/>
</FormControl>
<Label htmlFor="s-insecure">不加密 (HTTP)</Label>
<Label htmlFor="s-insecure">{t('form.insecure')}</Label>
</FormItem>
)}
/>
@@ -362,7 +373,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
onCheckedChange={field.onChange}
/>
</FormControl>
<Label htmlFor="s-default">设为默认</Label>
<Label htmlFor="s-default">{t('form.setDefault')}</Label>
</FormItem>
)}
/>
@@ -372,7 +383,7 @@ export const StorageFormDialog = memo(function StorageFormDialog({
checked={createIfMissing}
onCheckedChange={setCreateIfMissing}
/>
<Label htmlFor="s-autocreate">测试时桶不存在则自动创建</Label>
<Label htmlFor="s-autocreate">{t('form.autoCreate')}</Label>
</div>
</div>
@@ -398,14 +409,20 @@ export const StorageFormDialog = memo(function StorageFormDialog({
onClick={handleTest}
>
<PlugZap className="w-4 h-4 mr-1" />
{testing ? '测试中...' : '测试连接'}
{testing ? t('form.testing') : t('form.test')}
</Button>
<Button
type="submit"
className="flex-1 bg-primary hover:bg-primary/90"
disabled={submitting}
>
{submitting ? (editing ? '保存中...' : '添加中...') : editing ? '保存修改' : '添加存储'}
{submitting
? editing
? t('form.saving')
: t('form.adding')
: editing
? t('form.save')
: t('form.add')}
</Button>
</div>
+1 -10
View File
@@ -12,20 +12,11 @@ export const STORAGE_TYPE_ICONS: Record<string, LucideIcon> = {
export const STORAGE_TYPE_ICON_CLASS: Record<string, string> = {
s3: 'text-warning',
oss: 'text-destructive',
oss: 'text-primary',
cos: 'text-info',
obs: 'text-info',
minio: 'text-info',
rustfs: 'text-info',
}
export const STORAGE_TYPE_COLORS: Record<string, string> = {
s3: 'bg-warning/10 text-warning',
oss: 'bg-destructive/10 text-destructive',
cos: 'bg-info/10 text-info',
obs: 'bg-info/10 text-info',
minio: 'bg-info/10 text-info',
rustfs: 'bg-info/10 text-info',
}
export const STORAGE_TYPES = ['s3', 'oss', 'cos', 'obs', 'minio', 'rustfs'] as const
+14 -15
View File
@@ -1,4 +1,5 @@
import { useEffect, memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HardDrive, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { storageApi, deploymentApi } from '@/api'
@@ -19,6 +20,7 @@ import { StorageFormDialog } from './components/StorageFormDialog'
* 卡片/表单/删除确认为域内子组件。成功操作静默(列表刷新即反馈)。
*/
const StoragePage = memo(function StoragePage() {
const { t } = useTranslation('storage')
const query = useListQuery<StorageConfig>((params, signal) =>
storageApi.list(params, { signal })
)
@@ -46,11 +48,11 @@ const StoragePage = memo(function StoragePage() {
setDeleteTarget(null)
query.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '删除存储配置失败')
toast.error(err instanceof Error ? err.message : t('page.errDelete'))
} finally {
setDeleting(false)
}
}, [deleteTarget, query])
}, [deleteTarget, query, t])
// 设为默认存储(后端保证唯一默认:置新默认会取消旧默认)
const handleSetDefault = useCallback(
@@ -59,23 +61,20 @@ const StoragePage = memo(function StoragePage() {
await storageApi.update(id, { isDefault: true })
query.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '设置默认存储失败')
toast.error(err instanceof Error ? err.message : t('page.errSetDefault'))
}
},
[query]
[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.add')}
</Button>
}
>
@@ -86,8 +85,8 @@ const StoragePage = memo(function StoragePage() {
) : query.list.length === 0 ? (
<EmptyState
icon={HardDrive}
title="暂无存储配置"
description="添加第一个 Loki 存储后端"
title={t('page.emptyTitle')}
description={t('page.emptyDescription')}
/>
) : (
<>
@@ -119,7 +118,7 @@ const StoragePage = memo(function StoragePage() {
setEditingTarget(null)
}
}}
onCreated={query.refresh}
onCreated={() => (editingTarget ? query.refresh() : query.reload())}
editing={editingTarget}
/>
@@ -132,7 +131,7 @@ const StoragePage = memo(function StoragePage() {
loading={deleting}
description={
depRefCount > 0
? `确定要删除「${deleteTarget?.name}」吗?警告:仍有 ${depRefCount} 个部署使用该存储,删除后这些部署的对象存储配置将失效。`
? t('page.deleteWarning', { name: deleteTarget?.name, count: depRefCount })
: undefined
}
onConfirm={handleDelete}
@@ -1,6 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router'
import { Users, RefreshCw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
@@ -33,6 +34,7 @@ export const LdapImportDialog = memo(function LdapImportDialog({
localUsers,
onImported,
}: LdapImportDialogProps) {
const { t } = useTranslation('users')
const [loading, setLoading] = useState(false)
const [importing, setImporting] = useState(false)
const [users, setUsers] = useState<LdapUserPreview[]>([])
@@ -55,11 +57,11 @@ export const LdapImportDialog = memo(function LdapImportDialog({
setUsers(list)
setSelected(new Set())
} catch (err) {
setError(err instanceof Error ? err.message : '加载 LDAP 用户失败')
setError(err instanceof Error ? err.message : t('ldap.loadFailed'))
} finally {
setLoading(false)
}
}, [])
}, [t])
useEffect(() => {
if (open) void loadUsers()
@@ -88,7 +90,7 @@ export const LdapImportDialog = memo(function LdapImportDialog({
setResult(r)
onImported()
} catch (err) {
toast.error(err instanceof Error ? err.message : '导入失败')
toast.error(err instanceof Error ? err.message : t('ldap.importFailed'))
} finally {
setImporting(false)
}
@@ -98,21 +100,25 @@ export const LdapImportDialog = memo(function LdapImportDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>从 LDAP 导入用户</DialogTitle>
<DialogDescription>
来自 LDAP 目录的用户;导入后使用 LDAP 密码登录,角色为普通用户
</DialogDescription>
<DialogTitle>{t('ldap.title')}</DialogTitle>
<DialogDescription>{t('ldap.desc')}</DialogDescription>
</DialogHeader>
{result ? (
<div className="space-y-3">
<div className="flex items-center gap-4 text-sm">
{result.successCount > 0 && (
<span className="text-success">新增 {result.successCount}</span>
<span className="text-success">{t('ldap.added', { count: result.successCount })}</span>
)}
{result.updatedCount > 0 && (
<span className="text-info">{t('ldap.converted', { count: result.updatedCount })}</span>
)}
{result.skippedCount > 0 && (
<span className="text-muted-foreground">{t('ldap.skipped', { count: result.skippedCount })}</span>
)}
{result.failedCount > 0 && (
<span className="text-destructive">{t('ldap.failedCount', { count: result.failedCount })}</span>
)}
{result.updatedCount > 0 && <span className="text-info">转为 LDAP {result.updatedCount}</span>}
{result.skippedCount > 0 && <span className="text-muted-foreground">跳过 {result.skippedCount}</span>}
{result.failedCount > 0 && <span className="text-destructive">失败 {result.failedCount}</span>}
</div>
{result.failedUsers.length > 0 && (
<div className="text-xs text-destructive space-y-1 max-h-32 overflow-y-auto">
@@ -122,15 +128,17 @@ export const LdapImportDialog = memo(function LdapImportDialog({
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => void loadUsers()}>继续导入</Button>
<Button onClick={() => onOpenChange(false)}>完成</Button>
<Button variant="outline" onClick={() => void loadUsers()}>{t('ldap.continue')}</Button>
<Button onClick={() => onOpenChange(false)}>{t('ldap.done')}</Button>
</div>
</div>
) : (
<>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
{loading ? '加载 LDAP 用户…' : `共 ${users.length} 个用户,已选 ${selected.size} 个`}
{loading
? t('ldap.loading')
: t('ldap.summary', { total: users.length, selected: selected.size })}
</p>
<Button variant="ghost" size="sm" onClick={() => void loadUsers()} disabled={loading}>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
@@ -140,9 +148,11 @@ export const LdapImportDialog = memo(function LdapImportDialog({
<div className="space-y-3">
<p className="text-sm text-destructive">{error}</p>
<p className="text-xs text-muted-foreground">
请到
<Link to="/settings" className="text-primary mx-1">系统设置</Link>
检查 LDAP 配置与启用状态
{t('ldap.errorHintPre')}
<Link to="/settings" className="text-primary mx-1">
{t('menu.settings', { ns: 'common' })}
</Link>
{t('ldap.errorHintPost')}
</p>
</div>
) : (
@@ -168,14 +178,14 @@ export const LdapImportDialog = memo(function LdapImportDialog({
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{u.username}</span>
{isLdapAlready && (
<span className="text-xs text-muted-foreground">已导入</span>
<span className="text-xs text-muted-foreground">{t('ldap.imported')}</span>
)}
{willConvert && (
<span className="text-xs text-info">本地同名,将转为 LDAP</span>
<span className="text-xs text-info">{t('ldap.willConvert')}</span>
)}
</div>
<p className="text-xs text-muted-foreground truncate">
{u.displayName || '无姓名'} · {u.email || '无邮箱'}
{u.displayName || t('ldap.noName')} · {u.email || t('ldap.noEmail')}
</p>
</div>
</label>
@@ -183,16 +193,20 @@ export const LdapImportDialog = memo(function LdapImportDialog({
})}
{!loading && users.length === 0 && (
<div className="flex items-center justify-center gap-2 py-10 text-muted-foreground text-sm">
<Users className="w-4 h-4" /> 目录下没有用户
<Users className="w-4 h-4" /> {t('ldap.empty')}
</div>
)}
</div>
</ScrollArea>
)}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t('dialog.cancel', { ns: 'common' })}
</Button>
<Button onClick={handleImport} disabled={importing || selected.size === 0}>
{importing ? '导入中...' : `导入 ${selected.size} 个用户`}
{importing
? t('ldap.importing')
: t('ldap.importCount', { count: selected.size })}
</Button>
</div>
</>
@@ -1,5 +1,6 @@
import { memo, useEffect, useState } from 'react'
import { Dice5 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
@@ -39,6 +40,7 @@ export const ResetPasswordDialog = memo(function ResetPasswordDialog({
onClose,
onReset,
}: ResetPasswordDialogProps) {
const { t } = useTranslation('users')
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
@@ -53,7 +55,7 @@ export const ResetPasswordDialog = memo(function ResetPasswordDialog({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (password.length < 6) {
setError('密码至少需要 6 位')
setError(t('form.passwordMin'))
return
}
if (!user) return
@@ -63,7 +65,7 @@ export const ResetPasswordDialog = memo(function ResetPasswordDialog({
onClose()
onReset()
} catch (err) {
toast.error(err instanceof Error ? err.message : '重置密码失败')
toast.error(err instanceof Error ? err.message : t('resetFailed'))
} finally {
setSubmitting(false)
}
@@ -73,19 +75,19 @@ export const ResetPasswordDialog = memo(function ResetPasswordDialog({
<Dialog open={user !== null} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>重置密码</DialogTitle>
<DialogTitle>{t('resetPassword')}</DialogTitle>
<DialogDescription>
重置「{user?.username}」的密码后,该用户的所有登录会话将立即失效。
{t('resetDesc', { name: user?.username ?? '' })}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="reset-password">新密码 *</Label>
<Label htmlFor="reset-password">{t('newPassword')}</Label>
<div className="flex gap-2">
<PasswordInput
id="reset-password"
className="flex-1"
placeholder="至少 6 位"
placeholder={t('form.passwordPlaceholder')}
value={password}
onChange={(e) => {
setPassword(e.target.value)
@@ -97,8 +99,8 @@ export const ResetPasswordDialog = memo(function ResetPasswordDialog({
<Button
type="button"
variant="outline"
aria-label="随机生成密码"
title="随机生成密码"
aria-label={t('randomPassword')}
title={t('randomPassword')}
onClick={() => {
setPassword(randomPassword())
setError('')
@@ -111,10 +113,10 @@ export const ResetPasswordDialog = memo(function ResetPasswordDialog({
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose} disabled={submitting}>
取消
{t('dialog.cancel', { ns: 'common' })}
</Button>
<Button type="submit" disabled={submitting || password.length < 6}>
{submitting ? '重置中...' : '重置密码'}
{submitting ? t('resetting') : t('resetPassword')}
</Button>
</div>
</form>
@@ -1,7 +1,8 @@
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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
@@ -29,17 +30,19 @@ import { PasswordInput } from '@/components/PasswordInput'
import { userApi } from '@/api'
import { toast } from 'sonner'
const userFormSchema = z.object({
username: z
.string()
.min(2, '用户名至少 2 个字符')
.max(32, '用户名最多 32 个字符'),
nickname: z.string().optional(),
password: z.string().min(6, '密码至少需要 6 位'),
role: z.enum(['admin', 'user']),
})
// 表单校验 — 工厂接收 t,以便 zod 报错文案走 users 词典(见 form.*)
const buildUserFormSchema = (t: (key: string) => string) =>
z.object({
username: z
.string()
.min(2, t('form.usernameMin'))
.max(32, t('form.usernameMax')),
nickname: z.string().optional(),
password: z.string().min(6, t('form.passwordMin')),
role: z.enum(['admin', 'user']),
})
export type UserFormValues = z.infer<typeof userFormSchema>
export type UserFormValues = z.infer<ReturnType<typeof buildUserFormSchema>>
const DEFAULT_VALUES: UserFormValues = {
username: '',
@@ -60,8 +63,12 @@ export const UserFormDialog = memo(function UserFormDialog({
onOpenChange,
onCreated,
}: UserFormDialogProps) {
const { t } = useTranslation('users')
const [submitting, setSubmitting] = useState(false)
// 报错文案依赖当前语言,t 变化时重建 schema(resolver 随之更新)
const userFormSchema = useMemo(() => buildUserFormSchema(t), [t])
const form = useForm<UserFormValues>({
resolver: zodResolver(userFormSchema),
defaultValues: DEFAULT_VALUES,
@@ -85,7 +92,7 @@ export const UserFormDialog = memo(function UserFormDialog({
onOpenChange(false)
onCreated()
} catch (err) {
toast.error(err instanceof Error ? err.message : '创建用户失败')
toast.error(err instanceof Error ? err.message : t('createFailed'))
} finally {
setSubmitting(false)
}
@@ -95,7 +102,7 @@ export const UserFormDialog = memo(function UserFormDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>创建用户</DialogTitle>
<DialogTitle>{t('create')}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
@@ -104,9 +111,9 @@ export const UserFormDialog = memo(function UserFormDialog({
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>用户名 *</FormLabel>
<FormLabel>{t('form.username')}</FormLabel>
<FormControl>
<Input placeholder="登录用户名" autoComplete="off" {...field} />
<Input placeholder={t('form.usernamePlaceholder')} autoComplete="off" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -117,9 +124,9 @@ export const UserFormDialog = memo(function UserFormDialog({
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel>昵称</FormLabel>
<FormLabel>{t('form.nickname')}</FormLabel>
<FormControl>
<Input placeholder="显示昵称(可选)" {...field} />
<Input placeholder={t('form.nicknamePlaceholder')} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -130,10 +137,10 @@ export const UserFormDialog = memo(function UserFormDialog({
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>初始密码 *</FormLabel>
<FormLabel>{t('form.password')}</FormLabel>
<FormControl>
<PasswordInput
placeholder="至少 6 位"
placeholder={t('form.passwordPlaceholder')}
autoComplete="new-password"
{...field}
/>
@@ -147,16 +154,16 @@ export const UserFormDialog = memo(function UserFormDialog({
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>角色 *</FormLabel>
<FormLabel>{t('form.role')}</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="选择角色" />
<SelectValue placeholder={t('form.rolePlaceholder')} />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="user">普通用户</SelectItem>
<SelectItem value="admin">管理员</SelectItem>
<SelectItem value="user">{t('role.user')}</SelectItem>
<SelectItem value="admin">{t('role.admin')}</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -164,7 +171,7 @@ export const UserFormDialog = memo(function UserFormDialog({
)}
/>
<Button type="submit" className="w-full" disabled={submitting}>
{submitting ? '创建中...' : '创建用户'}
{submitting ? t('form.submitting') : t('create')}
</Button>
</form>
</Form>
+127 -26
View File
@@ -1,6 +1,9 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { Users, UserRound, UsersRound, Plus, KeyRound, Trash2, ShieldCheck } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import '@/i18n/domains'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { userApi } from '@/api'
import { PageContainer } from '@/components/PageContainer'
@@ -19,22 +22,24 @@ import { ResetPasswordDialog } from './components/ResetPasswordDialog'
/** 角色徽章 */
function SourceBadge({ source }: { source?: string }) {
const { t } = useTranslation('users')
return (
<span className="inline-flex items-center px-2 py-0.5 rounded-full bg-info/10 text-info text-xs font-medium">
{source === 'ldap' ? 'LDAP 导入' : '本地账号'}
<span className="inline-flex items-center px-2 py-0.5 rounded-full bg-info/10 text-info text-xs font-medium">
{source === 'ldap' ? t('source.ldap') : t('source.local')}
</span>
)
}
function RoleBadge({ role }: { role: string }) {
const { t } = useTranslation('users')
return role === 'admin' ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-success/10 text-success text-xs font-medium">
<ShieldCheck className="w-3 h-3" />
管理员
{t('role.admin')}
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-muted text-muted-foreground text-xs font-medium">
普通用户
{t('role.user')}
</span>
)
}
@@ -45,6 +50,7 @@ function RoleBadge({ role }: { role: string }) {
* - 单卡片行列表 + 创建/重置密码/删除;成功操作静默(反馈分级)
*/
const UsersPage = memo(function UsersPage() {
const { t } = useTranslation('users')
const currentUser = getUser()
const isAdmin = currentUser?.role === 'admin'
@@ -66,6 +72,56 @@ const UsersPage = memo(function UsersPage() {
const [resetTarget, setResetTarget] = useState<User | null>(null)
const [deleteTarget, setDeleteTarget] = useState<User | null>(null)
const [deleting, setDeleting] = useState(false)
// 批量删除:自己一行不可选(后端守卫同样拒绝)
const [selected, setSelected] = useState<Set<number>>(new Set())
const [batchConfirm, setBatchConfirm] = useState(false)
const [batchDeleting, setBatchDeleting] = useState(false)
// 列表刷新后剔除已不存在的选择项(如翻页/删除后)
useEffect(() => {
setSelected((prev) => {
const alive = new Set(query.list.map((u) => u.id))
const next = new Set([...prev].filter((id) => alive.has(id)))
return next.size === prev.size ? prev : next
})
}, [query.list])
const selectable = query.list.filter((u) => u.id !== currentUser?.id)
const allSelected = selectable.length > 0 && selectable.every((u) => selected.has(u.id))
const toggleSelect = (id: number) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
setSelected((prev) => {
if (selectable.every((u) => prev.has(u.id))) return new Set()
return new Set(selectable.map((u) => u.id))
})
}
const handleBatchDelete = useCallback(async () => {
setBatchDeleting(true)
try {
const res = await userApi.batchRemove([...selected])
setBatchConfirm(false)
setSelected(new Set())
if (res.failures.length > 0) {
const reasons = res.failures.map((f) => `#${f.id}: ${f.reason}`).join('; ')
toast.warning(t('batch.skippedWarn', { count: res.failures.length, reasons }))
}
query.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : t('batch.failed'))
} finally {
setBatchDeleting(false)
}
}, [selected, query, t])
const handleDelete = useCallback(async () => {
const id = deleteTarget?.id
@@ -76,20 +132,20 @@ const UsersPage = memo(function UsersPage() {
setDeleteTarget(null)
query.refresh()
} catch (err) {
toast.error(err instanceof Error ? err.message : '删除用户失败')
toast.error(err instanceof Error ? err.message : t('deleteFailed'))
} finally {
setDeleting(false)
}
}, [deleteTarget, query])
}, [deleteTarget, query, t])
// 非管理员:菜单不可见,直访时给明确提示
if (!isAdmin) {
return (
<PageContainer title="用户管理">
<PageContainer title={t('title')}>
<EmptyState
icon={UserRound}
title="需要管理员权限"
description="用户管理仅管理员可用,请联系管理员处理账号事宜"
title={t('adminOnly.title')}
description={t('adminOnly.desc')}
/>
</PageContainer>
)
@@ -97,19 +153,19 @@ const UsersPage = memo(function UsersPage() {
return (
<PageContainer
title="用户管理"
description="管理平台登录账号(仅管理员)"
title={t('title')}
description={t('subtitle')}
action={
<div className="flex items-center gap-2">
{ldapReady && (
<Button variant="outline" onClick={() => setLdapImportOpen(true)}>
<UsersRound className="w-4 h-4 mr-1" />
从 LDAP 导入
{t('ldapImport')}
</Button>
)}
<Button onClick={() => setFormOpen(true)}>
<Plus className="w-4 h-4 mr-1" />
创建用户
{t('create')}
</Button>
</div>
}
@@ -119,16 +175,47 @@ const UsersPage = memo(function UsersPage() {
) : query.error ? (
<ErrorState message={query.error} onRetry={query.refresh} />
) : query.list.length === 0 ? (
<EmptyState icon={Users} title="暂无用户" description="创建第一个用户账号" />
<EmptyState icon={Users} title={t('emptyTitle')} description={t('emptyDesc')} />
) : (
<Card className="border-border">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
<CardTitle className="text-base flex items-center gap-2 w-full">
<Users className="w-4 h-4" />
用户列表(共 {query.total} 个)
{t('listTitle', { count: query.total })}
{selectable.length > 0 && (
<label className="ml-auto flex items-center gap-2 text-xs font-normal text-muted-foreground cursor-pointer">
<Checkbox
checked={allSelected}
onCheckedChange={toggleSelectAll}
aria-label={t('batch.selectAll')}
/>
{t('batch.selectAll')}
</label>
)}
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
{selected.size > 0 && (
<div className="flex items-center justify-between gap-2 mb-3 rounded-lg bg-muted/60 px-3 py-2">
<span className="text-sm text-muted-foreground">
{t('batch.selected', { count: selected.size })}
</span>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setSelected(new Set())}>
{t('batch.clear')}
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:bg-destructive/10"
onClick={() => setBatchConfirm(true)}
>
<Trash2 className="w-3.5 h-3.5 mr-1" />
{t('batch.delete')}
</Button>
</div>
</div>
)}
<div className="divide-y divide-border">
{query.list.map((user) => (
<div
@@ -136,6 +223,13 @@ const UsersPage = memo(function UsersPage() {
className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0"
>
<div className="flex items-center gap-3 min-w-0">
<Checkbox
checked={selected.has(user.id)}
onCheckedChange={() => toggleSelect(user.id)}
disabled={user.id === currentUser?.id}
aria-label={t('selectAria', { name: user.username })}
className="flex-shrink-0"
/>
<div className="w-9 h-9 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
<UserRound className="w-4 h-4 text-muted-foreground" />
</div>
@@ -145,13 +239,13 @@ const UsersPage = memo(function UsersPage() {
<RoleBadge role={user.role} />
<SourceBadge source={user.authSource} />
{user.id === currentUser?.id && (
<span className="text-xs text-muted-foreground">(当前登录)</span>
<span className="text-xs text-muted-foreground">{t('currentUser')}</span>
)}
</div>
<p className="text-xs text-muted-foreground truncate mt-0.5">
{user.nickname || '未设置昵称'}
{user.nickname || t('noNickname')}
{user.createdAt &&
` · 创建于 ${new Date(user.createdAt).toLocaleDateString()}`}
` · ${t('createdAt', { date: new Date(user.createdAt).toLocaleDateString() })}`}
</p>
</div>
</div>
@@ -159,18 +253,16 @@ const UsersPage = memo(function UsersPage() {
{user.authSource !== 'ldap' && (
<Button variant="outline" size="sm" onClick={() => setResetTarget(user)}>
<KeyRound className="w-3.5 h-3.5 mr-1" />
重置密码
{t('resetPassword')}
</Button>
)}
<Button
variant="outline"
size="sm"
aria-label={`删除用户 ${user.username}`}
aria-label={t('deleteAria', { name: user.username })}
className="text-destructive hover:bg-destructive/10"
disabled={user.id === currentUser?.id}
title={
user.id === currentUser?.id ? '不能删除当前登录账号' : undefined
}
title={user.id === currentUser?.id ? t('cannotDeleteSelf') : undefined}
onClick={() => setDeleteTarget(user)}
>
<Trash2 className="w-3.5 h-3.5" />
@@ -201,18 +293,27 @@ const UsersPage = memo(function UsersPage() {
user={resetTarget}
onClose={() => setResetTarget(null)}
onReset={() => {
toast.info(`已重置「${resetTarget?.username}」的密码,其所有会话已失效`)
toast.info(t('resetDone', { name: resetTarget?.username ?? '' }))
setResetTarget(null)
}}
/>
<ConfirmDialog
open={batchConfirm}
onOpenChange={setBatchConfirm}
title={t('batch.confirmTitle')}
description={t('batch.confirmDesc', { count: selected.size })}
loading={batchDeleting}
onConfirm={handleBatchDelete}
/>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null)
}}
name={deleteTarget?.username}
title="确认删除用户"
title={t('confirmDeleteTitle')}
onConfirm={handleDelete}
loading={deleting}
/>
@@ -1,6 +1,7 @@
import { memo, useState } from 'react'
import { Plus, Rocket, ExternalLink } from 'lucide-react'
import { Link } from 'react-router'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { StatusBadge } from '@/components/StatusBadge'
@@ -36,6 +37,7 @@ export const StepDeploy = memo(function StepDeploy({
storages,
onChanged,
}: StepDeployProps) {
const { t } = useTranslation('wizard')
const [open, setOpen] = useState(false)
const [deploying, setDeploying] = useState<number | null>(null)
@@ -48,7 +50,7 @@ export const StepDeploy = memo(function StepDeploy({
if (latest && latest.status === 'pending') {
setDeploying(latest.id)
await deploymentApi.deploy(latest.id)
toast.info(`已触发「${latest.name}」部署,可在部署页查看日志与进度`)
toast.info(t('deploy.deployTriggered', { name: latest.name }))
}
} catch {
// 列表/触发失败不打断向导,用户可到部署页手动触发
@@ -75,8 +77,12 @@ export const StepDeploy = memo(function StepDeploy({
)}
</div>
<p className="text-xs text-muted-foreground truncate mt-0.5">
{runningLoki.mode} · 命名空间 {runningLoki.namespace || '-'} ·{' '}
{runningLoki.endpoint || 'endpoint 待部署完成生成'}
{t('deploy.meta', {
mode: runningLoki.mode,
namespace: runningLoki.namespace || '-',
endpoint:
runningLoki.endpoint || t('deploy.endpointPending'),
})}
</p>
{runningLoki.grafanaEndpoint && (
<a
@@ -86,7 +92,9 @@ export const StepDeploy = memo(function StepDeploy({
className="text-xs text-primary hover:underline inline-flex items-center gap-1 mt-1"
>
<ExternalLink className="w-3 h-3" />
打开 Grafana({runningLoki.grafanaEndpoint.replace(/^https?:\/\//, '')} · admin/admin)
{t('deploy.openGrafana', {
url: runningLoki.grafanaEndpoint.replace(/^https?:\/\//, ''),
})}
</a>
)}
</div>
@@ -99,34 +107,34 @@ export const StepDeploy = memo(function StepDeploy({
target="_blank"
rel="noopener noreferrer"
className={
runningLoki.mode === 'k8s-helm' || runningLoki.mode === 'k8s-yaml'
runningLoki.mode === 'k8s-yaml'
? 'pointer-events-none opacity-50'
: undefined
}
>
<ExternalLink className="w-3.5 h-3.5 mr-1" />
打开 Loki
{t('deploy.openLoki')}
</a>
</Button>
)}
<Button variant="outline" size="sm" asChild>
<Link to="/deployments">部署详情</Link>
<Link to="/deployments">{t('deploy.details')}</Link>
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
尚无运行中的 Loki —— 选择 Kubernetes 或主机 Docker 部署
{t('deploy.none')}
</p>
<Button size="sm" onClick={() => setOpen(true)}>
<Plus className="w-4 h-4 mr-1" />
创建部署
{t('deploy.create')}
</Button>
</div>
)}
{deploying !== null && (
<p className="text-xs text-info">正在触发部署…</p>
<p className="text-xs text-info">{t('deploy.triggering')}</p>
)}
<DeploymentFormDialog
@@ -1,5 +1,6 @@
import { memo, useState } from 'react'
import { Boxes, Server, Plus, CheckCircle2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { cn } from '@/lib/utils'
@@ -17,7 +18,7 @@ interface StepEnvProps {
}
/**
* 步骤① 选择部署环境与接入目标 —— 首步先定形态(K8s 集群 / 裸金属主机),
* 步骤① 选择部署环境与接入目标 —— 首步先定形态(Kubernetes 集群 / 裸金属主机),
* 该选择分支决定后续所有步骤(Loki 模式、采集类型、Grafana 形态)。
*/
export const StepEnv = memo(function StepEnv({
@@ -27,6 +28,7 @@ export const StepEnv = memo(function StepEnv({
hostNames,
onChanged,
}: StepEnvProps) {
const { t } = useTranslation('wizard')
const [selected, setSelected] = useState<EnvKind | null>(null)
const [clusterOpen, setClusterOpen] = useState(false)
const [hostOpen, setHostOpen] = useState(false)
@@ -42,16 +44,16 @@ export const StepEnv = memo(function StepEnv({
{
kind: 'k8s',
icon: Boxes,
title: 'Kubernetes 集群',
desc: '通过 Kubernetes API 部署 Loki StatefulSet,DaemonSet 采集容器日志',
title: t('env.k8sTitle'),
desc: t('env.k8sDesc'),
ready: hasActiveCluster,
names: clusterNames,
},
{
kind: 'host',
icon: Server,
title: '裸金属主机',
desc: '以主机 Docker 部署 Loki,采集指定日志文件',
title: t('env.hostTitle'),
desc: t('env.hostDesc'),
ready: hasActiveHost,
names: hostNames,
},
@@ -85,14 +87,16 @@ export const StepEnv = memo(function StepEnv({
{env.ready && (
<span className="inline-flex items-center gap-1 text-xs text-success">
<CheckCircle2 className="w-3.5 h-3.5" />
已就绪
{t('env.ready')}
</span>
)}
</div>
<p className="text-xs text-muted-foreground leading-4">{env.desc}</p>
{env.names.length > 0 && (
<p className="text-xs text-muted-foreground mt-2 truncate">
已接入:{env.names.join('、')}
{t('env.connected', {
names: env.names.join(t('env.nameSep')),
})}
</p>
)}
</button>
@@ -103,28 +107,28 @@ export const StepEnv = memo(function StepEnv({
{selected === 'k8s' && (
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-3">
<p className="text-sm text-muted-foreground">
通过 kubeconfig 或 ServiceAccount Token 接入集群,创建后自动验证连通性
{t('env.k8sHint')}
</p>
<Button size="sm" onClick={() => setClusterOpen(true)}>
<Plus className="w-4 h-4 mr-1" />
添加集群
{t('env.addCluster')}
</Button>
</div>
)}
{selected === 'host' && (
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-3">
<p className="text-sm text-muted-foreground">
SSH 密钥或密码接入主机(用于部署与后续采集器安装)
{t('env.hostHint')}
</p>
<Button size="sm" onClick={() => setHostOpen(true)}>
<Plus className="w-4 h-4 mr-1" />
添加主机
{t('env.addHost')}
</Button>
</div>
)}
{!selected && (
<p className="text-sm text-muted-foreground text-center py-1">
↑ 选择部署环境后,此处提供对应的目标接入表单
{t('env.pickFirst')}
</p>
)}
@@ -1,5 +1,6 @@
import { memo, useState } from 'react'
import { Plus, CheckCircle2, Database } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { StorageFormDialog } from '@/pages/storage/components/StorageFormDialog'
@@ -20,6 +21,7 @@ export const StepStorage = memo(function StepStorage({
defaultStorage,
onChanged,
}: StepStorageProps) {
const { t } = useTranslation('wizard')
const [open, setOpen] = useState(false)
return (
@@ -30,33 +32,29 @@ export const StepStorage = memo(function StepStorage({
<Database className="w-4 h-4 text-muted-foreground" />
{hasStorage ? (
<span>
默认存储:<span className="font-medium text-foreground">{defaultStorage}</span>
{t('storage.defaultLabel')}
<span className="font-medium text-foreground">{defaultStorage}</span>
</span>
) : (
<span className="text-muted-foreground">
尚未配置存储 —— 可选本地 MinIO / RustFS,或 AWS / 阿里云对象存储
</span>
<span className="text-muted-foreground">{t('storage.none')}</span>
)}
</div>
<div className="flex items-center gap-2">
{hasStorage && (
<Button variant="outline" size="sm" asChild>
<Link to="/storage">管理存储</Link>
<Link to="/storage">{t('storage.manage')}</Link>
</Button>
)}
<Button size="sm" onClick={() => setOpen(true)}>
<Plus className="w-4 h-4 mr-1" />
{hasStorage ? '添加存储' : '配置存储'}
{hasStorage ? t('storage.add') : t('storage.configure')}
</Button>
</div>
</div>
<div className="flex items-start gap-2 rounded-lg bg-muted/50 p-3 text-xs text-muted-foreground leading-5">
<CheckCircle2 className="w-4 h-4 mt-0.5 text-success flex-shrink-0" />
<p>
推荐对象存储(MinIO / RustFS / S3 / OSS):多副本与数据保留更可靠。
添加时可用「测试连接」验证凭证,并自动创建不存在的桶;跳过则使用本地磁盘。
</p>
<p>{t('storage.hint')}</p>
</div>
<StorageFormDialog open={open} onOpenChange={setOpen} onCreated={onChanged} />
@@ -1,5 +1,6 @@
import { memo } from 'react'
import { Check } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
export interface WizardStepInfo {
@@ -9,14 +10,15 @@ export interface WizardStepInfo {
}
/**
* 向导步骤地图 — 状态推导式(opsx-sec 范式):完成态由真实数据计算,
* 向导步骤地图 — 状态推导式范式:完成态由真实数据计算,
* 非用户点击;active = 第一个未完成步骤。可任意顺序操作,刷新不丢。
*/
export const WizardSteps = memo(function WizardSteps({ steps }: { steps: WizardStepInfo[] }) {
const { t } = useTranslation('wizard')
const activeIndex = steps.findIndex((s) => !s.done)
return (
<div className="flex items-start gap-0 overflow-x-auto pb-2" aria-label="部署流程">
<div className="flex items-start gap-0 overflow-x-auto pb-2" aria-label={t('steps.flow')}>
{steps.map((step, i) => {
const isActive = i === activeIndex
const isDone = step.done
@@ -30,7 +32,11 @@ export const WizardSteps = memo(function WizardSteps({ steps }: { steps: WizardS
isActive && !isDone && 'bg-primary/10 border-primary text-primary animate-pulse',
!isActive && !isDone && 'bg-muted border-border text-muted-foreground'
)}
aria-label={isDone ? `${step.title} 已完成` : `${step.title} 待完成`}
aria-label={
isDone
? t('steps.doneAria', { title: step.title })
: t('steps.todoAria', { title: step.title })
}
>
{isDone ? <Check className="w-4 h-4" /> : <span className="text-sm font-semibold">{i + 1}</span>}
</div>
+36 -20
View File
@@ -1,6 +1,7 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router'
import { RadioTower, ArrowRight, Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Card, CardContent } from '@/components/ui/card'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { ErrorState } from '@/components/ErrorState'
@@ -13,6 +14,8 @@ import { StepEnv } from './components/StepEnv'
import { StepStorage } from './components/StepStorage'
import { StepDeploy } from './components/StepDeploy'
import { CollectorFormDialog } from '@/pages/collectors/components/CollectorFormDialog'
import '@/i18n/locales/zh/wizard'
import '@/i18n/locales/en/wizard'
interface Preflight {
hasStorage: boolean
@@ -39,6 +42,7 @@ interface Preflight {
* ①环境与目标 → ②存储 → ③Loki(+Grafana)→ ④采集
*/
const WizardPage = memo(function WizardPage() {
const { t } = useTranslation('wizard')
const [pre, setPre] = useState<Preflight | null>(null)
const [error, setError] = useState<string | null>(null)
const [clusters, setClusters] = useState<Cluster[]>([])
@@ -63,9 +67,9 @@ const WizardPage = memo(function WizardPage() {
setRunningDeployments(ds.list.filter((x) => x.status === 'running'))
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : '加载向导状态失败')
setError(err instanceof Error ? err.message : t('page.loadFailed'))
}
}, [])
}, [t])
useEffect(() => {
// refresh 为 async,setState 均发生在 await 之后(规则静态分析无法推断)
@@ -75,7 +79,7 @@ const WizardPage = memo(function WizardPage() {
if (error) {
return (
<PageContainer title="部署向导">
<PageContainer title={t('page.title')}>
<ErrorState message={error} onRetry={refresh} />
</PageContainer>
)
@@ -86,27 +90,39 @@ const WizardPage = memo(function WizardPage() {
const steps: WizardStepInfo[] = [
{
title: '环境与目标',
desc: 'K8s 集群或裸金属主机',
title: t('steps.env.title'),
desc: t('steps.env.desc'),
done: pre.hasActiveCluster || pre.hasActiveHost,
},
{ title: '准备存储', desc: '对象存储或本地磁盘', done: pre.hasStorage },
{ title: '部署 Loki', desc: '日志查询平台内置', done: pre.runningLoki !== null },
{ title: '配置采集', desc: '主机文件 / 集群 DaemonSet', done: pre.activeCollectors > 0 },
{
title: t('steps.storage.title'),
desc: t('steps.storage.desc'),
done: pre.hasStorage,
},
{
title: t('steps.deploy.title'),
desc: t('steps.deploy.desc'),
done: pre.runningLoki !== null,
},
{
title: t('steps.collect.title'),
desc: t('steps.collect.desc'),
done: pre.activeCollectors > 0,
},
]
const firstUndone = steps.findIndex((s) => !s.done)
const hints: string[] = []
if (firstUndone === 0) hints.push('先选择部署环境并接入集群或主机')
if (firstUndone === 1) hints.push('环境已就绪,建议先配置对象存储')
if (firstUndone === 2) hints.push('目标与存储已就绪,创建 Loki 部署吧')
if (firstUndone === 3) hints.push('Loki 已运行,添加采集策略开始收集日志')
if (firstUndone === -1) hints.push('全部就绪:日志正在收集,可到日志查询页分析')
if (firstUndone === 0) hints.push(t('hint.env'))
if (firstUndone === 1) hints.push(t('hint.storage'))
if (firstUndone === 2) hints.push(t('hint.deploy'))
if (firstUndone === 3) hints.push(t('hint.collect'))
if (firstUndone === -1) hints.push(t('hint.all'))
return (
<PageContainer
title="部署向导"
description="四步完成日志平台:环境 → 存储 → 部署 → 采集(步骤可按任意顺序完成)"
title={t('page.title')}
description={t('page.description')}
>
<Card className="border-border">
<CardContent className="p-5">
@@ -147,15 +163,15 @@ const WizardPage = memo(function WizardPage() {
<RadioTower className="w-5 h-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">
日志采集策略
{t('collect.title')}
{pre.activeCollectors > 0 && (
<span className="ml-2 text-xs text-success">
{pre.activeCollectors} 个采集中
{t('collect.activeCount', { count: pre.activeCollectors })}
</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
主机:指定日志文件路径 · 集群:DaemonSet 全量容器日志(部署到关联 Loki 的命名空间)
{t('collect.desc')}
</p>
</div>
</div>
@@ -167,12 +183,12 @@ const WizardPage = memo(function WizardPage() {
onClick={() => setCollectorOpen(true)}
>
<Plus className="w-3.5 h-3.5 mr-1" />
添加采集策略
{t('collect.add')}
</Button>
)}
<Button variant="outline" size="sm" asChild>
<Link to="/collectors">
管理采集
{t('collect.manage')}
<ArrowRight className="w-3.5 h-3.5 ml-1" />
</Link>
</Button>