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

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

No files matched your search

+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate icon" href="/favicon.ico" />
<title>opsx-loki</title>
<title>LokiStack</title>
</head>
<body>
<div id="root"></div>
+69 -3
View File
@@ -1,11 +1,11 @@
{
"name": "lokideploy-frontend",
"name": "lokistack-frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lokideploy-frontend",
"name": "lokistack-frontend",
"version": "1.0.0",
"dependencies": {
"@hookform/resolvers": "^5.2.2",
@@ -40,6 +40,7 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"i18next": "^26.5.11",
"input-otp": "^1.4.2",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
@@ -47,6 +48,7 @@
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.70.0",
"react-i18next": "^17.1.9",
"react-resizable-panels": "^4.2.2",
"react-router": "^7.6.1",
"recharts": "^2.15.4",
@@ -4372,6 +4374,43 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/html-parse-stringify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
"license": "MIT",
"funding": {
"url": "https://locize.com"
}
},
"node_modules/i18next": {
"version": "26.5.11",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.11.tgz",
"integrity": "sha512-Wc0klBPKxqZ2tL2qdAnF8xvvnWgr1IMGSY2ikTiAvaFVbz7xiFARtkoU4RidBDw1SyziGROp0qxu6qnJ7l2Wug==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ignore": {
"version": "5.3.2",
"dev": true,
@@ -5099,6 +5138,33 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-i18next": {
"version": "17.1.9",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.9.tgz",
"integrity": "sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"html-parse-stringify": "^4.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -5650,7 +5716,7 @@
},
"node_modules/typescript": {
"version": "5.9.3",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
+3 -1
View File
@@ -1,5 +1,5 @@
{
"name": "opsx-loki-frontend",
"name": "lokistack-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
@@ -42,6 +42,7 @@
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"i18next": "^26.5.11",
"input-otp": "^1.4.2",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
@@ -49,6 +50,7 @@
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.70.0",
"react-i18next": "^17.1.9",
"react-resizable-panels": "^4.2.2",
"react-router": "^7.6.1",
"recharts": "^2.15.4",
+7 -4
View File
@@ -3,6 +3,7 @@ import { Routes, Route, Navigate, useLocation } from 'react-router'
import { Toaster } from '@/components/ui/sonner'
import { isLoggedIn, getUser } from '@/lib/auth-storage'
import { routes, findRouteMeta } from '@/router/routes'
import { useTranslation } from 'react-i18next'
const Layout = lazy(() => import('@/components/Layout'))
@@ -36,13 +37,15 @@ function AppLoadingFallback() {
)
}
// document.title 由路由 meta 单源驱动
// document.title 由路由 meta 单源驱动(meta.title 为 i18n key,common 命名空间)
function useDocumentTitle() {
const location = useLocation()
const { t, i18n } = useTranslation()
useEffect(() => {
const title = findRouteMeta(location.pathname)?.title
document.title = title && location.pathname !== '/login' ? `${title} · lokistack` : 'lokistack'
}, [location.pathname])
const titleKey = findRouteMeta(location.pathname)?.title
const title = titleKey ? t(titleKey) : ''
document.title = title && location.pathname !== '/login' ? `${title} · LokiStack` : 'LokiStack'
}, [location.pathname, t, i18n.language])
}
/**
-3
View File
@@ -34,7 +34,4 @@ export const hostApi = {
validate: (id: number) => http.post<{ message: string }>(`/hosts/${id}/validate`),
getInfo: (id: number) => http.get<HostInfo>(`/hosts/${id}/info`),
execute: (id: number, command: string) =>
http.post<{ stdout: string; stderr: string }>(`/hosts/${id}/execute`, { command }),
};
+7
View File
@@ -17,6 +17,13 @@ export const userApi = {
remove: (id: number) => http.delete<{ message: string }>(`/users/${id}`),
// 批量删除:逐个应用守卫(不能删自己/最后一个 admin),失败项单独返回
batchRemove: (ids: number[]) =>
http.post<{ deleted: number; failures: Array<{ id: number; reason: string }> }>(
'/users/batch-delete',
{ ids },
),
// 管理员重置密码(后端会吊销目标用户全部旧会话)
resetPassword: (id: number, newPassword: string) =>
http.post<{ message: string }>(`/users/${id}/reset-password`, { newPassword }),
@@ -0,0 +1,53 @@
import { memo } from 'react'
import type { LucideIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
type CardActionButtonProps = {
/** 图标 */
icon: LucideIcon
/** 危险操作(删除)语义色 */
destructive?: boolean
/** 图标旋转(验证/部署等进行中态) */
spinning?: boolean
disabled?: boolean
onClick?: () => void
/** 追加样式(如部署钮的 success 底色) */
className?: string
} & (
/** 文字钮:图标 + 文案,内容宽度,不拉伸 */
| { label: string; ariaLabel?: undefined }
/** 纯图标钮:必须提供可访问名,同时作为 title 悬浮提示 */
| { label?: undefined; ariaLabel: string }
)
/**
* 资源卡片操作钮 — 五大资源页(集群/主机/存储/部署/采集)统一形式:
* outline + sm + 内容宽度(不用 flex-1,换行交给 ResourceCard 操作区的 flex-wrap)。
*/
export const CardActionButton = memo(function CardActionButton({
icon: Icon,
destructive = false,
spinning = false,
disabled = false,
onClick,
className,
label,
ariaLabel,
}: CardActionButtonProps) {
return (
<Button
type="button"
variant="outline"
size="sm"
className={cn('text-xs', destructive && 'text-destructive hover:text-destructive hover:bg-destructive/10', className)}
aria-label={label ? undefined : ariaLabel}
title={label ? undefined : ariaLabel}
disabled={disabled}
onClick={onClick}
>
<Icon className={cn(label ? 'size-3 mr-1' : 'size-3.5', spinning && 'animate-spin')} />
{label}
</Button>
)
})
+11 -7
View File
@@ -1,4 +1,5 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogAction,
@@ -31,26 +32,29 @@ export const ConfirmDialog = memo(function ConfirmDialog({
open,
onOpenChange,
name,
title = '确认删除',
title,
description,
confirmText = '删除',
confirmText,
loading = false,
onConfirm,
}: ConfirmDialogProps) {
const { t } = useTranslation()
const desc =
description ??
(name
? `确定要删除「${name}」吗?此操作不可恢复。`
: '确定要执行该操作吗?此操作不可恢复。')
? t('dialog.confirmDefault', { name })
: t('dialog.confirmIrreversible'))
const resolvedTitle = title ?? t('dialog.confirmTitle')
const resolvedConfirmText = confirmText ?? t('dialog.confirmText')
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogTitle>{resolvedTitle}</AlertDialogTitle>
<AlertDialogDescription>{desc}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={loading}>{t('dialog.cancel')}</AlertDialogCancel>
<AlertDialogAction
disabled={loading}
onClick={(e) => {
@@ -60,7 +64,7 @@ export const ConfirmDialog = memo(function ConfirmDialog({
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{loading ? '处理中...' : confirmText}
{loading ? t('dialog.processing') : resolvedConfirmText}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+7 -6
View File
@@ -1,3 +1,4 @@
import i18n from '@/i18n'
import { Component, type ReactNode } from 'react'
interface State {
@@ -16,7 +17,7 @@ function isChunkLoadError(error: Error): boolean {
)
}
const CHUNK_RELOADED_FLAG = 'opsxloki.chunk-reloaded'
const CHUNK_RELOADED_FLAG = 'lokistack.chunk-reloaded'
/**
* 全局错误边界
@@ -48,25 +49,25 @@ export default class ErrorBoundary extends Component<{ children: ReactNode }, St
return (
<div className="min-h-screen flex items-center justify-center bg-muted dark:bg-slate-950 p-4">
<div className="max-w-lg w-full bg-card rounded-2xl shadow-lg border border-destructive/30 p-6">
<h2 className="text-lg font-semibold text-destructive mb-2">页面渲染出错</h2>
<h2 className="text-lg font-semibold text-destructive mb-2">{i18n.t('boundary.title')}</h2>
<p className="text-sm text-muted-foreground mb-3">
页面遇到异常,可尝试刷新;若持续出现请把下方信息反馈给开发者。
{i18n.t('boundary.hint')}
</p>
<pre className="text-xs bg-muted font-mono text-destructive p-3 rounded-lg overflow-auto max-h-40 font-mono whitespace-pre-wrap">
{this.state.message || '未知错误'}
{this.state.message || i18n.t('boundary.unknown')}
</pre>
<div className="flex gap-2 mt-4">
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-primary text-white rounded-lg text-sm hover:bg-primary/90"
>
刷新页面
{i18n.t('boundary.reload')}
</button>
<button
onClick={this.handleReset}
className="px-4 py-2 bg-muted text-muted-foreground rounded-lg text-sm hover:bg-muted"
>
重置组件
{i18n.t('boundary.reset')}
</button>
</div>
</div>
+7 -4
View File
@@ -1,4 +1,5 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { AlertCircle, RotateCw } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -15,21 +16,23 @@ interface ErrorStateProps {
* 禁止把失败吞掉后渲染成「暂无数据」(数据组件三态之一,见开发标准 07 章)。
*/
export const ErrorState = memo(function ErrorState({
message = '加载失败',
message,
onRetry,
className,
}: ErrorStateProps) {
const { t } = useTranslation()
const resolvedMessage = message ?? t('error.title')
return (
<Card className="border-destructive/30 border-dashed">
<CardContent className={className ?? 'p-12 text-center'}>
<AlertCircle className="w-12 h-12 text-destructive mx-auto mb-4" />
<p className="text-muted-foreground font-medium">{message}</p>
<p className="text-sm text-muted-foreground mt-1">请检查网络或后端服务状态后重试</p>
<p className="text-muted-foreground font-medium">{resolvedMessage}</p>
<p className="text-sm text-muted-foreground mt-1">{t('error.hint')}</p>
{onRetry && (
<div className="mt-4">
<Button variant="outline" size="sm" onClick={onRetry}>
<RotateCw className="w-3.5 h-3.5 mr-1" />
重试
{t('error.retry')}
</Button>
</div>
)}
+49 -36
View File
@@ -16,27 +16,30 @@ import {
Menu,
Sun,
Moon,
Monitor,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { useIsMobile } from '@/hooks/use-mobile'
import { menuItems, findRouteMeta } from '@/router/routes'
import { getUser, clearAuth } from '@/lib/auth-storage'
import { isThemeAuto, setThemeAuto } from '@/lib/theme-schedule'
import { useScheduledTheme } from '@/hooks/useScheduledTheme'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
// 侧栏折叠状态持久化 key(命名空间见开发标准 08 章)
const LAYOUT_COLLAPSED_KEY = 'opsxloki.layout.collapsed'
const LAYOUT_COLLAPSED_KEY = 'lokistack.layout.collapsed'
// 侧栏导航 — 桌面 aside 与移动端抽屉共用;菜单数据来自路由配置单源派生
function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?: () => void }) {
const location = useLocation()
const { t } = useTranslation()
const isAdmin = getUser()?.role === 'admin'
// adminOnly 项(用户管理)仅管理员可见;后端 AdminAuth 兜底
const visibleItems = menuItems.filter((item) => !item.adminOnly || isAdmin)
return (
<nav className="flex-1 py-4 px-2 space-y-1 overflow-y-auto" aria-label="主导航">
<nav className="flex-1 py-4 px-2 space-y-1 overflow-y-auto" aria-label={t('layout.nav')}>
{visibleItems.map((item) => {
const isActive = location.pathname === item.path
return (
@@ -44,17 +47,17 @@ function SidebarNav({ collapsed, onNavigate }: { collapsed: boolean; onNavigate?
key={item.path}
to={item.path}
onClick={onNavigate}
aria-label={item.label}
aria-label={t(item.label)}
className={cn(
'relative flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
isActive
? 'bg-sidebar-primary/15 text-sidebar-primary font-semibold before:absolute before:left-0 before:h-5 before:w-0.5 before:rounded-full before:bg-sidebar-primary'
: 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
? 'bg-sidebar-accent text-sidebar-accent-foreground font-semibold before:absolute before:left-0 before:h-5 before:w-0.5 before:rounded-full before:bg-sidebar-accent-foreground/60'
: 'text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground'
)}
title={!collapsed ? item.label : undefined}
title={!collapsed ? t(item.label) : undefined}
>
{item.icon && <item.icon className="w-5 h-5 flex-shrink-0" />}
{!collapsed && <span className="text-sm font-medium">{item.label}</span>}
{!collapsed && <span className="text-sm font-medium">{t(item.label)}</span>}
{!collapsed && isActive && (
<ChevronRight className="w-4 h-4 ml-auto flex-shrink-0" />
)}
@@ -78,40 +81,44 @@ function SidebarLogo({ collapsed }: { collapsed: boolean }) {
<div className="w-8 h-8 rounded-lg border border-sidebar-primary/30 bg-sidebar-primary/15 flex items-center justify-center">
<Activity className="w-5 h-5 text-sidebar-primary" />
</div>
<span className="font-bold text-lg tracking-tight">lokistack</span>
<span className="font-bold text-xl tracking-tight">LokiStack</span>
</div>
)}
</div>
)
}
// 主题三态切换:浅色 → 深色 → 跟随系统(key: opsxloki.theme.mode)
// 主题二态切换:浅色 ↔ 深色(key: lokistack.theme.mode;resolvedTheme 兼容"跟随系统/定时"偏好)
// 定时模式开启时点击 = 关闭定时并切到相反主题(用户手动覆盖调度)。
function ThemeToggle() {
const { theme, setTheme } = useTheme()
const next = theme === 'light' ? 'dark' : theme === 'dark' ? 'system' : 'light'
const label = next === 'light' ? '切换为浅色' : next === 'dark' ? '切换为深色' : '切换为跟随系统'
const { resolvedTheme, setTheme } = useTheme()
const { t } = useTranslation()
const isDark = resolvedTheme === 'dark'
const label = isDark ? t('layout.toLight') : t('layout.toDark')
return (
<button
onClick={() => setTheme(next)}
onClick={() => {
if (isThemeAuto()) {
setThemeAuto(false)
setTheme(isDark ? 'light' : 'dark')
return
}
setTheme(isDark ? 'light' : 'dark')
}}
aria-label={label}
title={label}
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800"
className="p-2 rounded-lg text-muted-foreground hover:bg-muted"
>
{theme === 'light' ? (
<Sun className="w-4 h-4" />
) : theme === 'dark' ? (
<Moon className="w-4 h-4" />
) : (
<Monitor className="w-4 h-4" />
)}
{isDark ? <Moon className="w-4 h-4" /> : <Sun className="w-4 h-4" />}
</button>
)
}
// 页面级加载骨架(挂在 main 内,切页时布局不闪)
function PageSkeleton() {
const { t } = useTranslation()
return (
<div className="space-y-6" aria-busy="true" aria-label="页面加载中">
<div className="space-y-6" aria-busy="true" aria-label={t('layout.loading')}>
<div className="space-y-2">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-4 w-72" />
@@ -137,10 +144,16 @@ const Layout = memo(function Layout({ children }: { children: React.ReactNode })
const [mobileOpen, setMobileOpen] = useState(false)
const location = useLocation()
const navigate = useNavigate()
const { t } = useTranslation()
const user = getUser()
// 主题定时调度(「定时」模式开启时每分钟按时区解析浅/深色)
useScheduledTheme()
// 顶栏标题来自路由 meta
const pageTitle = findRouteMeta(location.pathname)?.title || 'lokistack'
// meta.title 为 i18n key(common 命名空间),展示前翻译
const pageTitleKey = findRouteMeta(location.pathname)?.title
const pageTitle = pageTitleKey ? t(pageTitleKey) : 'LokiStack'
// 折叠状态持久化(仅桌面端语义)
useEffect(() => {
@@ -171,8 +184,8 @@ const Layout = memo(function Layout({ children }: { children: React.ReactNode })
<div className="p-2 border-t border-sidebar-border flex-shrink-0">
<button
onClick={toggleSidebar}
aria-label={collapsed ? '展开菜单' : '收起菜单'}
title={collapsed ? '展开菜单' : '收起菜单'}
aria-label={collapsed ? t('layout.expand') : t('layout.collapse')}
title={collapsed ? t('layout.expand') : t('layout.collapse')}
className="flex items-center justify-center w-full py-2.5 rounded-lg text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
>
{collapsed ? (
@@ -190,7 +203,7 @@ const Layout = memo(function Layout({ children }: { children: React.ReactNode })
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" className="bg-sidebar text-sidebar-accent-foreground border-none p-0 w-72">
<SheetHeader className="sr-only">
<SheetTitle className="text-white">lokistack 导航</SheetTitle>
<SheetTitle className="text-sidebar-accent-foreground">{t('layout.sheetTitle')}</SheetTitle>
</SheetHeader>
<div className="flex flex-col h-full">
<SidebarLogo collapsed={false} />
@@ -203,40 +216,40 @@ const Layout = memo(function Layout({ children }: { children: React.ReactNode })
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
{/* Top Bar */}
<header className="h-16 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between px-4 md:px-6 flex-shrink-0 gap-3">
<header className="h-16 bg-card border-b border-border flex items-center justify-between px-4 md:px-6 flex-shrink-0 gap-3">
<div className="flex items-center gap-3 min-w-0">
{isMobile && (
<button
onClick={() => setMobileOpen(true)}
aria-label="打开导航菜单"
className="p-2 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800"
aria-label={t('layout.openNav')}
className="p-2 rounded-lg text-muted-foreground hover:bg-muted"
>
<Menu className="w-5 h-5" />
</button>
)}
<h1 className="text-lg md:text-xl font-semibold text-slate-800 dark:text-white truncate">
<h1 className="text-lg md:text-xl font-semibold text-foreground truncate">
{pageTitle}
</h1>
</div>
<div className="flex items-center gap-4 flex-shrink-0">
<ThemeToggle />
{user && (
<div className="flex items-center gap-3 text-sm text-slate-600 dark:text-slate-300">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4" />
<span>{user.username}</span>
<span className="px-2 py-0.5 rounded-full bg-success/10 text-success text-xs font-medium">
{user.role === 'admin' ? '管理员' : user.role}
{user.role === 'admin' ? t('layout.admin') : user.role}
</span>
</div>
<button
onClick={handleLogout}
title="退出登录"
aria-label="退出登录"
title={t('layout.logout')}
aria-label={t('layout.logout')}
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-all"
>
<LogOut className="w-4 h-4" />
<span className="hidden md:inline">退出</span>
<span className="hidden md:inline">{t('layout.logoutShort')}</span>
</button>
</div>
)}
+4 -2
View File
@@ -1,3 +1,4 @@
import i18n from '@/i18n'
import { memo } from 'react'
import { Activity } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -23,12 +24,13 @@ export const LoadingSpinner = memo(function LoadingSpinner({
className,
text,
}: LoadingSpinnerProps) {
const resolvedText = text ?? i18n.t('spinner.default')
return (
<div className={cn('flex flex-col items-center justify-center gap-3', className)}>
<Activity
className={cn('text-primary animate-spin', SIZE_MAP[size])}
/>
{text && <p className="text-sm text-muted-foreground">{text}</p>}
{resolvedText && <p className="text-sm text-muted-foreground">{resolvedText}</p>}
</div>
)
})
@@ -37,7 +39,7 @@ export const LoadingSpinner = memo(function LoadingSpinner({
* 全屏居中加载状态
*/
export const FullScreenLoader = memo(function FullScreenLoader({
text = '加载中...',
text,
}: Pick<LoadingSpinnerProps, 'text'>) {
return (
<div className="flex items-center justify-center h-64">
+3 -1
View File
@@ -1,4 +1,5 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useLocation } from 'react-router'
import { cn } from '@/lib/utils'
import { PageHeader } from '@/components/PageHeader'
@@ -26,7 +27,8 @@ export const PageContainer = memo(function PageContainer({
children,
}: PageContainerProps) {
const { pathname } = useLocation()
const routeTitle = findRouteMeta(pathname)?.title
const { t } = useTranslation()
const routeTitle = findRouteMeta(pathname)?.title ? t(findRouteMeta(pathname)!.title) : undefined
const repeatedTitle = Boolean(title && routeTitle && title.replace(/\s/g, '') === routeTitle.replace(/\s/g, ''))
const showHeader = Boolean(title && (!repeatedTitle || description || action))
+4 -2
View File
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next'
import { memo, useMemo } from 'react'
import {
Pagination,
@@ -42,13 +43,14 @@ export const PaginationBar = memo(function PaginationBar({
onPageChange,
className,
}: PaginationBarProps) {
const { t } = useTranslation()
const totalPages = Math.max(1, Math.ceil(total / pageSize))
const pages = useMemo(() => pageNumbers(page, totalPages), [page, totalPages])
if (total <= pageSize) {
return (
<div className={className}>
<p className="text-xs text-muted-foreground text-center">共 {total} 条</p>
<p className="text-xs text-muted-foreground text-center">{t('pagination.total', { count: total })}</p>
</div>
)
}
@@ -102,7 +104,7 @@ export const PaginationBar = memo(function PaginationBar({
</PaginationContent>
</Pagination>
<p className="text-xs text-muted-foreground text-center mt-1">
共 {total} 条 · 第 {page}/{totalPages} 页
{t('pagination.summary', { count: total, page, pages: totalPages })}
</p>
</div>
)
+2 -1
View File
@@ -1,5 +1,6 @@
import { memo, useState } from 'react'
import { Eye, EyeOff } from 'lucide-react'
import i18n from '@/i18n'
import { Input } from '@/components/ui/input'
interface PasswordInputProps extends Omit<React.ComponentProps<typeof Input>, 'type'> {
@@ -24,7 +25,7 @@ export const PasswordInput = memo(function PasswordInput({
<button
type="button"
tabIndex={-1}
aria-label={visible ? '隐藏密码' : '显示密码'}
aria-label={visible ? i18n.t('password.hide') : i18n.t('password.show')}
onClick={() => setVisible((prev) => !prev)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-slate-600"
>
+13 -8
View File
@@ -1,7 +1,7 @@
import { memo, useMemo } from 'react'
import { Clock } from 'lucide-react'
import { cn } from '@/lib/utils'
import { statusLabel } from '@/lib/utils'
import { useTranslation } from 'react-i18next'
import { STATUS_CONFIG } from './status-config'
export type StatusSize = 'sm' | 'md'
@@ -13,16 +13,19 @@ interface StatusBadgeProps {
className?: string
}
function getStatusConfig(status: string): { icon: typeof Clock; color: string; bg: string; label: string } {
const config = STATUS_CONFIG[status] || {
function getStatusConfig(status: string): { icon: typeof Clock; color: string; bg: string } {
return STATUS_CONFIG[status] || {
icon: Clock,
color: 'text-muted-foreground',
bg: 'bg-muted text-muted-foreground',
}
return {
...config,
label: statusLabel(status),
}
}
// 状态标签走 common.status.*;未收录的状态原样展示(数据层保留英文状态值)
function statusLabelOf(t: (k: string) => string, status: string): string {
const key = `status.${status}`
const label = t(key)
return label === key ? status : label
}
const SIZE_MAP: Record<StatusSize, { icon: string; badge: string }> = {
@@ -41,7 +44,9 @@ export const StatusBadge = memo(function StatusBadge({
size = 'sm',
className,
}: StatusBadgeProps) {
const { t } = useTranslation()
const config = useMemo(() => getStatusConfig(status), [status])
const label = statusLabelOf(t, status)
const sizeClasses = SIZE_MAP[size]
const Icon = config.icon
@@ -61,7 +66,7 @@ export const StatusBadge = memo(function StatusBadge({
status === 'deploying' && 'animate-pulse'
)}
/>
{showLabel && <span>{config.label}</span>}
{showLabel && <span>{label}</span>}
</span>
)
})
+1
View File
@@ -4,6 +4,7 @@ export { default as PageContainer } from './PageContainer';
export { default as PageHeader } from './PageHeader';
export { default as InfoRow } from './InfoRow';
export { ResourceCard } from './ResourceCard';
export { CardActionButton } from './CardActionButton';
export { default as ConfirmDialog } from './ConfirmDialog';
export { default as ErrorState } from './ErrorState';
export { default as EmptyState } from './EmptyState';
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+2 -1
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import i18n from '@/i18n'
import type { PageData } from '@/types'
import type { ListParams } from '@/api'
@@ -71,7 +72,7 @@ export function useListQuery<T>(
setTotal(res.total ?? 0)
} catch (err) {
if (currentVersion !== versionRef.current) return
setError(err instanceof Error ? err.message : '加载失败')
setError(err instanceof Error ? err.message : i18n.t('error.title'))
} finally {
if (currentVersion === versionRef.current) {
setLoading(false)
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react'
import { useTheme } from 'next-themes'
import {
isThemeAuto,
getThemeTimezone,
onThemeScheduleChange,
resolveScheduledTheme,
} from '@/lib/theme-schedule'
/**
* 主题定时调度 — 「定时」模式开启时,每分钟按时区解析浅/深色并写入主题偏好;
* 关闭时停止调度,当前主题保持最后一次设置。挂在 Layout 挂载一次即可。
*/
export function useScheduledTheme() {
const { setTheme } = useTheme()
const [auto, setAutoState] = useState(isThemeAuto)
const [timezone, setTimezoneState] = useState(getThemeTimezone)
useEffect(() => {
const sync = () => {
setAutoState(isThemeAuto())
setTimezoneState(getThemeTimezone())
}
// 同窗口自定义事件 + 跨窗口 storage 事件
const off = onThemeScheduleChange(sync)
const onStorage = (e: StorageEvent) => {
if (e.key === null || e.key === 'lokistack.theme.auto' || e.key === 'lokistack.theme.timezone') sync()
}
window.addEventListener('storage', onStorage)
return () => {
off()
window.removeEventListener('storage', onStorage)
}
}, [])
useEffect(() => {
if (!auto) return
const apply = () => setTheme(resolveScheduledTheme(timezone))
apply()
const timer = window.setInterval(apply, 60_000)
return () => window.clearInterval(timer)
}, [auto, timezone, setTheme])
return { auto, timezone }
}
+46
View File
@@ -0,0 +1,46 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { getStoredLanguage, storeLanguage, type AppLanguage } from '@/i18n'
const OPTIONS: Array<{ value: AppLanguage; labelKey: 'lang.zh' | 'lang.en' }> = [
{ value: 'zh', labelKey: 'lang.zh' },
{ value: 'en', labelKey: 'lang.en' },
]
/** 语言设置 — 紧凑分段选择器(中文/English),即时生效并持久化(key: lokistack.i18n.lang) */
export const LanguageSection = memo(function LanguageSection() {
const { t, i18n } = useTranslation()
const current = getStoredLanguage()
return (
<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('lang.section')}</span>
<div role="group" aria-label={t('lang.section')} className="inline-flex items-center rounded-lg border border-border bg-card p-0.5">
{OPTIONS.map((opt) => {
const active = current === opt.value
return (
<button
key={opt.value}
type="button"
aria-pressed={active}
onClick={() => {
storeLanguage(opt.value)
void i18n.changeLanguage(opt.value)
}}
className={cn(
'inline-flex items-center 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'
)}
>
{t(opt.labelKey)}
</button>
)
})}
</div>
</div>
)
})
export default LanguageSection
+28
View File
@@ -0,0 +1,28 @@
// 页面域命名空间注册 — dashboard / users / alerts 三域词典在此挂载。
// 约束:index.ts 只注册 common(由他人维护,不做改动);域词典通过本模块以
// addResourceBundle 挂载,由各域页面入口副作用 import 引入(页面为懒加载 chunk,
// chunk 求值先于组件渲染,因此 useTranslation('<domain>') 时资源必然已就绪)。
// 新增页面域时:在 locales/<lang>/ 下新增词典文件,并在下方 DOMAIN_BUNDLES 补一条。
import i18n from './index'
import zhDashboard from './locales/zh/dashboard'
import enDashboard from './locales/en/dashboard'
import zhUsers from './locales/zh/users'
import enUsers from './locales/en/users'
import zhAlerts from './locales/zh/alerts'
import enAlerts from './locales/en/alerts'
const DOMAIN_BUNDLES: ReadonlyArray<[string, string, object]> = [
['zh', 'dashboard', zhDashboard],
['en', 'dashboard', enDashboard],
['zh', 'users', zhUsers],
['en', 'users', enUsers],
['zh', 'alerts', zhAlerts],
['en', 'alerts', enAlerts],
]
for (const [lng, ns, resources] of DOMAIN_BUNDLES) {
if (!i18n.hasResourceBundle(lng, ns)) {
i18n.addResourceBundle(lng, ns, resources)
}
}
+87
View File
@@ -0,0 +1,87 @@
// i18n 入口 — 语言持久化(lokistack.i18n.lang)+ 命名空间注册。
// 约定:全局框架用 common 命名空间;每个页面域一个命名空间(见 locales/<lang>/<domain>.ts),
// 页面组件用 useTranslation('<domain>') 取文案,禁止散落硬编码。
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import zhCommon from './locales/zh/common'
import enCommon from './locales/en/common'
import zhClusters from './locales/zh/clusters'
import enClusters from './locales/en/clusters'
import zhHosts from './locales/zh/hosts'
import enHosts from './locales/en/hosts'
import zhStorage from './locales/zh/storage'
import enStorage from './locales/en/storage'
import zhDeployments from './locales/zh/deployments'
import enDeployments from './locales/en/deployments'
import zhCollectors from './locales/zh/collectors'
import enCollectors from './locales/en/collectors'
import zhWizard from './locales/zh/wizard'
import enWizard from './locales/en/wizard'
import zhLogs from './locales/zh/logs'
import enLogs from './locales/en/logs'
import zhDashboard from './locales/zh/dashboard'
import enDashboard from './locales/en/dashboard'
import zhUsers from './locales/zh/users'
import enUsers from './locales/en/users'
import zhAlerts from './locales/zh/alerts'
import enAlerts from './locales/en/alerts'
import zhSettings from './locales/zh/settings'
import enSettings from './locales/en/settings'
const LANG_KEY = 'lokistack.i18n.lang'
export type AppLanguage = 'zh' | 'en'
export function getStoredLanguage(): AppLanguage {
const stored = localStorage.getItem(LANG_KEY)
return stored === 'en' ? 'en' : 'zh'
}
export function storeLanguage(lang: AppLanguage): void {
localStorage.setItem(LANG_KEY, lang)
}
function detectLanguage(): AppLanguage {
const stored = localStorage.getItem(LANG_KEY)
if (stored === 'en' || stored === 'zh') return stored
return navigator.language?.toLowerCase().startsWith('zh') ? 'zh' : 'en'
}
void i18n.use(initReactI18next).init({
resources: {
zh: {
common: zhCommon,
clusters: zhClusters,
hosts: zhHosts,
storage: zhStorage,
deployments: zhDeployments,
collectors: zhCollectors,
wizard: zhWizard,
logs: zhLogs,
dashboard: zhDashboard,
users: zhUsers,
alerts: zhAlerts,
settings: zhSettings,
},
en: {
common: enCommon,
clusters: enClusters,
hosts: enHosts,
storage: enStorage,
deployments: enDeployments,
collectors: enCollectors,
wizard: enWizard,
logs: enLogs,
dashboard: enDashboard,
users: enUsers,
alerts: enAlerts,
settings: enSettings,
},
},
lng: detectLanguage(),
fallbackLng: 'zh',
defaultNS: 'common',
interpolation: { escapeValue: false },
})
export default i18n
+82
View File
@@ -0,0 +1,82 @@
// alerts namespace — Log alerts domain (header / rule form & templates / delivery history / Feishu (Lark) notify hints)
export default {
title: 'Alerts',
subtitle:
'Monitor logs with LogQL count rules; push a Feishu (Lark) card when the threshold is exceeded (1-minute cycle)',
checkNow: 'Check Now',
checking: 'Checking...',
create: 'New Rule',
emptyTitle: 'No alert rules yet',
emptyDesc:
'e.g. {namespace=~".+"} |= "FATAL" — any match within 15 minutes pushes a Feishu (Lark) card',
sourceFallback: 'Data source {{id}}',
noChannels: 'No channels linked',
channelSep: ', ',
ruleMeta:
'Fires on more than {{threshold}} matches within {{window}} min · Cooldown {{cooldown}} min',
lastFired: 'Last fired {{time}}',
editAria: 'Edit {{name}}',
deleteAria: 'Delete {{name}}',
historyTitle: 'Delivery history (last 50)',
notifyHintPre: 'Notifications not configured? Go to',
notifyHintPost: 'to configure a Feishu (Lark) Webhook',
editTitle: 'Edit rule · {{name}}',
createTitle: 'New Alert Rule',
templates: 'Quick templates (click to prefill, then adjust as needed)',
saving: 'Saving...',
save: 'Save Changes',
submitCreate: 'Create Rule',
loadFailed: 'Failed to load alert data',
saveFailed: 'Failed to save',
deleteFailed: 'Failed to delete',
checkDone: 'Check completed. See delivery history for results.',
checkFailed: 'Check failed',
toggleFailed: 'Failed to toggle',
confirmDeleteTitle: 'Confirm alert rule deletion',
delivery: {
sent: 'Sent',
cooldown: 'Skipped (cooldown)',
failed: 'Failed',
hits_one: '{{count}} match',
hits_other: '{{count}} matches',
},
tpl: {
nsError: {
label: 'Namespace errors',
desc: 'Error logs in a given namespace (default astralx)',
},
allNsError: {
label: 'All-NS errors > 1000',
desc: 'Total error logs across all namespaces exceed 1000',
},
ingress5xx: {
label: 'Ingress 5xx',
desc: 'ingress-nginx 5xx (requires JSON access logs; the platform can normalize them in one click)',
},
ingress4xx: {
label: 'Ingress 4xx',
desc: 'ingress-nginx 4xx (requires JSON access logs)',
},
fatal: {
label: 'FATAL',
desc: 'Alert on any FATAL log across all namespaces',
},
},
form: {
name: 'Rule name *',
namePlaceholder: 'App fatal errors',
source: 'Data source *',
sourcePlaceholder: 'Select a data source',
query: 'LogQL query *',
queryHint:
'Common: {app="xxx"} |= "FATAL" / {namespace=~".+"} |~ "(?i)error" / {app="x"} |= "OutOfMemoryError"',
channels: 'Channels (multi-select to fan out to multiple alert groups)',
noChannels: 'Create channels first under "Settings → Alert Notifications"',
channelDisabled: '(disabled)',
window: 'Window (min)',
threshold: 'Threshold (matches)',
thresholdHint: '0 = alert on any match',
cooldown: 'Cooldown (min)',
enabled: 'Enable rule',
},
}
+89
View File
@@ -0,0 +1,89 @@
// clusters namespace — cluster management page (English)
// Rich-text values (<code>/<strong>) are rendered via react-i18next <Trans>; keep the tags.
export default {
title: 'Clusters',
description: 'Manage Kubernetes clusters for Loki deployment',
actions: {
add: 'Add Cluster',
detail: 'Details',
editAria: 'Edit cluster {{name}}',
validateAria: 'Validate cluster {{name}}',
deleteAria: 'Delete cluster {{name}}',
},
fields: {
accessType: 'Access Method',
apiServer: 'API Server',
version: 'Version',
nodes: 'Nodes',
unknown: 'Unknown',
},
empty: {
title: 'No clusters yet',
description: 'Add your first Kubernetes cluster to get started',
},
delete: {
warning:
'Delete "{{name}}"? Warning: {{dep}} deployments and {{col}} collector policies still reference this cluster; they will stop working once it is deleted.',
},
toast: {
deleteFailed: 'Failed to delete cluster',
validateFailed: 'Validation failed',
createFailed: 'Failed to create cluster',
},
form: {
addTitle: 'Add Kubernetes Cluster',
editTitle: 'Edit Cluster',
editTitleWithName: 'Edit Cluster · {{name}}',
nameLabel: 'Cluster Name *',
descriptionLabel: 'Description',
descriptionPlaceholder: 'Production logging cluster',
accessTypeLabel: 'Access Method',
kubeconfigLabel: 'Kubeconfig *',
kubeconfigLabelEdit: 'Kubeconfig (leave blank to keep unchanged)',
kubeconfigPlaceholder: 'Paste kubeconfig YAML (API Server is detected automatically)...',
apiServerDetected: 'Detected API Server: {{server}}',
apiServerLabel: 'API Server *',
tokenLabel: 'Token *',
tokenLabelEdit: 'Token (leave blank to keep unchanged)',
tokenPlaceholder: 'Paste Service Account Token...',
tokenInsecureHint:
'Uses insecure mode, for clusters on private networks or with self-signed certificates',
rbacSummary: 'Least-privilege recommendations (click to expand)',
rbacScope:
'Grant permissions only in the <code>log</code> namespace so a leaked Token cannot affect other namespaces; <strong>do not use cluster-admin</strong>.',
rbacScriptIntro:
'Creates the <code>log</code> namespace, ServiceAccount, namespace-scoped Role and a long-lived Token in one shot (run it once in the cluster, then paste the Token into the field below):',
rbacScriptComment1: '# 1) Create in one shot: namespace + SA + namespace-scoped Role + long-lived Token',
rbacScriptComment2: '# 2) Extract the long-lived Token (it does not expire) and paste it into the Token field',
rbacNamespaceNote:
'The Namespace above is created by an administrator; the platform Token itself has no cluster-wide permissions. Only grant the <code>namespaces: create/get</code> ClusterRole if the platform really must create its own namespaces — note that namespace creation cannot be restricted by name, so pre-creating <code>log</code> is safer.',
rbacTokenNote:
'The Token is long-lived but bound to a Role in the <code>log</code> namespace only (it can only touch Pods and workloads there), so a leak is limited to <code>log</code>. The server converts it to a kubeconfig and stores it encrypted with <code>ENCRYPTION_KEY</code>; the database keeps only <code>ENC:</code> ciphertext and the API never returns the plaintext. To rotate it, delete and recreate the Secret — the old Token stops working immediately.',
contextLabel: 'Context (optional)',
contextPlaceholder: 'Leave blank to use the default context',
submitAdd: 'Add Cluster',
submitSave: 'Save Changes',
submittingAdd: 'Adding...',
submittingSave: 'Saving...',
validation: {
nameRequired: 'Cluster name is required',
kubeconfigRequired: 'Kubeconfig content is required',
apiServerRequired: 'API Server address is required',
tokenRequired: 'Token is required',
},
},
detail: {
loadError: 'Failed to load cluster details',
version: 'Version',
unknown: 'Unknown',
currentContext: 'Current Context',
defaultContext: 'Default',
nodesCount: 'Nodes ({{count}})',
nodeReady: 'Ready',
nodeNotReady: 'Not Ready',
nodeIp: 'IP: {{value}}',
nodeOs: 'OS: {{os}}/{{arch}}',
nodeVersion: 'Version: {{version}}',
namespaces: 'Namespaces',
},
}
+131
View File
@@ -0,0 +1,131 @@
// collectors namespace — "Log Collectors" page domain, English copy.
// Registration: the i18n entry (src/i18n/index.ts) is framework-owned; each page-domain
// dictionary registers itself via addResourceBundle when side-effect imported by its
// pages (zh/en each register their own bundle), coexisting with the static common NS.
const collectors = {
page: {
title: 'Log Collectors',
description: 'Configure log collection policies for hosts and clusters; logs are pushed to the linked Loki',
addAction: 'Add Collection Policy',
},
empty: {
title: 'No collection policies yet',
description: 'Create a log collector and link it to a deployed Loki',
},
typeLabel: {
hostPromtail: 'Host Promtail',
k8sDaemonset: 'Promtail DaemonSet',
k8sFluentbitSidecar: 'Fluent Bit Sidecar',
},
field: {
loki: 'Linked Loki',
cluster: 'Cluster',
host: 'Host',
namespace: 'Namespace',
logPath: 'Log Path',
target: 'Target',
unknown: 'Unknown',
},
action: {
install: 'Deploy',
uninstall: 'Uninstall',
editAria: 'Edit collection policy {{name}}',
deleteAria: 'Delete collector {{name}}',
},
toast: {
loadOptionsFailed: 'Failed to load Loki deployments, clusters, or hosts',
installFailed: 'Failed to deploy collector',
uninstallFailed: 'Failed to uninstall collector',
deleteFailed: 'Failed to delete collector',
createFailed: 'Failed to create collector',
saveFailed: 'Failed to save collection policy',
},
deleteConfirm: 'Delete "{{name}}"? The collector will be uninstalled automatically. This cannot be undone.',
form: {
title: 'Add Collection Policy',
name: 'Name',
type: 'Collector Type',
typeOption: {
hostPromtail: 'Host Promtail (SSH)',
k8sDaemonset: 'Kubernetes Promtail DaemonSet',
k8sFluentbitSidecar: 'Kubernetes Fluent Bit Sidecar',
},
lokiDeployment: 'Linked Loki Deployment',
lokiPlaceholder: 'Select a deployed Loki (push target)',
statusParen: ' ({{status}})',
hostParen: ' ({{ip}})',
host: 'Target Host',
hostPlaceholder: 'Select a host',
cluster: 'Target Cluster',
clusterPlaceholder: 'Select a cluster',
namespace: 'Namespace',
nsPickCluster: 'Select a cluster first',
nsLoading: 'Loading namespaces…',
nsPick: 'Select a namespace',
nsEmpty: 'No readable namespaces',
nsLoadFailed: 'Failed to read namespaces',
pickFromCluster: 'Pick from cluster',
manualInput: 'Enter manually',
targetDeployment: 'Target Deployment',
wlPickNamespace: 'Select a namespace first',
wlLoading: 'Loading Deployments…',
wlPick: 'Select a Deployment',
wlEmpty: 'No Deployments in this namespace',
wlLoadFailed: 'Failed to read Deployments',
wlReady: ' ({{ready}}/{{total}} ready)',
logPath: 'Log Path',
logPathHostHint: 'Supports glob wildcards (e.g. /var/log/*.log /var/log/syslog); separate multiple paths with commas',
logPathSidecarHint:
'Enter the file path your app writes to, e.g. /var/log/app/*.log. Existing log volumes are shared automatically; without one, a shared volume is created only in the dedicated logs directory. Regular paths such as /tmp/test.log are not mounted automatically.',
labels: 'Custom Labels (JSON, optional)',
labelsHint: 'When valid JSON, attached as labels to every log line for filtering in Loki/Grafana; leave empty to skip',
submit: 'Create & Deploy',
submitting: 'Creating...',
validation: {
nameRequired: 'Enter a collector name',
lokiRequired: 'Select the Loki deployment to link',
hostRequired: 'Select the target host',
clusterRequired: 'Select the target cluster',
namespaceRequired: 'Select a namespace',
targetDeploymentRequired: 'Enter the target Deployment name',
},
},
edit: {
title: 'Edit Collection Policy',
namespaces: 'Namespaces to Collect',
allNamespaces: 'All namespaces cluster-wide',
selectedCount: '{{count}} selected',
allCheckbox: 'All namespaces (collect cluster-wide)',
nsSearch: 'Search namespaces…',
loading: 'Loading…',
nsListEmpty: 'Namespace list unavailable',
nsNoMatch: 'No matches',
nsLoadFailed: 'Failed to load namespaces',
nsHint:
'Check "All namespaces" to collect cluster-wide; otherwise only the selected namespaces are collected. Generates the <code>kubernetes_sd_configs.namespaces</code> allowlist for Promtail.',
appLabelSource: 'app label source',
appSourcePlaceholder: 'Empty = default (controller-name fallback chain)',
appSourceHint:
'By default falls back through <code>app.kubernetes.io/name → app → controller name → pod name</code>. If your cluster has its own convention label (e.g. <code>k8s.kuboard.cn/name</code>), enter it here and it takes the highest priority; the rest still serve as fallback.',
appSourcePreset: {
default: 'Default: app.kubernetes.io/name → app → controller name → pod name',
controllerName: 'controller_name (ReplicaSet name of the Pod, hash stripped = Deployment name)',
k8sAppName: 'app.kubernetes.io/name (Kubernetes standard label)',
app: 'app (common shorthand)',
kuboard: 'k8s.kuboard.cn/name (Kuboard injected)',
},
appInclude: 'Only collect these apps (optional)',
appIncludePlaceholder: 'Empty = no limit; comma-separated, e.g. cex-spot-api, cex-asset-api',
appIncludeHint:
'When set, Promtail keeps only logs whose app label matches these values (<code>action: keep</code>) — the most direct way to control log volume. Values must match the app resolved by the "app label source" above.',
logPathHint: 'The Sidecar requires the app container\'s shared log volume or the dedicated logs directory. Click "Deploy" after saving to apply the new configuration.',
save: 'Save',
saving: 'Saving…',
validation: {
nameRequired: 'Enter a policy name',
},
},
}
export default collectors
+99
View File
@@ -0,0 +1,99 @@
// common namespace — app shell (menu / top bar / login / shared components / status)
export default {
appName: 'LokiStack',
menu: {
dashboard: 'Dashboard',
wizard: 'Deploy Wizard',
clusters: 'Clusters',
hosts: 'Hosts',
storage: 'Storage',
deployments: 'Deploy Loki',
collectors: 'Log Collectors',
logs: 'Log Query',
alerts: 'Alerts',
settings: 'Settings',
users: 'Users',
login: 'Sign In',
notFound: 'Page Not Found',
},
layout: {
nav: 'Main navigation',
sheetTitle: 'LokiStack Navigation',
collapse: 'Collapse menu',
expand: 'Expand menu',
openNav: 'Open navigation menu',
logout: 'Sign out',
logoutShort: 'Sign out',
toLight: 'Switch to light theme',
toDark: 'Switch to dark theme',
admin: 'Admin',
loading: 'Loading page',
},
login: {
subtitle: 'Loki Log Management Platform',
heading: 'Sign In',
username: 'Username',
usernamePlaceholder: 'Enter username',
password: 'Password',
passwordPlaceholder: 'Enter password',
remember: 'Remember username',
submit: 'Sign In',
submitting: 'Signing in...',
errCredential: 'Invalid username or password',
errConn: 'Cannot reach the backend. Make sure it is running on port 8080.',
errFallback: 'Sign-in failed',
backendDown: 'Backend is not running',
backendDownHint: 'Cannot reach the API service. Start the backend first:',
},
dialog: {
confirmTitle: 'Confirm Deletion',
confirmText: 'Delete',
cancel: 'Cancel',
processing: 'Processing...',
confirmIrreversible: 'Are you sure? This cannot be undone.',
confirmDefault: 'Delete "{{name}}"? This cannot be undone.',
},
error: {
title: 'Failed to load',
hint: 'Check the network or backend service, then retry',
retry: 'Retry',
},
pagination: {
total: '{{count}} items',
summary: '{{count}} items · Page {{page}} of {{pages}}',
},
status: {
active: 'Running',
running: 'Running',
inactive: 'Stopped',
stopped: 'Stopped',
pending: 'Pending',
deploying: 'Deploying',
failed: 'Failed',
error: 'Error',
},
notFound: {
heading: 'Page Not Found',
hint: 'The page does not exist or has been removed',
backHome: 'Back to Home',
},
lang: {
section: 'Language',
zh: '中文',
en: 'English',
},
boundary: {
title: 'Rendering Error',
hint: 'Something went wrong. Try refreshing; if it persists, report the details below.',
unknown: 'Unknown error',
reload: 'Reload Page',
reset: 'Reset Component',
},
spinner: {
default: 'Loading...',
},
password: {
hide: 'Hide password',
show: 'Show password',
},
}
+28
View File
@@ -0,0 +1,28 @@
// dashboard namespace — Dashboard domain (stat cards / quick start / recent deployments / resource lists)
export default {
stat: {
totalDeployments: 'Deployments',
runningCount: '{{count}} running',
clusters: 'Clusters',
connected: 'Connected',
hosts: 'Hosts',
sshReady: 'SSH ready',
status: 'Status',
},
quickStart: {
title: 'Start collecting logs in three steps',
subtitle: 'Target & environment → Storage → Deploy Loki and Grafana, then add a collection strategy',
cta: 'Open Deploy Wizard',
},
recent: {
title: 'Recent Deployments',
viewAll: 'View all',
emptyTitle: 'No deployments yet',
emptyDesc: 'Create your first Loki deployment',
},
list: {
manage: 'Manage',
empty: 'No {{title}} yet',
},
loadFailed: 'Failed to load dashboard data',
}
+124
View File
@@ -0,0 +1,124 @@
// deployments namespace — Loki deployment page copy (page / card / form / retention / logs dialogs)
export default {
page: {
title: 'Deploy Loki',
description: 'Deploy and manage Grafana Loki log aggregation services',
create: 'New Deployment',
emptyTitle: 'No deployments',
emptyDescription: 'Create your first Loki deployment',
// delete confirmation: host-docker removes the container too; other modes only remove the record
deleteDockerDesc:
'Delete "{{name}}"? The Loki container on the host will be removed as well. This cannot be undone.',
deleteRecordDesc:
'Delete "{{name}}"? Deleting the record does not uninstall the Loki resources already deployed on the cluster/host. This cannot be undone.',
errLoadRefs: 'Failed to load cluster/host/storage data',
errDeploy: 'Deployment failed',
errStop: 'Failed to stop',
errRestart: 'Failed to restart',
errDelete: 'Failed to delete the deployment',
},
card: {
mode: 'Mode',
modeK8s: 'Kubernetes',
modeDocker: 'Host Docker',
version: 'Version',
target: 'Target',
targetCluster: 'Cluster: {{name}}',
targetHost: 'Host: {{name}}',
targetUnknown: 'Unknown',
namespace: 'Namespace',
replicas: 'Replicas',
replicasValue: '{{replicas}} · Retain {{days}}d',
retention: 'Retention',
retentionValue: '{{days}}d',
storage: 'Storage',
localStorage: 'Local',
ready: 'Loki ready',
notReady: 'Loki not ready',
runningHint:
'Tip: link Loki under "Log Query → Data Sources" to start querying; for cross-cluster/public access, configure an Ingress domain for Loki first.',
configure: 'Configure',
deploy: 'Deploy',
deploying: 'Deploying...',
stop: 'Stop',
restart: 'Restart',
logs: 'Logs',
deleteAria: 'Delete deployment {{name}}',
},
form: {
title: 'Create Loki Deployment',
name: 'Deployment Name *',
description: 'Description',
descriptionPlaceholder: 'Loki log service for production',
version: 'Version',
mode: 'Deploy Mode',
modeK8s: 'Kubernetes',
modeDocker: 'Host Docker',
cluster: 'Target Cluster *',
clusterPlaceholder: 'Select a cluster',
namespace: 'Namespace *',
host: 'Target Host *',
hostPlaceholder: 'Select a host',
listenIP: 'Listen IP',
listenIPPlaceholder: 'Defaults to the host IP once a host is selected',
listenPort: 'Access Port',
dockerHint: 'Starts a Docker container on the host via SSH; config and data stay on the host.',
storage: 'Storage Config',
storagePlaceholder: 'Select storage (optional)',
localStorage: 'Local storage (default)',
replicas: 'Replicas',
retentionDays: 'Retention (days)',
storageSize: 'Storage Size',
memory: 'Memory',
authEnabled: 'Enable multi-tenant authentication',
create: 'Create Deployment',
creating: 'Creating...',
errCreate: 'Failed to create the deployment',
// zod validation messages (t injected by the schema factory)
nameRequired: 'Enter a deployment name',
versionRequired: 'Select a Loki version',
retentionInvalid: 'Retention days must be an integer no less than 1',
replicasInvalid: 'Replicas must be an integer between 1 and 10',
cpuRequired: 'Enter the CPU quota (e.g. 500m)',
memRequired: 'Enter the memory quota (e.g. 512Mi)',
storageSizeRequired: 'Enter the storage size (e.g. 20Gi)',
clusterRequired: 'Select a target cluster',
hostRequired: 'Select a target host',
portInvalid: 'Port must be between 1 and 65535',
namespaceRequired: 'Enter the namespace',
storageSizeMin: 'Alibaba Cloud ESSD requires at least 20Gi — enter 20Gi or more',
},
retention: {
title: 'Edit Loki Config · {{name}}',
locationLabel: 'Config location: ',
locationHostBinary: '/etc/loki/loki.yaml → limits_config.retention_period',
locationHostDocker: 'Host /etc/lokistack/loki-{{id}}/loki.yaml → limits_config.retention_period',
locationK8s: 'ConfigMap {{namespace}}/loki-config → loki.yaml',
compactorNote: 'Retention is enforced by the Loki compactor; MinIO only stores the log objects.',
liveTitle: 'Full live loki.yaml',
liveHint:
'Read from the deployment target with storage credentials masked; retention days below are edited separately.',
view: 'View Full Config',
refresh: 'Refresh',
loading: 'Loading…',
templateNote: 'Generated by the deployment template',
yamlLabel: 'Retention Config YAML',
yamlHint: 'Edit limits_config.retention_period, e.g. 90d or 2160h. Only this setting is accepted here.',
applyHintRunning:
'Save only updates the deployment record; Save & Apply redeploys Loki and queries may be briefly interrupted.',
applyHintIdle: 'Takes effect on the next deployment.',
cancel: 'Cancel',
saveOnly: 'Save Only',
saveApply: 'Save & Apply',
processing: 'Processing…',
savedApplied: 'Saved {{days}} days — Loki is being redeployed',
yamlRequired: 'Enter the YAML config',
errLoad: 'Failed to read the live config',
errSave: 'Failed to save the Loki config',
},
logs: {
title: 'Deployment Logs - {{name}}',
empty: 'No logs yet...',
errLoad: 'Failed to load deployment logs',
},
}
+81
View File
@@ -0,0 +1,81 @@
// hosts namespace — host management page (English)
export default {
title: 'Hosts',
description: 'Manage SSH hosts for binary-based Loki deployment',
actions: {
add: 'Add Host',
detail: 'Details',
editAria: 'Edit host {{name}}',
validateAria: 'Validate host {{name}}',
deleteAria: 'Delete host {{name}}',
},
fields: {
address: 'Address',
user: 'User',
auth: 'Auth',
authPassword: 'Password',
authKey: 'SSH Key',
os: 'OS',
unknown: 'Unknown',
},
empty: {
title: 'No hosts yet',
description: 'Add your first SSH host to get started',
},
delete: {
warning:
'Delete "{{name}}"? Warning: {{dep}} deployments and {{col}} collector policies still reference this host; they will stop working once it is deleted.',
},
toast: {
deleteFailed: 'Failed to delete host',
validateFailed: 'Validation failed',
createFailed: 'Failed to add host',
},
form: {
addTitle: 'Add SSH Host',
editTitle: 'Edit Host',
editTitleWithName: 'Edit Host · {{name}}',
nameLabel: 'Name *',
hostIpLabel: 'Host IP *',
portLabel: 'Port',
userLabel: 'Username *',
authTypeLabel: 'Authentication',
passwordLabel: 'Password *',
passwordLabelEdit: 'Password (leave blank to keep unchanged)',
passwordPlaceholder: 'SSH login password',
privateKeyLabel: 'Private Key *',
privateKeyLabelEdit: 'Private Key (leave blank to keep unchanged)',
keyPhraseLabel: 'Passphrase (optional)',
keyPhrasePlaceholder: 'Leave blank if no passphrase',
labelsLabel: 'Labels (JSON)',
submitAdd: 'Add Host',
submitSave: 'Save Changes',
submittingAdd: 'Adding...',
submittingSave: 'Saving...',
validation: {
nameRequired: 'Host name is required',
hostIpRequired: 'Host IP is required',
portRequired: 'Port is required',
portNumeric: 'Port must be a number',
portRange: 'Port must be between 1 and 65535',
userRequired: 'Username is required',
passwordRequired: 'SSH password is required',
privateKeyRequired: 'Paste the SSH private key content',
},
},
detail: {
loadError: 'Failed to load host details',
usage: 'Resource Usage',
memory: 'Memory',
disk: 'Disk',
usageDetail: '({{detail}})',
basicInfo: 'Basic Info',
hostname: 'Hostname',
os: 'OS',
arch: 'Architecture',
kernel: 'Kernel',
cpuCores: 'CPU Cores',
uptime: 'Uptime',
unknown: 'Unknown',
},
}
+325
View File
@@ -0,0 +1,325 @@
// logs namespace — Log Query page (/logs) English copy
// Terms left untranslated: LogQL, Live Tail (Tail), Loki, label, stream, Ingress, Pod, Namespace.
export default {
nav: {
aria: 'Log center navigation',
},
tabs: {
query: 'Loki Query',
access: 'Query Access',
sources: 'Data Sources',
descQuery: 'Search by log stream and time range; drag the timeline to narrow the range, and click a log line to view its context.',
descAccess: 'Reuse log scopes with policy groups and attach multiple users at once.',
descSources: 'Configure Loki connections and authentication.',
},
toolbar: {
run: 'Run',
running: 'Running…',
tail: 'Tail',
tailStop: 'Stop Tail',
sourceAria: 'Data source',
sourceEmptyOption: 'No data sources available',
limitLabel: 'Per page',
limitHint: 'Up to 1000 entries per page; paging keeps only the current page. For wide ranges, check the timeline first, then drag to select a peak window.',
rangeInvalid: 'Select a valid start and end time',
queryFailed: 'Query failed',
},
source: {
loadFailed: 'Failed to load data sources',
missingTitle: 'No Loki data source available',
missingBody:
'Link a Loki data source first under "Data Sources": for in-cluster Loki, pick "Cluster Service" (Namespace / Service / port, no Ingress needed); for cross-cluster or public queries, expose Loki via an Ingress domain first, then add it with "Direct URL". Queries are unavailable until a data source is linked.',
goConfigure: 'Go to data sources',
sectionAria: 'Data source management',
heading: 'Loki Data Sources',
hint: 'Tip: when Loki runs in the cluster, add a "Cluster Service" data source directly (no Ingress needed); for cross-cluster / public queries, expose Loki via an Ingress domain first, then link it with "Direct URL". Without a linked data source, the query page cannot be used.',
managedCluster: 'Cluster {{name}}',
managedBadge: 'Platform deployed · managed with the deployment',
add: '+ Add manually',
nameLabel: 'Name',
targetUrl: 'Direct URL',
targetClusterSvc: 'Cluster Service',
addressLabel: 'Loki URL',
clusterLabel: 'Cluster',
clusterPlaceholder: 'Select a cluster…',
namespaceLabel: 'Namespace',
serviceLabel: 'Service',
portLabel: 'Port',
authLabel: 'Authentication',
authNone: 'None',
authBasic: 'Basic Auth',
authBearer: 'Bearer Token',
authHeaders: 'Custom Headers',
usernameLabel: 'Username',
passwordLabel: 'Password',
tokenLabel: 'Token',
headersLabel: 'Custom Headers (JSON object)',
headersInvalid: 'Headers must be a JSON object with string values',
skipTLS: 'Skip TLS verification',
setDefault: 'Set as default',
test: 'Test connection',
save: 'Save data source',
saveFailed: 'Failed to save',
remove: 'Delete',
},
range: {
lastMinutes: 'Last {{count}} minutes',
lastHours: 'Last {{count}} hours',
lastHours_one: 'Last {{count}} hour',
custom: 'Custom time',
title: 'Select time range',
start: 'Start time',
end: 'End time',
apply: 'Apply',
recent: 'Recently used',
},
editor: {
aria: 'LogQL query',
valuesOf: 'Values of {{label}}',
quickTab: 'Quick LogQL',
historyTab: 'History',
historyTabCount: 'History ({{count}})',
escClose: 'Esc to close',
historyEmpty: 'Frequently used LogQL will appear here after you run a query',
},
templates: {
aria: 'Quick query templates',
title: 'Quick LogQL builder',
hint: 'Pick a namespace, then an app, then a pod; app candidates come only from the selected namespace.',
namespace: 'Namespace',
app: 'App',
appWithKey: 'App ({{key}})',
pod: 'Pod',
selectCount: 'Select ({{count}} total)',
noApp: 'No app in this scope',
pickNsFirst: 'Pick a namespace first to narrow down',
noPod: 'No pods under this app',
optional: 'Optional',
scopeLogs: 'Query this scope',
errorLogs: 'Errors (error)',
warnLogs: 'Warnings (warn)',
podLogs: 'Specific pod logs',
ingressLogs: 'Ingress access logs',
scopeHint: 'Pick a namespace, then an app or pod — or choose values in the Label browser',
},
labels: {
browser: 'Label Browser',
selectedCount: '{{count}} selected',
loadingLabels: 'Loading labels…',
noLabels: 'No labels available',
pickHint: 'Select a label on the left, then search its values',
valuesOf: 'Values of {{label}}',
searchAria: 'Search values of {{label}}',
searchPlaceholder: 'Search or type a label value',
scoped: 'Narrow candidates by current query',
loading: 'Loading…',
noValues: 'No matching values',
useAsValue: 'Use "{{value}}" as value',
hint: 'Select namespace first to query all replicas in that Namespace; then app / service_name to narrow to a service (workloads without an app label can rely on namespace alone). Candidates come from logs written to Loki within the current time range; the authorized scope is still enforced server-side.',
},
volume: {
statsLoading: 'Counting logs across the whole time range; you can keep browsing the log page.',
statsError: 'Log volume statistics timed out or failed; the log page still works. Narrow the time range and query again.',
histogramLoading: 'Counting…',
histogramEmpty: 'No log volume data',
timelineAria: 'Log volume timeline',
timelineTitle: 'Log volume timeline',
timelineHint: 'Hover to see counts; press and drag at any height to select a time span; the query runs automatically after selection.',
timelineTotal: '{{count}} logs in range',
timelineBucket: '{{count}} logs',
timelineBucket_one: '{{count}} log',
},
results: {
aria: 'Log results',
title: 'Log Results',
summary: 'Page {{page}} · {{count}} entries',
summary_one: 'Page {{page}} · {{count}} entry',
summaryMore: 'Page {{page}} · {{count}} entries · more available',
summaryMore_one: 'Page {{page}} · {{count}} entry · more available',
viewAria: 'Log display mode',
viewRaw: 'Raw',
viewTable: 'Table',
viewLine: 'Line',
viewStream: 'Stream',
viewPie: 'Pie',
loadingPage: 'Loading log page',
loadingQuery: 'Querying Loki',
loadingWait: 'Waiting for {{seconds}}s. Wide-range queries may take a while.',
pagingHint: 'Logs are paged newest to oldest; charts show the current page, and the top timeline covers the whole query range.',
domainFilter: 'Domain filter',
domainPlaceholder: 'Type or pick a domain',
domainQuery: 'Query in Loki',
domainHint: 'Matches only the request Host recorded in log lines. Showing {{shown}} / {{total}}.',
domainNoMatch: 'No logs match the current domain filter',
filterLabel: 'Filter by this label',
showContext: 'Show 5 lines before and after',
prevPage: 'Previous',
nextPage: 'Next',
pageOf: 'Page {{page}}',
noSourceTitle: 'No queryable data source',
noSourceHint: 'Ask an administrator to add or authorize a data source',
noMatchTitle: 'No matching log entries',
noMatchHint: 'No results under the current time range and filters',
idleTitle: 'Enter a LogQL query and run it',
idleHint: 'You can also build queries quickly in the Label browser',
retry: 'Query again',
},
views: {
noDomain: 'No domain recorded',
otherDomain: 'Other domains',
unknown: 'Unknown',
untagged: 'Untagged',
other: 'Other',
tableLoaded: '{{count}} log entries loaded on this page.',
tableEmpty: 'No logs to display',
thTime: 'Time',
thNsPod: 'namespace / pod',
thLevel: 'Level',
thLog: 'Log',
lineByLoaded: 'Based on currently loaded logs; load more for additional results.',
lineByRange: 'Log volume aggregated over the query time range, from Loki.',
lineGroupAria: 'Line chart grouping',
lineByDomain: 'By domain',
lineByTotal: 'Total',
lineEmpty: 'No time-series data for this query',
lineSeriesTotal: 'Log count',
streamParsed: 'Showing {{count}} parsed entries',
streamByLevel: 'Showing {{count}} loaded entries by level',
streamSuffix: ', based on currently loaded results.',
streamGroupAria: 'Stream chart grouping',
streamByStatus: 'By HTTP status class',
streamEmpty: 'No log streams to display',
pieByAccess: 'Grouping {{count}} parsed entries by access fields',
pieByLabel: 'Grouping {{count}} loaded entries by label',
pieGroupAria: 'Pie chart grouping',
pieDomain: 'Domain (request Host)',
pieStatus: 'HTTP status',
pieMethod: 'Method',
piePath: 'Path',
pieUpstream: 'Upstream service',
pieLevel: 'Level',
pieEmpty: 'No label distribution to display',
},
ingress: {
modesAria: 'Ingress statistics mode',
modeTraffic: 'Endpoints PV / UV',
modeErrors: 'Top 10 errors',
modeSlow: 'Top 10 slow requests',
modeDetail: 'Request details',
summary:
'Based on the {{loaded}} entries loaded on this page, of which {{parsed}} are ingress-nginx access logs. Statistics are recomputed after paging; UV is deduplicated by non-empty client IP.',
statPv: 'PV',
statUv: 'UV (IP)',
statErrors: '4xx / 5xx',
statSlow: 'Slow ≥ {{threshold}}s',
errorClassAria: 'Error status range',
errorAll: 'All 4xx / 5xx',
error5xx: '5xx only',
error4xx: '4xx only',
slowThreshold: 'Slow request threshold',
seconds: '{{count}}s',
slowHint: 'Sorted by request_time from the access log; 408 / 499 / 504 are only timeout hints and do not by themselves prove a backend timeout.',
slowMissing: '{{count}} entries lack a valid duration.',
trafficHint: 'Success rate = 2xx / 3xx requests ÷ PV; query strings are stripped from paths.',
thHost: 'Request Host',
thPath: 'Path',
thSuccess: 'Success rate',
thErrorRate: 'Error rate',
thStatus: 'Status codes',
thSlow: 'Slow',
thMaxLatency: 'Max latency',
thAvgLatency: 'Avg latency',
thTimeout: '408 / 499 / 504',
thClientIp: 'Client IP',
thStatusShort: 'Status',
thLatency: 'Latency',
thTime: 'Time',
empty: 'No matching requests on this page',
noDomain: 'No Host recorded',
},
context: {
title: 'Context of the same log stream',
sameFile: 'Same file',
sameStream: 'Same log stream',
meta: 'Up to 5 entries before and after · ±5 minutes',
file: 'File: {{name}}',
current: 'Current',
currentAria: 'Current log',
beforeAria: 'Logs before',
afterAria: 'Logs after',
beforeTitle: 'Before · {{count}} entries',
beforeTitle_one: 'Before · {{count}} entry',
afterTitle: 'After · {{count}} entries',
afterTitle_one: 'After · {{count}} entry',
beforeEmpty: 'No logs in the previous 5 minutes',
afterEmpty: 'No logs in the following 5 minutes',
loadFailed: 'Failed to load context',
},
access: {
loadFailed: 'Failed to load query policies',
validate: 'Fill in the policy group name, choose a data source, and select "Anyone" or at least one user',
saveFailed: 'Failed to save policy group',
deleteFailed: 'Failed to delete policy group',
sourceRef: 'Data source #{{id}}',
listTitle: 'Query policy groups',
create: 'New',
listHint: 'One log scope can be shared by multiple users; later changes apply to all of them.',
loading: 'Loading…',
empty: 'No policy groups yet',
anyone: 'Anyone',
userCount: '{{count}} users',
userCount_one: '{{count}} user',
editTitle: 'Edit policy group',
createTitle: 'New policy group',
formHint: 'Choose a data source and the allowed log scope, then attach the users who reuse this policy.',
remove: 'Delete',
nameLabel: 'Policy group name',
namePlaceholder: 'e.g. Production agent logs',
sourceLabel: 'Data source',
sourceEmpty: 'Add a data source first',
scopeTitle: 'Allowed log scope',
ownerFallback: 'Policy group',
scopeHint: 'Restrict precisely by Namespace, App, or Pod. Unselected labels are not restricted.',
usersTitle: 'Users',
selectedAnyone: 'Selected: Anyone (anyone)',
selectedCount: 'Selected {{count}}',
anyoneToggle: 'Anyone (anyone)',
anyoneActive: '✓ Anyone (anyone)',
selectFiltered: 'Select filtered',
clear: 'Clear',
usersHint: 'A user can belong to only one policy group per data source; users already in another group are grayed out.',
searchAria: 'Search users',
searchPlaceholder: 'Search by username or nickname',
grouped: 'In a group',
noMatch: 'No matching users',
saving: 'Saving…',
save: 'Save policy group',
deleteTitle: 'Delete query policy group',
deleteDesc: 'After deleting "{{name}}", attached users will lose the data source access granted by this group.',
ruleAria: 'Query scope of {{name}}',
nsLabel: 'Namespace',
appLabel: 'App',
podLabel: 'Pod',
},
select: {
placeholder: 'Search and select',
any: 'Any',
loading: 'Loading…',
noMatch: 'No matches',
},
raw: {
searching: 'Querying…',
count: '{{count}} logs',
count_one: '{{count}} log',
dedupSuffix: ' (deduplicated from {{count}})',
dedup: 'Dedup',
empty: 'No matching logs',
original: 'Raw log',
labelsTitle: 'Labels (click to filter)',
filterValue: 'Filter this value',
excludeValue: 'Exclude this value',
fields: 'Extracted fields',
loadMore: 'Load more',
loadingMore: 'Loading…',
},
}
+141
View File
@@ -0,0 +1,141 @@
// settings namespace — Settings page (general/password/about, notifications, LDAP, image registry), English copy.
// Note: theme & language card titles and ThemeSection copy are owned elsewhere and are not in this dictionary.
export default {
title: 'Settings',
description: 'General settings, alert notifications, LDAP integration, and the default image registry',
tabs: {
general: 'General',
notify: 'Alert Notifications',
ldap: 'LDAP Integration',
registry: 'Default Image Registry',
},
// Action labels shared across sections
actions: {
cancel: 'Cancel',
saving: 'Saving...',
testing: 'Testing...',
},
password: {
title: 'Change Password',
oldLabel: 'Current password',
oldPlaceholder: 'Enter current password',
newLabel: 'New password',
newPlaceholder: 'Enter new password (at least 6 characters)',
confirmLabel: 'Confirm new password',
confirmPlaceholder: 'Re-enter new password',
submit: 'Change password',
submitting: 'Changing...',
errMismatch: 'Passwords do not match',
errTooShort: 'New password must be at least 6 characters',
errFailed: 'Failed to change password',
},
about: {
title: 'About LokiStack',
versionLabel: 'Version',
backendLabel: 'Backend',
frontendLabel: 'Frontend',
k8sLabel: 'Kubernetes Access',
summaryLabel: 'Overview',
backendValue: 'Go + Gin + JSON file storage',
frontendValue: 'React 19 + TypeScript + Tailwind CSS',
k8sValue: 'client-go, direct API Server access',
summaryValue: 'Lightweight Loki log management platform',
},
notify: {
cardTitle: 'Alert Channels — Feishu (Lark)',
create: 'New channel',
// Empty state is assembled as "prefix + link + suffix" (link points to the Alerts page)
emptyBefore: 'No channels yet — create one per alert group (e.g. "App alerts", "Nginx alerts"), then link rules to it on the ',
emptyLink: 'Alerts',
emptyAfter: ' page',
webhookConfigured: 'Webhook configured',
webhookMissing: 'Not configured',
secretConfigured: ' · Signing secret configured',
testAria: 'Test {{name}}',
editAria: 'Edit {{name}}',
deleteAria: 'Delete {{name}}',
editTitle: 'Edit Channel · {{name}}',
createTitle: 'New Notification Channel',
nameLabel: 'Channel name *',
namePlaceholder: 'e.g. App alerts / Nginx alerts',
webhookLabel: 'Webhook URL',
webhookKeepHint: ' (leave blank to keep current)',
webhookSavedPlaceholder: 'Saved — enter a new URL to replace it',
signingLabel: 'Signing secret (optional)',
signingSavedPlaceholder: 'Saved — leave blank to keep current',
signingPlaceholder: 'Feishu (Lark) bot signing secret',
enable: 'Enable channel',
saveChanges: 'Save changes',
createChannel: 'Create channel',
deleteTitle: 'Delete Notification Channel',
testSent: 'Test card sent to "{{name}}"',
errLoad: 'Failed to load notification channels',
errNameRequired: 'Channel name is required',
errWebhookRequired: 'A Webhook URL is required for a new channel',
errSave: 'Save failed',
errSend: 'Send failed',
errToggle: 'Failed to toggle',
errDelete: 'Delete failed',
},
ldap: {
cardTitle: 'LDAP Integration',
headerHint: 'Enables LDAP sign-in and bulk user import',
errLoad: 'Failed to load LDAP configuration',
disabledHint:
'LDAP is disabled — turn it on and fill in the directory details (server / Bind DN / Base DN). After saving, "Import from LDAP" appears on the Users page.',
hostLabel: 'Server address *',
portLabel: 'Port',
ldapsLabel: 'LDAPS',
bindDnLabel: 'Bind DN *',
bindPasswordLabel: 'Bind password *',
bindPasswordSavedPlaceholder: 'Saved — enter a new password to replace it',
bindPasswordPlaceholder: 'Administrator password',
baseDnLabel: 'Base DN *',
userFilterLabel: 'User filter',
usernameLabel: 'Username',
nameLabel: 'Full name',
emailLabel: 'Email',
noEmail: 'No email',
testButton: 'Test connection',
saveButton: 'Save configuration',
errMissingFields: 'Fill in the server address / Bind DN / Base DN first',
errTest: 'Test failed',
savedToast: 'LDAP configuration saved',
},
registry: {
cardTitle: 'Image Registry',
modeOverseasLabel: 'Official (overseas)',
modeOverseasDesc: 'docker.io, for overseas networks',
modeCnLabel: 'China mirror',
modeCnDesc: 'docker.m.daocloud.io mirror, for networks in China',
modeCustomLabel: 'Private registry',
modeCustomDesc: 'Your own registry prefix (e.g. Huawei Cloud SWR)',
errLoad: 'Failed to load image registry configuration',
savedToast: 'Image registry saved. Future deployments and collectors will use the new image addresses.',
customPrefixLabel: 'Private registry prefix *',
customPrefixHint: 'The platform prepends this prefix to image names, e.g. {{example}}/grafana/loki:3.4.0',
imagesLabel: 'Per-service images (repository / tag can be overridden)',
tagFollowVersion: 'Follows deployment version',
repoAria: '{{label}} image repository',
tagAria: '{{label}} image tag',
// The images hint is assembled as "prefix + <code>grafana/loki</code> + suffix"
imagesHintBefore: 'Repository is the image path (e.g. ',
imagesHintAfter: '); when Tag is empty, Loki / Grafana follow the deployment version and Promtail / Fluent Bit use built-in defaults.',
previewTitle: 'Effective image preview',
saveButton: 'Save image registry',
},
themeCard: {
title: 'Theme',
light: 'Light',
dark: 'Dark',
system: 'Follow System',
auto: 'Scheduled',
timezone: 'Timezone',
timezoneAria: 'Theme schedule timezone',
browser: 'Follow browser (local)',
autoHint: 'Light from {{start}}:00 to {{end}}:00 in the selected timezone, dark otherwise; the sun/moon toggle overrides anytime.',
},
prefsCard: {
title: 'Appearance & Language',
},
}
+55
View File
@@ -0,0 +1,55 @@
// storage namespace — storage page copy (page shell / card / form dialog)
export default {
page: {
title: 'Storage',
description: 'Configure object storage backends for Loki',
add: 'Add Storage',
emptyTitle: 'No storage configs',
emptyDescription: 'Add your first Loki storage backend',
// delete confirmation shown while deployments still reference the config
deleteWarning_one:
'Delete "{{name}}"? Warning: {{count}} deployment still uses this storage; its object storage config will stop working.',
deleteWarning_other:
'Delete "{{name}}"? Warning: {{count}} deployments still use this storage; their object storage configs will stop working.',
errDelete: 'Failed to delete the storage config',
errSetDefault: 'Failed to set the default storage',
},
card: {
default: 'Default',
endpoint: 'Endpoint',
pathPrefix: 'Path prefix',
none: 'None',
connection: 'Connection',
insecureHttp: 'HTTP (insecure)',
setDefault: 'Set as default',
editAria: 'Edit storage config {{name}}',
deleteAria: 'Delete storage config {{name}}',
},
form: {
addTitle: 'Add Storage Config',
editTitle: 'Edit Storage Config',
name: 'Name *',
storageType: 'Storage Type',
endpoint: 'Endpoint',
bucket: 'Bucket *',
region: 'Region',
secretPlaceholder: 'Enter the Secret Key',
secretPlaceholderSaved: 'Saved · leave empty to keep',
insecure: 'Insecure (HTTP)',
setDefault: 'Set as default',
autoCreate: 'Auto-create the bucket during testing if missing',
test: 'Test Connection',
testing: 'Testing...',
add: 'Add Storage',
adding: 'Adding...',
save: 'Save Changes',
saving: 'Saving...',
testHintSaved: 'Fill in Bucket and Access Key first (Secret Key is saved — leave it empty)',
testHintMissing: 'Fill in Bucket and Access Key / Secret Key first',
errSubmit: 'Failed to create the storage config',
errTest: 'Connection test failed',
// zod validation messages (t injected by the schema factory)
nameRequired: 'Enter a storage config name',
bucketRequired: 'Enter the bucket name',
},
}
+87
View File
@@ -0,0 +1,87 @@
// users namespace — User management domain (header / role & source badges / create & reset password / LDAP import)
export default {
title: 'User Management',
subtitle: 'Manage platform sign-in accounts (admin only)',
create: 'Create User',
ldapImport: 'Import from LDAP',
emptyTitle: 'No users yet',
emptyDesc: 'Create the first user account',
listTitle: 'Users ({{count}} total)',
currentUser: '(you)',
noNickname: 'No nickname set',
createdAt: 'Created {{date}}',
resetPassword: 'Reset Password',
resetDesc:
'After resetting the password for "{{name}}", all of their sign-in sessions will be invalidated immediately.',
newPassword: 'New password *',
randomPassword: 'Generate random password',
resetting: 'Resetting...',
resetDone: 'Password for "{{name}}" has been reset; all of their sessions are now invalid',
resetFailed: 'Failed to reset password',
deleteAria: 'Delete user {{name}}',
deleteFailed: 'Failed to delete user',
cannotDeleteSelf: 'You cannot delete the account you are currently signed in with',
confirmDeleteTitle: 'Confirm user deletion',
createFailed: 'Failed to create user',
adminOnly: {
title: 'Admin access required',
desc: 'User management is available to admins only. Contact an administrator for account matters.',
},
role: {
admin: 'Admin',
user: 'User',
},
source: {
ldap: 'LDAP import',
local: 'Local account',
},
form: {
username: 'Username *',
usernamePlaceholder: 'Sign-in username',
usernameMin: 'Username must be at least 2 characters',
usernameMax: 'Username must be at most 32 characters',
nickname: 'Nickname',
nicknamePlaceholder: 'Display nickname (optional)',
password: 'Initial password *',
passwordPlaceholder: 'At least 6 characters',
passwordMin: 'Password must be at least 6 characters',
role: 'Role *',
rolePlaceholder: 'Select a role',
submitting: 'Creating...',
},
ldap: {
title: 'Import users from LDAP',
desc: 'Users from the LDAP directory. Imported users sign in with their LDAP password and are assigned the User role.',
added: '{{count}} added',
converted: '{{count}} converted to LDAP',
skipped: '{{count}} skipped',
failedCount: '{{count}} failed',
continue: 'Import more',
done: 'Done',
loading: 'Loading LDAP users...',
summary: '{{total}} users · {{selected}} selected',
errorHintPre: 'Go to',
errorHintPost: 'to check the LDAP configuration and status',
imported: 'Imported',
willConvert: 'Exists locally, will convert to LDAP',
noName: 'No name',
noEmail: 'No email',
empty: 'No users in this directory',
importing: 'Importing...',
importCount_one: 'Import {{count}} user',
importCount_other: 'Import {{count}} users',
importFailed: 'Import failed',
loadFailed: 'Failed to load LDAP users',
},
selectAria: 'Select user {{name}}',
batch: {
selected: '{{count}} selected',
delete: 'Delete Selected',
clear: 'Clear selection',
selectAll: 'Select all',
confirmTitle: 'Confirm Bulk Deletion',
confirmDesc: 'Delete {{count}} selected users? Protected accounts (your own / the last admin) will be skipped. This cannot be undone.',
skippedWarn: '{{count}} skipped: {{reasons}}',
failed: 'Bulk delete failed',
},
}
+69
View File
@@ -0,0 +1,69 @@
// wizard namespace — "Deploy Wizard" page domain, English copy.
// Registration: same as the collectors namespace — each page-domain dictionary registers
// itself via addResourceBundle when side-effect imported (no changes to src/i18n/index.ts).
const wizard = {
page: {
title: 'Deploy Wizard',
description: 'Set up the logging platform in four steps: environment → storage → deploy → collect (steps can be completed in any order)',
loadFailed: 'Failed to load wizard status',
},
steps: {
flow: 'Deployment flow',
doneAria: '{{title}} completed',
todoAria: '{{title}} pending',
env: { title: 'Environment & Target', desc: 'Kubernetes cluster or bare-metal host' },
storage: { title: 'Prepare Storage', desc: 'Object storage or local disk' },
deploy: { title: 'Deploy Loki', desc: 'Built-in log query' },
collect: { title: 'Configure Collection', desc: 'Host files / cluster DaemonSet' },
},
hint: {
env: 'Start by choosing an environment and connecting a cluster or host',
storage: 'Environment ready — object storage is recommended next',
deploy: 'Target and storage ready — create your Loki deployment',
collect: 'Loki is running — add a collection policy to start collecting logs',
all: 'All set: logs are being collected — head to Log Query to analyze them',
},
env: {
k8sTitle: 'Kubernetes Cluster',
k8sDesc: 'Deploy Loki as a StatefulSet via the Kubernetes API; a DaemonSet collects container logs',
hostTitle: 'Bare-Metal Host',
hostDesc: 'Deploy Loki with host Docker and collect specified log files',
ready: 'Ready',
connected: 'Connected: {{names}}',
nameSep: ', ',
k8sHint: 'Connect a cluster via kubeconfig or ServiceAccount token; connectivity is verified automatically on creation',
addCluster: 'Add Cluster',
hostHint: 'Connect a host via SSH key or password (used for deployment and later collector installation)',
addHost: 'Add Host',
pickFirst: '↑ Pick an environment above to get the matching target onboarding form',
},
storage: {
defaultLabel: 'Default storage: ',
none: 'No storage configured — use local MinIO / RustFS, or AWS / Alibaba Cloud object storage',
manage: 'Manage Storage',
add: 'Add Storage',
configure: 'Configure Storage',
hint: 'Object storage (MinIO / RustFS / S3 / OSS) is recommended for more reliable replication and retention. Use "Test Connection" when adding to verify credentials and auto-create missing buckets; skip to use local disk.',
},
deploy: {
meta: '{{mode}} · Namespace {{namespace}} · {{endpoint}}',
endpointPending: 'endpoint available after deployment',
openGrafana: 'Open Grafana ({{url}} · admin/admin)',
openLoki: 'Open Loki',
details: 'Deployment Details',
none: 'No running Loki — deploy to Kubernetes or host Docker',
create: 'Create Deployment',
triggering: 'Triggering deployment…',
deployTriggered: 'Deployment of "{{name}}" triggered — check the deployments page for logs and progress',
},
collect: {
title: 'Log Collection Policies',
activeCount: '{{count}} active',
desc: 'Hosts: specified log file paths · Clusters: all container logs via DaemonSet (deployed into the linked Loki\'s namespace)',
add: 'Add Collection Policy',
manage: 'Manage Collectors',
},
}
export default wizard
+78
View File
@@ -0,0 +1,78 @@
// alerts 命名空间 — 日志告警域中文文案(页头 / 规则表单与模板 / 发送历史 / 飞书通知提示)
export default {
title: '日志告警',
subtitle: '按 LogQL 计数规则监控日志,超阈值推送飞书卡片(周期 1 分钟)',
checkNow: '立即检查',
checking: '检查中...',
create: '新建规则',
emptyTitle: '暂无告警规则',
emptyDesc: '例如:{namespace=~".+"} |= "FATAL",15 分钟内超过 0 条即推送飞书',
sourceFallback: '数据源 {{id}}',
noChannels: '未关联通道',
channelSep: '、',
ruleMeta: '{{window}} 分钟超过 {{threshold}} 条触发 · 冷却 {{cooldown}} 分钟',
lastFired: '上次触发 {{time}}',
editAria: '编辑 {{name}}',
deleteAria: '删除 {{name}}',
historyTitle: '发送历史(最近 50 条)',
notifyHintPre: '通知未配置?去',
notifyHintPost: '配置飞书 Webhook',
editTitle: '编辑规则 · {{name}}',
createTitle: '新建告警规则',
templates: '快捷模板(点击预填,可再修改)',
saving: '保存中...',
save: '保存修改',
submitCreate: '创建规则',
loadFailed: '加载告警数据失败',
saveFailed: '保存失败',
deleteFailed: '删除失败',
checkDone: '已执行一次检查,结果见发送历史',
checkFailed: '检查失败',
toggleFailed: '切换失败',
confirmDeleteTitle: '确认删除告警规则',
delivery: {
sent: '已发送',
cooldown: '冷却跳过',
failed: '失败',
hits_other: '{{count}} 条命中',
},
tpl: {
nsError: {
label: '业务 NS error',
desc: '指定命名空间(默认 astralx)内 error 日志',
},
allNsError: {
label: '全 NS error > 1000',
desc: '所有命名空间 error 日志总量超过 1000 条',
},
ingress5xx: {
label: 'Ingress 5xx',
desc: 'ingress-nginx 5xx(需 JSON access log,平台可一键标准化)',
},
ingress4xx: {
label: 'Ingress 4xx',
desc: 'ingress-nginx 4xx(需 JSON access log)',
},
fatal: {
label: 'FATAL',
desc: '所有命名空间出现 FATAL 即告警',
},
},
form: {
name: '规则名称 *',
namePlaceholder: '应用致命异常',
source: '数据源 *',
sourcePlaceholder: '选择数据源',
query: 'LogQL 查询 *',
queryHint:
'常用:{app="xxx"} |= "FATAL" / {namespace=~".+"} |~ "(?i)error" / {app="x"} |= "OutOfMemoryError"',
channels: '通知通道(可多选,扇出到多个告警群)',
noChannels: '请先在「系统设置 → 告警通知」创建通道',
channelDisabled: '(禁用)',
window: '统计窗口(分)',
threshold: '触发阈值(条)',
thresholdHint: '0 = 出现即告警',
cooldown: '冷却(分)',
enabled: '启用规则',
},
}
+88
View File
@@ -0,0 +1,88 @@
// clusters 命名空间 — 集群管理页中文文案
// 富文本(含 <code>/<strong>)配合 react-i18next <Trans> 渲染,勿删标签
export default {
title: '集群管理',
description: '管理用于 Loki 部署的 Kubernetes 集群',
actions: {
add: '添加集群',
detail: '详情',
editAria: '编辑集群 {{name}}',
validateAria: '验证集群 {{name}}',
deleteAria: '删除集群 {{name}}',
},
fields: {
accessType: '接入方式',
apiServer: 'API Server',
version: '版本',
nodes: '节点数',
unknown: '未知',
},
empty: {
title: '暂无集群',
description: '添加第一个 Kubernetes 集群开始使用',
},
delete: {
warning:
'确定要删除「{{name}}」吗?警告:仍有 {{dep}} 个部署、{{col}} 个采集策略引用该集群,删除后它们将无法工作。',
},
toast: {
deleteFailed: '删除集群失败',
validateFailed: '验证失败',
createFailed: '创建集群失败',
},
form: {
addTitle: '添加 Kubernetes 集群',
editTitle: '编辑集群',
editTitleWithName: '编辑集群 · {{name}}',
nameLabel: '集群名称 *',
descriptionLabel: '描述',
descriptionPlaceholder: '生产环境日志集群',
accessTypeLabel: '接入方式',
kubeconfigLabel: 'Kubeconfig 内容 *',
kubeconfigLabelEdit: 'Kubeconfig 内容(留空保持不变)',
kubeconfigPlaceholder: '粘贴 kubeconfig YAML(自动识别 API Server)...',
apiServerDetected: '已识别 API Server:{{server}}',
apiServerLabel: 'API Server *',
tokenLabel: 'Token *',
tokenLabelEdit: 'Token(留空保持不变)',
tokenPlaceholder: '粘贴 Service Account Token...',
tokenInsecureHint: '采用 insecure 模式,便于内网/自签证书集群接入',
rbacSummary: '最小授权建议(点击展开)',
rbacScope:
'只授予 <code>log</code> 命名空间权限,Token 泄露也不会影响其他命名空间;<strong>不要使用 cluster-admin</strong>。',
rbacScriptIntro:
'一次性创建 <code>log</code> 命名空间、ServiceAccount、命名空间级 Role 和长期 Token(在集群里执行一次,Token 粘贴到下方输入框):',
rbacScriptComment1: '# 1) 一次创建:命名空间 + SA + 命名空间级 Role + 长期 Token',
rbacScriptComment2: '# 2) 取出长期 Token(不自动过期),粘贴到 Token 输入框',
rbacNamespaceNote:
'上面的 Namespace 由管理员创建,平台 Token 本身没有任何集群级权限。只有确实需要平台自建命名空间时,才额外授予 <code>namespaces: create/get</code> 的 ClusterRole;注意 namespace 的 create 无法按名称限制,所以预建 <code>log</code> 更安全。',
rbacTokenNote:
'Token 长期有效,但只绑定 <code>log</code> 命名空间的 Role(仅能操作该命名空间的 Pod / 工作负载),泄露影响面仅限于 <code>log</code>;服务端会转成 kubeconfig 并用 <code>ENCRYPTION_KEY</code> 加密存储,数据库里只有 <code>ENC:</code> 密文,接口不返回明文。轮换时删除该 Secret 重建即可,旧 Token 立即失效。',
contextLabel: '上下文(可选)',
contextPlaceholder: '留空则使用默认上下文',
submitAdd: '添加集群',
submitSave: '保存修改',
submittingAdd: '添加中...',
submittingSave: '保存中...',
validation: {
nameRequired: '请输入集群名称',
kubeconfigRequired: '请填写 kubeconfig 内容',
apiServerRequired: '请填写 API Server 地址',
tokenRequired: '请填写 Token',
},
},
detail: {
loadError: '加载集群详情失败',
version: '版本',
unknown: '未知',
currentContext: '当前上下文',
defaultContext: '默认',
nodesCount: '节点({{count}})',
nodeReady: '就绪',
nodeNotReady: '未就绪',
nodeIp: 'IP:{{value}}',
nodeOs: '系统:{{os}}/{{arch}}',
nodeVersion: '版本:{{version}}',
namespaces: '命名空间',
},
}
+135
View File
@@ -0,0 +1,135 @@
// collectors 命名空间 — 「日志采集」页面域中文文案。
// 注册说明:i18n 入口(src/i18n/index.ts)由框架统一维护,页面域词典在被页面组件
// 与入口处 resources 静态注册的 common 命名空间共存。
const collectors = {
page: {
title: '日志采集',
description: '为主机与集群配置日志采集策略,日志将推送到关联的 Loki',
addAction: '添加采集策略',
},
empty: {
title: '暂无采集策略',
description: '新建一个日志采集器,关联已部署的 Loki',
},
// 采集器类型徽章(卡片描述行)
typeLabel: {
hostPromtail: '主机 Promtail',
k8sDaemonset: 'Promtail DaemonSet',
k8sFluentbitSidecar: 'Fluent Bit Sidecar',
},
// 卡片字段标签
field: {
loki: '关联 Loki',
cluster: '集群',
host: '主机',
namespace: '命名空间',
logPath: '日志路径',
target: '目标',
unknown: '未知',
},
action: {
install: '部署',
uninstall: '卸载',
editAria: '编辑采集策略 {{name}}',
deleteAria: '删除采集器 {{name}}',
},
toast: {
loadOptionsFailed: '加载 Loki 部署/集群/主机数据失败',
installFailed: '部署采集器失败',
uninstallFailed: '卸载采集器失败',
deleteFailed: '删除采集器失败',
createFailed: '创建采集器失败',
saveFailed: '保存采集策略失败',
},
deleteConfirm: '确定要删除「{{name}}」吗?删除时将自动卸载采集端,此操作不可恢复。',
form: {
title: '添加采集策略',
name: '名称',
type: '采集器类型',
// 新建表单下拉里的类型选项(比卡片徽章多部署通道说明)
typeOption: {
hostPromtail: '主机 Promtail(SSH)',
k8sDaemonset: 'Kubernetes Promtail DaemonSet',
k8sFluentbitSidecar: 'Kubernetes Fluent Bit Sidecar',
},
lokiDeployment: '关联 Loki 部署',
lokiPlaceholder: '选择已部署的 Loki(推送目标)',
// 下拉项名称后的括号后缀(状态/IP),中英文括号形态不同
statusParen: '({{status}})',
hostParen: '({{ip}})',
host: '目标主机',
hostPlaceholder: '选择主机',
cluster: '目标集群',
clusterPlaceholder: '选择集群',
namespace: '命名空间',
nsPickCluster: '先选择集群',
nsLoading: '加载命名空间…',
nsPick: '选择命名空间',
nsEmpty: '暂无可读取的命名空间',
nsLoadFailed: '读取命名空间失败',
pickFromCluster: '从集群选择',
manualInput: '手动填写',
targetDeployment: '目标 Deployment',
wlPickNamespace: '先选择命名空间',
wlLoading: '加载 Deployment…',
wlPick: '选择 Deployment',
wlEmpty: '此命名空间暂无 Deployment',
wlLoadFailed: '读取 Deployment 失败',
wlReady: '(就绪 {{ready}}/{{total}})',
logPath: '采集日志路径',
logPathHostHint: '支持 glob 通配(如 /var/log/*.log /var/log/syslog);多个路径用逗号分隔',
logPathSidecarHint:
'填写业务写入的文件路径,如 /var/log/app/*.log。已有日志卷会自动共享;无日志卷时仅在专用 logs 目录创建共享卷。/tmp/test.log 等普通目录不会自动挂载。',
labels: '自定义标签(JSON,可选)',
labelsHint: '合法 JSON 时将附加到每条日志的标签,便于在 Loki/Grafana 中筛选;留空则不加',
submit: '创建并部署',
submitting: '创建中...',
validation: {
nameRequired: '请输入采集器名称',
lokiRequired: '请选择关联的 Loki 部署',
hostRequired: '请选择目标主机',
clusterRequired: '请选择目标集群',
namespaceRequired: '请选择命名空间',
targetDeploymentRequired: '请填写目标 Deployment 名称',
},
},
edit: {
title: '编辑采集策略',
namespaces: '采集命名空间',
allNamespaces: '全集群所有命名空间',
selectedCount: '已选 {{count}} 个',
allCheckbox: '所有命名空间(全集群采集)',
nsSearch: '搜索命名空间…',
loading: '加载中…',
nsListEmpty: '未取到命名空间列表(可手动输入)',
nsNoMatch: '无匹配',
nsLoadFailed: '加载命名空间失败',
// nsHint/appSourceHint/appIncludeHint 含行内 <code>,渲染处用 Trans 组件
nsHint:
'勾「所有命名空间」= 采集全集群;否则只采集勾选的命名空间。生成 Promtail 的 <code>kubernetes_sd_configs.namespaces</code> 白名单。',
appLabelSource: 'app 标签取值来源',
appSourcePlaceholder: '留空 = 默认(controller 名回退链)',
appSourceHint:
'默认按 <code>app.kubernetes.io/name → app → controller 名 → pod 名</code> 四级回退。若集群有自己的私有规范标签(如 <code>k8s.kuboard.cn/name</code>),填在这里会作为最高优先级,其余仍兜底。',
appSourcePreset: {
default: '默认:app.kubernetes.io/name → app → controller 名 → pod 名',
controllerName: 'controller_name(Pod 的 ReplicaSet 名,去 hash = Deployment 名)',
k8sAppName: 'app.kubernetes.io/name(Kubernetes 标准标签)',
app: 'app(常见简写)',
kuboard: 'k8s.kuboard.cn/name(Kuboard 注入)',
},
appInclude: '只采集这些 app(可选)',
appIncludePlaceholder: '留空 = 不限;多个用逗号分隔,如 cex-spot-api, cex-asset-api',
appIncludeHint:
'填了之后 Promtail 只保留 app 命中这些值的日志(<code>action: keep</code>),是控制日志量最直接的手段。值需与上面「app 标签取值来源」解析出的 app 一致。',
logPathHint: 'Sidecar 需使用业务容器的共享日志卷,或专用 logs 目录。保存后点击「部署」应用新配置。',
save: '保存',
saving: '保存中…',
validation: {
nameRequired: '请输入采集策略名称',
},
},
}
export default collectors
+99
View File
@@ -0,0 +1,99 @@
// common 命名空间 — 全局框架(菜单/顶栏/登录/通用组件/状态)中文文案
export default {
appName: 'LokiStack',
menu: {
dashboard: '仪表盘',
wizard: '部署向导',
clusters: '集群管理',
hosts: '主机管理',
storage: '存储配置',
deployments: '部署 Loki',
collectors: '日志采集',
logs: '日志查询',
alerts: '日志告警',
settings: '系统设置',
users: '用户管理',
login: '登录',
notFound: '页面不存在',
},
layout: {
nav: '主导航',
sheetTitle: 'LokiStack 导航',
collapse: '收起菜单',
expand: '展开菜单',
openNav: '打开导航菜单',
logout: '退出登录',
logoutShort: '退出',
toLight: '切换为浅色',
toDark: '切换为深色',
admin: '管理员',
loading: '页面加载中',
},
login: {
subtitle: 'Loki 日志管理平台',
heading: '登录',
username: '用户名',
usernamePlaceholder: '请输入用户名',
password: '密码',
passwordPlaceholder: '请输入密码',
remember: '记住用户名',
submit: '登录',
submitting: '登录中...',
errCredential: '用户名或密码错误',
errConn: '无法连接后端服务,请确认后端已启动(端口 8080)。',
errFallback: '登录失败',
backendDown: '后端服务未启动',
backendDownHint: '无法连接 API 服务,请先启动后端:',
},
dialog: {
confirmTitle: '确认删除',
confirmText: '删除',
cancel: '取消',
processing: '处理中...',
confirmIrreversible: '确定要执行该操作吗?此操作不可恢复。',
confirmDefault: '确定要删除「{{name}}」吗?此操作不可恢复。',
},
error: {
title: '加载失败',
hint: '请检查网络或后端服务状态后重试',
retry: '重试',
},
pagination: {
total: '共 {{count}} 条',
summary: '共 {{count}} 条 · 第 {{page}}/{{pages}} 页',
},
status: {
active: '运行中',
running: '运行中',
inactive: '已停止',
stopped: '已停止',
pending: '等待中',
deploying: '部署中',
failed: '失败',
error: '异常',
},
notFound: {
heading: '页面不存在',
hint: '页面不存在或已被移除',
backHome: '返回首页',
},
lang: {
section: '语言',
zh: '中文',
en: 'English',
},
boundary: {
title: '页面渲染出错',
hint: '页面遇到异常,可尝试刷新;若持续出现请把下方信息反馈给开发者。',
unknown: '未知错误',
reload: '刷新页面',
reset: '重置组件',
},
spinner: {
default: '加载中...',
},
password: {
hide: '隐藏密码',
show: '显示密码',
},
}
+28
View File
@@ -0,0 +1,28 @@
// dashboard 命名空间 — 仪表盘域中文文案(指标卡 / 快速开始引导 / 最近部署 / 资源快捷列表)
export default {
stat: {
totalDeployments: '部署总数',
runningCount: '{{count}} 个运行中',
clusters: '集群',
connected: '已接入',
hosts: '主机',
sshReady: 'SSH 就绪',
status: '状态',
},
quickStart: {
title: '三步开始收集日志',
subtitle: '环境与目标 → 存储 → 部署 Loki 与 Grafana,最后添加采集策略',
cta: '进入部署向导',
},
recent: {
title: '最近部署',
viewAll: '查看全部',
emptyTitle: '暂无部署',
emptyDesc: '创建你的第一个 Loki 部署',
},
list: {
manage: '管理',
empty: '暂无{{title}}',
},
loadFailed: '加载仪表盘数据失败',
}
+121
View File
@@ -0,0 +1,121 @@
// deployments 命名空间 — Loki 部署页中文文案(页面/卡片/表单/保留配置/日志弹窗)
export default {
page: {
title: '部署 Loki',
description: '部署与管理 Grafana Loki 日志聚合服务',
create: '新建部署',
emptyTitle: '暂无部署',
emptyDescription: '创建你的第一个 Loki 部署',
// 删除确认:host-docker 连容器一起删,其余模式仅删记录不卸载资源
deleteDockerDesc: '确定要删除「{{name}}」吗?将同时移除主机上的 Loki 容器,此操作不可恢复。',
deleteRecordDesc:
'确定要删除「{{name}}」吗?删除记录不会自动卸载集群/主机上已部署的 Loki 资源,此操作不可恢复。',
errLoadRefs: '加载集群/主机/存储数据失败',
errDeploy: '部署失败',
errStop: '停止失败',
errRestart: '重启失败',
errDelete: '删除部署失败',
},
card: {
mode: '方式',
modeK8s: 'Kubernetes',
modeDocker: '主机 Docker',
version: '版本',
target: '目标',
targetCluster: '集群:{{name}}',
targetHost: '主机:{{name}}',
targetUnknown: '未知',
namespace: '命名空间',
replicas: '副本',
replicasValue: '{{replicas}} · 保留 {{days}} 天',
retention: '保留',
retentionValue: '{{days}} 天',
storage: '存储',
localStorage: '本地',
ready: 'Loki 就绪',
notReady: 'Loki 未就绪',
runningHint:
'提示:到「日志查询 → 数据源」关联 Loki 后即可查询;跨集群/公网访问需先为 Loki 配置 Ingress 域名。',
configure: '配置',
deploy: '部署',
deploying: '部署中...',
stop: '停止',
restart: '重启',
logs: '日志',
deleteAria: '删除部署 {{name}}',
},
form: {
title: '创建 Loki 部署',
name: '部署名称 *',
description: '描述',
descriptionPlaceholder: '生产环境 Loki 日志服务',
version: '版本',
mode: '部署方式',
modeK8s: 'Kubernetes',
modeDocker: '主机 Docker',
cluster: '目标集群 *',
clusterPlaceholder: '请选择集群',
namespace: '命名空间 *',
host: '目标主机 *',
hostPlaceholder: '请选择主机',
listenIP: '监听内网 IP',
listenIPPlaceholder: '选择主机后默认使用主机 IP',
listenPort: '访问端口',
dockerHint: '通过 SSH 在主机启动 Docker 容器,配置和数据保存在主机本地。',
storage: '存储配置',
storagePlaceholder: '请选择存储(可选)',
localStorage: '本地存储(默认)',
replicas: '副本数',
retentionDays: '保留天数',
storageSize: '存储容量',
memory: '内存',
authEnabled: '启用多租户鉴权',
create: '创建部署',
creating: '创建中...',
errCreate: '创建部署失败',
// zod 校验消息(schema 工厂注入 t)
nameRequired: '请输入部署名称',
versionRequired: '请选择 Loki 版本',
retentionInvalid: '保留天数需为不小于 1 的整数',
replicasInvalid: '副本数需为 1-10 之间的整数',
cpuRequired: '请填写 CPU 配额(如 500m)',
memRequired: '请填写内存配额(如 512Mi)',
storageSizeRequired: '请填写存储容量(如 20Gi)',
clusterRequired: '请选择目标集群',
hostRequired: '请选择目标主机',
portInvalid: '端口须在 1-65535 之间',
namespaceRequired: '请填写命名空间',
storageSizeMin: '阿里云 ESSD 最小 20Gi,请填 20Gi 或以上',
},
retention: {
title: '编辑 Loki 配置 · {{name}}',
locationLabel: '配置位置:',
locationHostBinary: '/etc/loki/loki.yaml → limits_config.retention_period',
locationHostDocker: '主机 /etc/lokistack/loki-{{id}}/loki.yaml → limits_config.retention_period',
locationK8s: 'ConfigMap {{namespace}}/loki-config → loki.yaml',
compactorNote: '保留策略由 Loki compactor 执行;MinIO 只负责存储日志对象。',
liveTitle: '运行中的完整 loki.yaml',
liveHint: '从部署目标读取,存储凭证已遮盖;下方保留天数仍单独编辑。',
view: '查看完整配置',
refresh: '刷新配置',
loading: '读取中…',
templateNote: '由部署模板生成',
yamlLabel: '保留配置 YAML',
yamlHint: '编辑 limits_config.retention_period,例如 90d 或 2160h。此处只接受这一项配置。',
applyHintRunning: '仅保存会更新部署记录;保存并应用会重新部署 Loki,查询可能短暂中断。',
applyHintIdle: '保存后在下次部署时生效。',
cancel: '取消',
saveOnly: '仅保存',
saveApply: '保存并应用',
processing: '处理中…',
savedApplied: '已保存 {{days}} 天,Loki 正在重新部署',
yamlRequired: '请输入 YAML 配置',
errLoad: '读取运行配置失败',
errSave: '保存 Loki 配置失败',
},
logs: {
title: '部署日志 - {{name}}',
empty: '暂无日志...',
errLoad: '加载部署日志失败',
},
}
+81
View File
@@ -0,0 +1,81 @@
// hosts 命名空间 — 主机管理页中文文案
export default {
title: '主机管理',
description: '管理用于二进制方式部署 Loki 的 SSH 主机',
actions: {
add: '添加主机',
detail: '详情',
editAria: '编辑主机 {{name}}',
validateAria: '验证主机 {{name}}',
deleteAria: '删除主机 {{name}}',
},
fields: {
address: '地址',
user: '用户',
auth: '认证',
authPassword: '密码',
authKey: 'SSH 密钥',
os: '系统',
unknown: '未知',
},
empty: {
title: '暂无主机',
description: '添加第一台 SSH 主机开始使用',
},
delete: {
warning:
'确定要删除「{{name}}」吗?警告:仍有 {{dep}} 个部署、{{col}} 个采集策略引用该主机,删除后它们将无法工作。',
},
toast: {
deleteFailed: '删除主机失败',
validateFailed: '验证失败',
createFailed: '添加主机失败',
},
form: {
addTitle: '添加 SSH 主机',
editTitle: '编辑主机',
editTitleWithName: '编辑主机 · {{name}}',
nameLabel: '名称 *',
hostIpLabel: '主机 IP *',
portLabel: '端口',
userLabel: '用户名 *',
authTypeLabel: '认证方式',
passwordLabel: '密码 *',
passwordLabelEdit: '密码(留空保持不变)',
passwordPlaceholder: 'SSH 登录密码',
privateKeyLabel: '私钥 *',
privateKeyLabelEdit: '私钥(留空保持不变)',
keyPhraseLabel: '密钥口令(可选)',
keyPhrasePlaceholder: '无口令则留空',
labelsLabel: '标签(JSON)',
submitAdd: '添加主机',
submitSave: '保存修改',
submittingAdd: '添加中...',
submittingSave: '保存中...',
validation: {
nameRequired: '请输入主机名称',
hostIpRequired: '请输入主机 IP',
portRequired: '请输入端口',
portNumeric: '端口必须为数字',
portRange: '端口必须在 1-65535 之间',
userRequired: '请输入用户名',
passwordRequired: '请填写 SSH 登录密码',
privateKeyRequired: '请粘贴 SSH 私钥内容',
},
},
detail: {
loadError: '加载主机详情失败',
usage: '资源使用率',
memory: '内存',
disk: '磁盘',
usageDetail: '({{detail}})',
basicInfo: '基本信息',
hostname: '主机名',
os: '系统',
arch: '架构',
kernel: '内核',
cpuCores: 'CPU 核数',
uptime: '运行时长',
unknown: '未知',
},
}
+316
View File
@@ -0,0 +1,316 @@
// logs 命名空间 — 日志查询页(/logs)中文文案
export default {
nav: {
aria: '日志中心导航',
},
tabs: {
query: 'Loki 查询',
access: '查询授权',
sources: '数据源管理',
descQuery: '按日志流和时间范围检索;拖动时间线缩小范围,点日志查看前后文。',
descAccess: '用策略组复用日志范围,并一次关联多个用户。',
descSources: '配置 Loki 连接与认证方式。',
},
toolbar: {
run: '查询',
running: '查询中…',
tail: 'Tail',
tailStop: '停止 Tail',
sourceAria: '数据源',
sourceEmptyOption: '暂无可用数据源',
limitLabel: '每页条数',
limitHint: '每页最多 1000 条;翻页只保留当前页。大范围先看时间线,再拖选高峰时段。',
rangeInvalid: '请选择有效的起止时间',
queryFailed: '查询失败',
},
source: {
loadFailed: '加载数据源失败',
missingTitle: '还没有可用的 Loki 数据源',
missingBody:
'请先到「数据源管理」关联 Loki:集群内可直接选「集群服务」(命名空间 / Service / 端口,无需 Ingress);跨集群或公网查询,请先给 Loki 配置 Ingress 域名,再用「URL 直连」填写该域名。未关联数据源无法查询。',
goConfigure: '去配置数据源',
sectionAria: '数据源管理',
heading: 'Loki 数据源',
hint: '提示:Loki 部署在集群内时,可直接添加「集群服务」数据源(无需 Ingress);跨集群 / 公网查询需先为 Loki 配置 Ingress 域名,再用「URL 直连」关联。未关联数据源时,查询页无法使用。',
managedCluster: '集群 {{name}}',
managedBadge: '平台部署 · 随部署管理',
add: '+ 手动添加',
nameLabel: '名称',
targetUrl: 'URL 直连',
targetClusterSvc: '集群服务',
addressLabel: 'Loki 地址',
clusterLabel: '集群',
clusterPlaceholder: '选择集群…',
namespaceLabel: '命名空间',
serviceLabel: 'Service',
portLabel: '端口',
authLabel: '认证方式',
authNone: '无认证',
authBasic: 'Basic Auth',
authBearer: 'Bearer Token',
authHeaders: '自定义 Headers',
usernameLabel: '用户名',
passwordLabel: '密码',
tokenLabel: '令牌',
headersLabel: '自定义 Headers(JSON 对象)',
headersInvalid: 'Headers 必须是字符串键值 JSON 对象',
skipTLS: '跳过 TLS 校验',
setDefault: '设为默认',
test: '测试连接',
save: '保存数据源',
saveFailed: '保存失败',
remove: '删除',
},
range: {
lastMinutes: '最近 {{count}} 分钟',
lastHours: '最近 {{count}} 小时',
custom: '自定义时间',
title: '选择时间范围',
start: '开始时间',
end: '结束时间',
apply: '应用时间',
recent: '最近使用',
},
editor: {
aria: 'LogQL 查询语句',
valuesOf: '{{label}} 的值',
quickTab: '快捷 LogQL',
historyTab: '查询历史',
historyTabCount: '查询历史 ({{count}})',
escClose: 'Esc 关闭',
historyEmpty: '运行查询后,常用 LogQL 会出现在这里',
},
templates: {
aria: '快捷查询模板',
title: '快捷生成 LogQL',
hint: '先选 namespace,再选 app,最后选副本 pod;app 候选只来自所选命名空间。',
namespace: '命名空间',
app: 'App',
appWithKey: 'App({{key}})',
pod: '副本 / Pod',
selectCount: '请选择(共 {{count}} 个)',
noApp: '该范围内无 app',
pickNsFirst: '先选命名空间可缩小范围',
noPod: '该 app 下无副本',
optional: '可选',
scopeLogs: '查询该范围日志',
errorLogs: '错误 error',
warnLogs: '告警 warn',
podLogs: '指定副本日志',
ingressLogs: 'Ingress 访问日志',
scopeHint: '先选 namespace,再选 app 或 pod,也可在 Label 浏览器选值',
},
labels: {
browser: 'Label 浏览器',
selectedCount: '已选 {{count}}',
loadingLabels: '加载标签中…',
noLabels: '暂无可用标签',
pickHint: '选择左侧标签,再搜索对应的值',
valuesOf: '{{label}} 的值',
searchAria: '搜索 {{label}} 的值',
searchPlaceholder: '搜索或输入标签值',
scoped: '按当前查询条件缩小候选',
loading: '加载中…',
noValues: '没有匹配值',
useAsValue: '使用“{{value}}”作为值',
hint: '先选 namespace 可查该命名空间全部副本;再选 app / service_name 可缩小到某个服务(无 app 标签的工作负载用 namespace 即可)。候选来自当前时间范围内已写入 Loki 的日志;授权范围仍由服务端限制。',
},
volume: {
statsLoading: '正在统计整个时间范围的日志量,日志页可继续浏览。',
statsError: '日志量统计超时或失败;日志页仍可使用。可缩小时间范围后重新查询。',
histogramLoading: '统计中…',
histogramEmpty: '暂无日志量数据',
timelineAria: '日志量时间线',
timelineTitle: '日志量时间线',
timelineHint: '悬停查看数量,按住任意高度拖选时间;选择后自动查询。',
timelineTotal: '时间桶合计 {{count}} 条',
timelineBucket: '{{count}} 条',
},
results: {
aria: '日志结果',
title: '日志结果',
summary: '第 {{page}} 页 · 本页 {{count}} 条',
summaryMore: '第 {{page}} 页 · 本页 {{count}} 条 · 还有更多',
viewAria: '日志展示方式',
viewRaw: '原始日志',
viewTable: '表格',
viewLine: '折线图',
viewStream: '流图',
viewPie: '饼图',
loadingPage: '正在加载日志页',
loadingQuery: '正在查询 Loki',
loadingWait: '已等待 {{seconds}} 秒。大范围查询可能需要较长时间。',
pagingHint: '日志按时间从新到旧翻页;图表展示当前页,顶部时间线展示整个查询范围。',
domainFilter: '域名筛选',
domainPlaceholder: '输入或选择域名',
domainQuery: '在 Loki 中查询',
domainHint: '仅匹配日志记录的请求 Host。当前 {{shown}} / {{total}} 条。',
domainNoMatch: '当前域名筛选没有匹配的日志',
filterLabel: '筛选此标签',
showContext: '查看前后各 5 条',
prevPage: '上一页',
nextPage: '下一页',
pageOf: '第 {{page}} 页',
noSourceTitle: '暂无可查询的数据源',
noSourceHint: '请联系管理员添加或授权数据源',
noMatchTitle: '未找到匹配的日志记录',
noMatchHint: '当前时间范围和筛选条件下没有匹配结果',
idleTitle: '输入 LogQL 并运行查询',
idleHint: '也可以使用 Label 浏览器快速构建查询',
retry: '重新查询',
},
views: {
noDomain: '未记录域名',
otherDomain: '其他域名',
unknown: '未知',
untagged: '未标记',
other: '其他',
tableLoaded: '当前页已加载 {{count}} 条日志。',
tableEmpty: '暂无可展示的日志',
thTime: '时间',
thNsPod: 'namespace / pod',
thLevel: '级别',
thLog: '日志',
lineByLoaded: '按当前已加载日志统计;更多结果请继续加载。',
lineByRange: '按查询时间范围统计的日志量,来自 Loki 时间聚合。',
lineGroupAria: '折线图分组字段',
lineByDomain: '按域名',
lineByTotal: '总量',
lineEmpty: '当前查询没有可用的时间统计',
lineSeriesTotal: '日志条数',
streamParsed: '展示 {{count}} 条已解析日志',
streamByLevel: '按级别展示当前已加载的 {{count}} 条日志',
streamSuffix: ',基于当前已加载结果。',
streamGroupAria: '流图分组字段',
streamByStatus: '按 HTTP 状态段',
streamEmpty: '暂无可展示的日志流',
pieByAccess: '按访问字段统计已解析的 {{count}} 条日志',
pieByLabel: '按标签统计当前已加载的 {{count}} 条日志',
pieGroupAria: '饼图分组字段',
pieDomain: '域名(请求 Host)',
pieStatus: 'HTTP 状态码',
pieMethod: '请求方法',
piePath: '请求路径',
pieUpstream: '上游服务',
pieLevel: '级别',
pieEmpty: '暂无可展示的标签分布',
},
ingress: {
modesAria: 'Ingress 统计方式',
modeTraffic: '接口 PV / UV',
modeErrors: '异常 Top 10',
modeSlow: '慢请求 Top 10',
modeDetail: '请求明细',
summary:
'仅统计当前页已加载的 {{loaded}} 条日志,其中 {{parsed}} 条为 ingress-nginx 访问日志。翻页后重新统计;UV 按非空客户端 IP 去重。',
statPv: 'PV',
statUv: 'UV(IP)',
statErrors: '4xx / 5xx',
statSlow: '慢请求 ≥ {{threshold}}s',
errorClassAria: '异常状态码范围',
errorAll: '全部 4xx / 5xx',
error5xx: '只看 5xx',
error4xx: '只看 4xx',
slowThreshold: '慢请求阈值',
seconds: '{{count}} 秒',
slowHint: '依据 access log 的 request_time 排序;408 / 499 / 504 仅作超时线索,不能单凭状态码断定后端超时。',
slowMissing: '{{count}} 条缺少有效耗时。',
trafficHint: '访问成功率按 2xx / 3xx 请求数 ÷ PV 计算;路径已去掉查询参数。',
thHost: '请求 Host',
thPath: '接口路径',
thSuccess: '成功率',
thErrorRate: '异常率',
thStatus: '状态码',
thSlow: '慢请求',
thMaxLatency: '最长耗时',
thAvgLatency: '平均耗时',
thTimeout: '408 / 499 / 504',
thClientIp: '客户端 IP',
thStatusShort: '状态',
thLatency: '耗时',
thTime: '时间',
empty: '当前页没有符合条件的请求',
noDomain: '未记录 Host',
},
context: {
title: '同一日志流的前后文',
sameFile: '同一文件',
sameStream: '同一日志流',
meta: '前后各最多 5 条 · 前后 5 分钟',
file: '文件: {{name}}',
current: '当前日志',
currentAria: '当前日志',
beforeAria: '之前的日志',
afterAria: '之后的日志',
beforeTitle: '之前 · {{count}} 条',
afterTitle: '之后 · {{count}} 条',
beforeEmpty: '此前 5 分钟内无日志',
afterEmpty: '此后 5 分钟内无日志',
loadFailed: '读取上下文失败',
},
access: {
loadFailed: '加载查询策略失败',
validate: '请填写策略组名称,选择数据源,并选择「所有人」或至少一位用户',
saveFailed: '保存策略组失败',
deleteFailed: '删除策略组失败',
sourceRef: '数据源 #{{id}}',
listTitle: '查询策略组',
create: '新建',
listHint: '一套日志范围可关联多个用户,后续修改会同时生效。',
loading: '加载中…',
empty: '还没有策略组',
anyone: '所有人',
userCount: '{{count}} 位用户',
editTitle: '编辑策略组',
createTitle: '新建策略组',
formHint: '选择数据源及允许的日志范围,再关联需要复用此策略的用户。',
remove: '删除',
nameLabel: '策略组名称',
namePlaceholder: '例如:生产环境 Agent 日志',
sourceLabel: '数据源',
sourceEmpty: '请先添加数据源',
scopeTitle: '允许查询的日志范围',
ownerFallback: '策略组',
scopeHint: '可按命名空间、App、副本 / Pod 精确限制。未选择的标签不作限制。',
usersTitle: '关联用户',
selectedAnyone: '已选 所有人(anyone)',
selectedCount: '已选 {{count}} 位',
anyoneToggle: '所有人(anyone)',
anyoneActive: '✓ 所有人(anyone)',
selectFiltered: '选择当前筛选',
clear: '清空',
usersHint: '同一用户在同一数据源只能属于一个策略组;已在其他组的用户会置灰。',
searchAria: '搜索用户',
searchPlaceholder: '搜索用户名或昵称',
grouped: '已分组',
noMatch: '没有匹配用户',
saving: '保存中…',
save: '保存策略组',
deleteTitle: '删除查询策略组',
deleteDesc: '删除「{{name}}」后,关联用户将失去由该组授予的数据源访问权限。',
ruleAria: '{{name}} 查询范围',
nsLabel: '命名空间',
appLabel: 'App',
podLabel: '副本 / Pod',
},
select: {
placeholder: '搜索并选择',
any: '不限',
loading: '加载中…',
noMatch: '无匹配值',
},
raw: {
searching: '查询中…',
count: '{{count}} 条',
dedupSuffix: '(去重后,原 {{count}} 条)',
dedup: '去重',
empty: '没有匹配的日志',
original: '日志原文',
labelsTitle: '标签(点击过滤)',
filterValue: '过滤此值',
excludeValue: '排除此值',
fields: '提取字段',
loadMore: '加载更多',
loadingMore: '加载中…',
},
}
+141
View File
@@ -0,0 +1,141 @@
// settings 命名空间 — 系统设置页(通用配置/密码/关于、告警通知、LDAP 接入、镜像源)中文文案
// 注意:主题卡片与语言卡片的标题、ThemeSection 内文案由其他模块负责,不在本词典内。
export default {
title: '系统设置',
description: '通用配置、告警通知、LDAP 接入与默认镜像源',
tabs: {
general: '通用配置',
notify: '告警通知',
ldap: 'LDAP 接入',
registry: '默认镜像源',
},
// 跨小节复用的操作文案
actions: {
cancel: '取消',
saving: '保存中...',
testing: '测试中...',
},
password: {
title: '修改密码',
oldLabel: '当前密码',
oldPlaceholder: '请输入当前密码',
newLabel: '新密码',
newPlaceholder: '请输入新密码(至少 6 位)',
confirmLabel: '确认新密码',
confirmPlaceholder: '请再次输入新密码',
submit: '修改密码',
submitting: '修改中...',
errMismatch: '两次输入的密码不一致',
errTooShort: '新密码至少需要 6 位',
errFailed: '密码修改失败',
},
about: {
title: '关于 lokistack',
versionLabel: '版本',
backendLabel: '后端',
frontendLabel: '前端',
k8sLabel: 'Kubernetes 接入',
summaryLabel: '简介',
backendValue: 'Go + Gin + JSON 文件存储',
frontendValue: 'React 19 + TypeScript + Tailwind CSS',
k8sValue: 'client-go 直连 API Server',
summaryValue: '轻量级 Loki 日志管理平台',
},
notify: {
cardTitle: '告警通知通道(飞书/Lark)',
create: '新建通道',
// 空态文案按「前段 + 链接 + 后段」拼接(链接指向日志告警页)
emptyBefore: '尚无通知通道 —— 每个告警群创建一个通道(如「应用告警群」「Nginx 告警群」),在',
emptyLink: '日志告警',
emptyAfter: '页把规则关联到对应通道',
webhookConfigured: 'Webhook 已配置',
webhookMissing: '未配置',
secretConfigured: ' · 签名已配置',
testAria: '测试 {{name}}',
editAria: '编辑 {{name}}',
deleteAria: '删除 {{name}}',
editTitle: '编辑通道 · {{name}}',
createTitle: '新建通知通道',
nameLabel: '通道名称 *',
namePlaceholder: '如:应用告警群 / Nginx 告警群',
webhookLabel: 'Webhook 地址',
webhookKeepHint: '(留空保持不变)',
webhookSavedPlaceholder: '已保存,输入新地址以修改',
signingLabel: '签名校验密钥(可选)',
signingSavedPlaceholder: '已保存,留空保持不变',
signingPlaceholder: '飞书机器人「签名校验」密钥',
enable: '启用通道',
saveChanges: '保存修改',
createChannel: '创建通道',
deleteTitle: '确认删除通知通道',
testSent: '测试卡片已发送到「{{name}}」',
errLoad: '加载通知通道失败',
errNameRequired: '请填写通道名称',
errWebhookRequired: '新建通道需要填写 Webhook 地址',
errSave: '保存失败',
errSend: '发送失败',
errToggle: '切换失败',
errDelete: '删除失败',
},
ldap: {
cardTitle: 'LDAP 接入',
headerHint: '启用后支持 LDAP 登录与批量导入',
errLoad: '加载 LDAP 配置失败',
disabledHint:
'LDAP 未启用 —— 打开开关填写目录服务信息(服务器 / Bind DN / Base DN),保存后用户管理页将出现「从 LDAP 导入」',
hostLabel: '服务器地址 *',
portLabel: '端口',
ldapsLabel: 'LDAPS',
bindDnLabel: 'Bind DN *',
bindPasswordLabel: 'Bind 密码 *',
bindPasswordSavedPlaceholder: '已保存,输入新密码以修改',
bindPasswordPlaceholder: '管理员密码',
baseDnLabel: 'Base DN *',
userFilterLabel: '用户过滤器',
usernameLabel: '用户名',
nameLabel: '姓名',
emailLabel: '邮箱',
noEmail: '无邮箱',
testButton: '测试连接',
saveButton: '保存配置',
errMissingFields: '请先填写服务器地址 / Bind DN / Base DN',
errTest: '测试失败',
savedToast: 'LDAP 配置已保存',
},
registry: {
cardTitle: '镜像仓库源',
modeOverseasLabel: '海外官方',
modeOverseasDesc: 'docker.io,海外网络环境',
modeCnLabel: '国内加速',
modeCnDesc: 'docker.m.daocloud.io 镜像,国内环境防被墙',
modeCustomLabel: '私有仓库',
modeCustomDesc: '自有 registry 前缀(如华为云 SWR)',
errLoad: '加载镜像源配置失败',
savedToast: '镜像源已保存,后续部署/采集将使用新镜像地址',
customPrefixLabel: '私有仓库前缀 *',
customPrefixHint: '平台会在镜像名前拼接此前缀,如 {{example}}/grafana/loki:3.4.0',
imagesLabel: '各服务镜像(可单独覆盖仓库 / Tag)',
tagFollowVersion: '跟随部署版本',
repoAria: '{{label}} 镜像仓库',
tagAria: '{{label}} 镜像 Tag',
// 镜像说明按「前段 + <code>grafana/loki</code> + 后段」拼接
imagesHintBefore: '仓库填镜像路径(如 ',
imagesHintAfter: ');Tag 留空时 Loki / Grafana 跟随部署版本,Promtail / Fluent Bit 用内置默认。',
previewTitle: '生效镜像预览',
saveButton: '保存镜像源',
},
themeCard: {
title: '主题',
light: '浅色',
dark: '深色',
system: '跟随系统',
auto: '定时切换',
timezone: '判定时区',
timezoneAria: '主题定时切换时区',
browser: '跟随浏览器(本地)',
autoHint: '按所选时区,{{start}}:00–{{end}}:00 使用浅色,其余时段深色;顶栏太阳/月亮可随时手动覆盖。',
},
prefsCard: {
title: '外观与语言',
},
}
+53
View File
@@ -0,0 +1,53 @@
// storage 命名空间 — 存储配置页中文文案(页面编排/卡片/表单弹窗)
export default {
page: {
title: '存储配置',
description: '为 Loki 配置对象存储后端',
add: '添加存储',
emptyTitle: '暂无存储配置',
emptyDescription: '添加第一个 Loki 存储后端',
// 删除确认文案(有部署引用时的警告;count 走中文无复数)
deleteWarning:
'确定要删除「{{name}}」吗?警告:仍有 {{count}} 个部署使用该存储,删除后这些部署的对象存储配置将失效。',
errDelete: '删除存储配置失败',
errSetDefault: '设置默认存储失败',
},
card: {
default: '默认',
endpoint: '访问地址',
pathPrefix: '路径前缀',
none: '无',
connection: '连接',
insecureHttp: 'HTTP(不加密)',
setDefault: '设为默认',
editAria: '编辑存储配置 {{name}}',
deleteAria: '删除存储配置 {{name}}',
},
form: {
addTitle: '添加存储配置',
editTitle: '编辑存储配置',
name: '名称 *',
storageType: '存储类型',
endpoint: '访问地址',
bucket: 'Bucket *',
region: '区域',
secretPlaceholder: '请输入 Secret Key',
secretPlaceholderSaved: '已保存 · 留空则不修改',
insecure: '不加密 (HTTP)',
setDefault: '设为默认',
autoCreate: '测试时桶不存在则自动创建',
test: '测试连接',
testing: '测试中...',
add: '添加存储',
adding: '添加中...',
save: '保存修改',
saving: '保存中...',
testHintSaved: '请先填写 Bucket 与 Access Key(Secret Key 已保存,留空即可)',
testHintMissing: '请先填写 Bucket 与 Access Key / Secret Key',
errSubmit: '创建存储配置失败',
errTest: '测试连接失败',
// zod 校验消息(schema 工厂注入 t)
nameRequired: '请输入存储配置名称',
bucketRequired: '请填写 Bucket 名称',
},
}
+85
View File
@@ -0,0 +1,85 @@
// users 命名空间 — 用户管理域中文文案(页头 / 角色与来源徽章 / 创建·重置密码 / LDAP 导入)
export default {
title: '用户管理',
subtitle: '管理平台登录账号(仅管理员)',
create: '创建用户',
ldapImport: '从 LDAP 导入',
emptyTitle: '暂无用户',
emptyDesc: '创建第一个用户账号',
listTitle: '用户列表(共 {{count}} 个)',
currentUser: '(当前登录)',
noNickname: '未设置昵称',
createdAt: '创建于 {{date}}',
resetPassword: '重置密码',
resetDesc: '重置「{{name}}」的密码后,该用户的所有登录会话将立即失效。',
newPassword: '新密码 *',
randomPassword: '随机生成密码',
resetting: '重置中...',
resetDone: '已重置「{{name}}」的密码,其所有会话已失效',
resetFailed: '重置密码失败',
deleteAria: '删除用户 {{name}}',
deleteFailed: '删除用户失败',
cannotDeleteSelf: '不能删除当前登录账号',
confirmDeleteTitle: '确认删除用户',
createFailed: '创建用户失败',
adminOnly: {
title: '需要管理员权限',
desc: '用户管理仅管理员可用,请联系管理员处理账号事宜',
},
role: {
admin: '管理员',
user: '普通用户',
},
source: {
ldap: 'LDAP 导入',
local: '本地账号',
},
form: {
username: '用户名 *',
usernamePlaceholder: '登录用户名',
usernameMin: '用户名至少 2 个字符',
usernameMax: '用户名最多 32 个字符',
nickname: '昵称',
nicknamePlaceholder: '显示昵称(可选)',
password: '初始密码 *',
passwordPlaceholder: '至少 6 位',
passwordMin: '密码至少需要 6 位',
role: '角色 *',
rolePlaceholder: '选择角色',
submitting: '创建中...',
},
ldap: {
title: '从 LDAP 导入用户',
desc: '来自 LDAP 目录的用户;导入后使用 LDAP 密码登录,角色为普通用户',
added: '新增 {{count}}',
converted: '转为 LDAP {{count}}',
skipped: '跳过 {{count}}',
failedCount: '失败 {{count}}',
continue: '继续导入',
done: '完成',
loading: '加载 LDAP 用户…',
summary: '共 {{total}} 个用户,已选 {{selected}} 个',
errorHintPre: '请到',
errorHintPost: '检查 LDAP 配置与启用状态',
imported: '已导入',
willConvert: '本地同名,将转为 LDAP',
noName: '无姓名',
noEmail: '无邮箱',
empty: '目录下没有用户',
importing: '导入中...',
importCount_other: '导入 {{count}} 个用户',
importFailed: '导入失败',
loadFailed: '加载 LDAP 用户失败',
},
selectAria: '选择用户 {{name}}',
batch: {
selected: '已选 {{count}} 项',
delete: '批量删除',
clear: '清除选择',
selectAll: '全选',
confirmTitle: '确认批量删除',
confirmDesc: '确定要删除选中的 {{count}} 个用户吗?受保护的账号(自己/最后一个管理员)将被跳过,此操作不可恢复。',
skippedWarn: '{{count}} 项被跳过:{{reasons}}',
failed: '批量删除失败',
},
}
+70
View File
@@ -0,0 +1,70 @@
// wizard 命名空间 — 「部署向导」页面域中文文案。
// 注册说明:与 collectors 命名空间一致,页面域词典在被页面组件副作用导入时
// 通过 addResourceBundle 自行挂载(不动 src/i18n/index.ts)。
const wizard = {
page: {
title: '部署向导',
description: '四步完成日志平台:环境 → 存储 → 部署 → 采集(步骤可按任意顺序完成)',
loadFailed: '加载向导状态失败',
},
steps: {
flow: '部署流程',
doneAria: '{{title}} 已完成',
todoAria: '{{title}} 待完成',
env: { title: '环境与目标', desc: 'Kubernetes 集群或裸金属主机' },
storage: { title: '准备存储', desc: '对象存储或本地磁盘' },
deploy: { title: '部署 Loki', desc: '日志查询平台内置' },
collect: { title: '配置采集', desc: '主机文件 / 集群 DaemonSet' },
},
// 步骤地图下方的进行提示(按第一个未完成步骤推导)
hint: {
env: '先选择部署环境并接入集群或主机',
storage: '环境已就绪,建议先配置对象存储',
deploy: '目标与存储已就绪,创建 Loki 部署吧',
collect: 'Loki 已运行,添加采集策略开始收集日志',
all: '全部就绪:日志正在收集,可到日志查询页分析',
},
env: {
k8sTitle: 'Kubernetes 集群',
k8sDesc: '通过 Kubernetes API 部署 Loki StatefulSet,DaemonSet 采集容器日志',
hostTitle: '裸金属主机',
hostDesc: '以主机 Docker 部署 Loki,采集指定日志文件',
ready: '已就绪',
connected: '已接入:{{names}}',
nameSep: '、',
k8sHint: '通过 kubeconfig 或 ServiceAccount Token 接入集群,创建后自动验证连通性',
addCluster: '添加集群',
hostHint: 'SSH 密钥或密码接入主机(用于部署与后续采集器安装)',
addHost: '添加主机',
pickFirst: '↑ 选择部署环境后,此处提供对应的目标接入表单',
},
storage: {
defaultLabel: '默认存储:',
none: '尚未配置存储 —— 可选本地 MinIO / RustFS,或 AWS / 阿里云对象存储',
manage: '管理存储',
add: '添加存储',
configure: '配置存储',
hint: '推荐对象存储(MinIO / RustFS / S3 / OSS):多副本与数据保留更可靠。添加时可用「测试连接」验证凭证,并自动创建不存在的桶;跳过则使用本地磁盘。',
},
deploy: {
meta: '{{mode}} · 命名空间 {{namespace}} · {{endpoint}}',
endpointPending: 'endpoint 待部署完成生成',
openGrafana: '打开 Grafana({{url}} · admin/admin)',
openLoki: '打开 Loki',
details: '部署详情',
none: '尚无运行中的 Loki —— 选择 Kubernetes 或主机 Docker 部署',
create: '创建部署',
triggering: '正在触发部署…',
deployTriggered: '已触发「{{name}}」部署,可在部署页查看日志与进度',
},
collect: {
title: '日志采集策略',
activeCount: '{{count}} 个采集中',
desc: '主机:指定日志文件路径 · 集群:DaemonSet 全量容器日志(部署到关联 Loki 的命名空间)',
add: '添加采集策略',
manage: '管理采集',
},
}
export default wizard
+11 -11
View File
@@ -36,14 +36,14 @@
--input: 214 25% 84%;
--ring: 161 94% 30%;
--radius: 0.625rem;
--sidebar-background: 222 47% 11%;
--sidebar-foreground: 215 20% 65%;
--sidebar-primary: 213 94% 72%;
--sidebar-primary-foreground: 222 47% 11%;
--sidebar-accent: 217 33% 17%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217 33% 20%;
--sidebar-ring: 213 94% 72%;
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 222 25% 25%;
--sidebar-primary: 161 94% 30%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 210 40% 94%;
--sidebar-accent-foreground: 222 47% 11%;
--sidebar-border: 214 32% 91%;
--sidebar-ring: 161 94% 30%;
}
.dark {
@@ -74,12 +74,12 @@
--ring: 160 84% 39%;
--sidebar-background: 222 47% 8%;
--sidebar-foreground: 215 20% 65%;
--sidebar-primary: 213 94% 72%;
--sidebar-primary-foreground: 222 47% 11%;
--sidebar-primary: 160 84% 39%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217 33% 17%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217 33% 20%;
--sidebar-ring: 213 94% 72%;
--sidebar-ring: 160 84% 39%;
}
}
+18 -4
View File
@@ -1,11 +1,13 @@
// 凭证存储统一出口 — 全项目对 token / 用户信息的读写必须经过这里,
// 禁止组件直接操作 localStorage(避免 key 散落与多处重复实现)。
// key 采用命名空间 opsxloki.auth.*(双端一致性约定,见开发标准 08 章)。
// key 采用命名空间 lokistack.auth.*(双端一致性约定,见开发标准 08 章)。
import type { User } from '@/types';
const TOKEN_KEY = 'opsxloki.auth.token';
const REFRESH_TOKEN_KEY = 'opsxloki.auth.refresh-token';
const USER_KEY = 'opsxloki.auth.user';
const TOKEN_KEY = 'lokistack.auth.token';
const REFRESH_TOKEN_KEY = 'lokistack.auth.refresh-token';
const USER_KEY = 'lokistack.auth.user';
// 「记住用户名」不属于凭证,clearAuth() 登出时保留
const REMEMBER_USERNAME_KEY = 'lokistack.auth.remember-username';
export interface TokenPair {
accessToken: string;
@@ -51,3 +53,15 @@ export function clearAuth(): void {
export function isLoggedIn(): boolean {
return getAccessToken() !== null;
}
export function getRememberedUsername(): string | null {
return localStorage.getItem(REMEMBER_USERNAME_KEY);
}
export function saveRememberedUsername(username: string | null): void {
if (username) {
localStorage.setItem(REMEMBER_USERNAME_KEY, username);
} else {
localStorage.removeItem(REMEMBER_USERNAME_KEY);
}
}
+79
View File
@@ -0,0 +1,79 @@
// 主题定时切换 — 「定时」模式按所选时区的钟点决定浅/深色(白天浅色、夜间深色)。
// 存储独立于 next-themes 的主题 key:自动模式下由调度器持续把解析结果写入主题偏好。
const AUTO_KEY = 'lokistack.theme.auto'
const TZ_KEY = 'lokistack.theme.timezone'
/** 同窗口内通知调度器状态变化(storage 事件不回弹到本窗口) */
const CHANGE_EVENT = 'lokistack:theme-schedule-change'
/** 白天区间:[DAY_START_HOUR, DAY_END_HOUR) 为浅色,其余深色 */
export const DAY_START_HOUR = 7
export const DAY_END_HOUR = 19
export function isThemeAuto(): boolean {
return localStorage.getItem(AUTO_KEY) === '1'
}
export function setThemeAuto(on: boolean): void {
if (on) localStorage.setItem(AUTO_KEY, '1')
else localStorage.removeItem(AUTO_KEY)
window.dispatchEvent(new Event(CHANGE_EVENT))
}
/** 时区偏好;空串 = 跟随浏览器本地时区 */
export function getThemeTimezone(): string {
return localStorage.getItem(TZ_KEY) ?? ''
}
export function setThemeTimezone(tz: string): void {
if (tz) localStorage.setItem(TZ_KEY, tz)
else localStorage.removeItem(TZ_KEY)
window.dispatchEvent(new Event(CHANGE_EVENT))
}
/** 订阅调度器偏好变化(同窗口;跨窗口由调用方另行监听 storage 事件) */
export function onThemeScheduleChange(handler: () => void): () => void {
window.addEventListener(CHANGE_EVENT, handler)
return () => window.removeEventListener(CHANGE_EVENT, handler)
}
/** 取某时区的当前钟点(0-23);en-US + hour12:false 在个别引擎会把午夜返回 "24",取模兜底 */
export function hourInTimezone(tz: string, now = new Date()): number {
const opts: Intl.DateTimeFormatOptions = { hour: '2-digit', hour12: false }
if (tz) opts.timeZone = tz
const hour = Number(now.toLocaleString('en-US', opts).match(/\d+/)?.[0] ?? now.getHours())
return hour % 24
}
/** 按时区判定此刻应为浅色还是深色 */
export function resolveScheduledTheme(tz: string, now = new Date()): 'light' | 'dark' {
const hour = hourInTimezone(tz, now)
return hour >= DAY_START_HOUR && hour < DAY_END_HOUR ? 'light' : 'dark'
}
/** 浏览器支持的 IANA 时区列表;不支持时回退到常用时区 */
export function listTimezones(): string[] {
try {
const supported = (Intl as unknown as { supportedValuesOf?: (key: string) => string[] }).supportedValuesOf
if (typeof supported === 'function') return supported('timeZone')
} catch {
// ignore
}
return [
'UTC',
'Asia/Shanghai',
'Asia/Hong_Kong',
'Asia/Taipei',
'Asia/Tokyo',
'Asia/Singapore',
'Asia/Kolkata',
'Asia/Dubai',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Europe/Moscow',
'America/New_York',
'America/Chicago',
'America/Los_Angeles',
'Australia/Sydney',
]
}
+36 -9
View File
@@ -3,16 +3,25 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import { ThemeProvider } from 'next-themes'
import './index.css'
import '@/i18n'
// 一次性迁移旧 LokiDeploy 时代的 localStorage key → opsxloki.*(保留登录态/主题/折叠/查询历史)
// 一次性迁移历史 localStorage key → lokistack.*(保留登录态/主题/折叠/查询历史)。
// 「读旧写新」保留一个迁移版本(开发标准 08 章),之后可移除旧 key 条目。
function migrateStorage() {
// 新命名空间的来源按时代从新到旧排列:同时存在时,较近期的偏好优先占位
const pairs: Array<[string, string]> = [
['lokideploy.auth.token', 'opsxloki.auth.token'],
['lokideploy.auth.refresh-token', 'opsxloki.auth.refresh-token'],
['lokideploy.auth.user', 'opsxloki.auth.user'],
['lokideploy.theme.mode', 'opsxloki.theme.mode'],
['lokideploy.layout.collapsed', 'opsxloki.layout.collapsed'],
['lokideploy_log_query_history', 'opsxloki_log_query_history'],
['opsxloki.auth.token', 'lokistack.auth.token'],
['opsxloki.auth.refresh-token', 'lokistack.auth.refresh-token'],
['opsxloki.auth.user', 'lokistack.auth.user'],
['opsxloki.auth.remember-username', 'lokistack.auth.remember-username'],
['opsxloki.theme.mode', 'lokistack.theme.mode'],
['opsxloki.layout.collapsed', 'lokistack.layout.collapsed'],
['opsxloki.chunk-reloaded', 'lokistack.chunk-reloaded'],
['lokideploy.auth.token', 'lokistack.auth.token'],
['lokideploy.auth.refresh-token', 'lokistack.auth.refresh-token'],
['lokideploy.auth.user', 'lokistack.auth.user'],
['lokideploy.theme.mode', 'lokistack.theme.mode'],
['lokideploy.layout.collapsed', 'lokistack.layout.collapsed'],
]
for (const [oldKey, newKey] of pairs) {
const v = localStorage.getItem(oldKey)
@@ -21,6 +30,24 @@ function migrateStorage() {
localStorage.removeItem(oldKey)
}
}
// 按用户 id 拼接的 key(查询历史/时间范围历史)按前缀整体迁移
const LEGACY_LOGS_PREFIX = 'opsxloki.logs.'
for (let i = localStorage.length - 1; i >= 0; i--) {
const oldKey = localStorage.key(i)
if (!oldKey?.startsWith(LEGACY_LOGS_PREFIX)) continue
const newKey = `lokistack.logs.${oldKey.slice(LEGACY_LOGS_PREFIX.length)}`
if (localStorage.getItem(newKey) === null) {
localStorage.setItem(newKey, localStorage.getItem(oldKey) as string)
}
localStorage.removeItem(oldKey)
}
// 收尾清扫:迁移完成后,残留的旧命名空间 key(多时代并存时的失势重复项)已无人读取,统一移除
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i)
if (key?.startsWith('opsxloki.') || key?.startsWith('lokideploy.')) {
localStorage.removeItem(key)
}
}
}
migrateStorage()
import App from './App.tsx'
@@ -33,8 +60,8 @@ createRoot(document.getElementById('root')!).render(
<ErrorBoundary>
<ThemeProvider
attribute="class"
defaultTheme="system"
storageKey="opsxloki.theme.mode"
defaultTheme="light"
storageKey="lokistack.theme.mode"
enableSystem
disableTransitionOnChange
>
+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>
Loaded 100 of 201 files, more files were not shown because too many files have changed in this diff. Show more