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