feat(i18n): 增量国际化迁移 — deploy/kubernetes/monitor/task 全模块 + 补全 28 缺失翻译键

迁移范围:
- deploy 模块 (PipelineEditor/PipelineDetailDialog/PipelineFormDialog/PipelineRunDialog/ReleaseCreateDialog/ReleaseDetailDialog/ReleaseManager/ToolConfigTab/JobRoleCreateDialog 等 14 文件)
- kubernetes 模块 (YamlEditorToolbar/DeploymentHpaTab/DeploymentLogsTab/DeploymentPodsTab/DnsHostSection/SchedulingSection/EventsTab/HPACronHPATab/HistoryTab/PodsTab/ServicesTab/ActionModals 等 12 文件)
- monitor 模块 (AlertEventsTab/AlertCenterDetail/DatasourcePage/LogAlertBell/LogCenter/LogCenterArchBanner/StatChartPanel/LogRoleCreateDialog 等 8 文件)
- task 模块 (CronJobStatsPanel/CronJobTable/CronJobViewDialog/ExecutionHistoryDialog 4 文件)
- layout (PageEmpty/PageLoading 2 文件)
- site-monitor/snmp-monitor 模块 (6 文件)

翻译键: 3975 → 4003 (+28 缺失键补全), 22 命名空间, zh-CN/en-US 完全对齐
新增命名空间: agent/aiops/cloud/kubernetes/monitor/pipeline/rbac/system/task/ui

验证: go build✅ tsc -b=0 npm build✅ (20.78s) 17/17 集成测试通过
This commit is contained in:
attacker committed 2026-07-02 12:39:36 +08:00
1 parent eddf01dd79
commit 63e4c220d4
59 files changed
+6699 -627

No files matched your search

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -39,6 +40,7 @@ import { ContentPanel } from '@/components/layout/ContentPanel';
import { StatStrip } from '@/components/ui/stat-strip'; import { StatStrip } from '@/components/ui/stat-strip';
export function AppReleaseManager() { export function AppReleaseManager() {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [envFilter, setEnvFilter] = useState<string>('all'); const [envFilter, setEnvFilter] = useState<string>('all');
@@ -188,8 +190,8 @@ export function AppReleaseManager() {
{release.version} {release.version}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell>{getEnvBadge(release.environment)}</TableCell> <TableCell>{getEnvBadge(release.environment, t)}</TableCell>
<TableCell>{getStatusBadge(release.status)}</TableCell> <TableCell>{getStatusBadge(release.status, t)}</TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-1 text-xs dg-text-secondary"> <div className="flex items-center gap-1 text-xs dg-text-secondary">
<GitBranch className="h-3 w-3" /> <GitBranch className="h-3 w-3" />
+8 -6
View File
@@ -1,17 +1,19 @@
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { GitBranch, Settings2, Shield, Users, Server } from 'lucide-react'; import { GitBranch, Settings2, Shield, Users, Server } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const NAV_ITEMS = [ const NAV_ITEMS = [
{ path: '/pipelines', label: '流水线', icon: GitBranch }, { path: '/pipelines', labelKey: 'deploy.pipeline.title', icon: GitBranch },
{ path: '/deploy/build-nodes', label: '构建节点', icon: Server }, { path: '/deploy/build-nodes', labelKey: 'deploy.deployNav.buildNodes', icon: Server },
{ path: '/deploy/configureTools', label: '发布配置', icon: Settings2 }, { path: '/deploy/configureTools', labelKey: 'deploy.label.releaseConfig', icon: Settings2 },
{ path: '/deploy/rbac', label: 'Job 权限', icon: Shield }, { path: '/deploy/rbac', labelKey: 'deploy.deployNav.jobPermission', icon: Shield },
{ path: '/deploy/groups', label: '用户组', icon: Users }, { path: '/deploy/groups', labelKey: 'deploy.label.userGroups', icon: Users },
]; ];
// 应用发布模块二级导航 // Logan.Li // 应用发布模块二级导航 // Logan.Li
export function DeployNav() { export function DeployNav() {
const { t } = useTranslation();
return ( return (
<nav className="flex items-center gap-1 p-1 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] w-fit flex-wrap"> <nav className="flex items-center gap-1 p-1 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] w-fit flex-wrap">
{NAV_ITEMS.map((item) => { {NAV_ITEMS.map((item) => {
@@ -28,7 +30,7 @@ export function DeployNav() {
)} )}
> >
<Icon className="h-3.5 w-3.5" /> <Icon className="h-3.5 w-3.5" />
{item.label} {t(item.labelKey)}
</NavLink> </NavLink>
); );
})} })}
@@ -1,4 +1,5 @@
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { import {
Dialog, Dialog,
@@ -36,6 +37,7 @@ export function PipelineDetailDialog({
activeStage: string | null; activeStage: string | null;
setActiveStage: (id: string | null) => void; setActiveStage: (id: string | null) => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"> <DialogContent className="max-w-4xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
@@ -48,19 +50,19 @@ export function PipelineDetailDialog({
<Tabs defaultValue="stages"> <Tabs defaultValue="stages">
<TabsList className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]"> <TabsList className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
<TabsTrigger value="stages" className="data-[state=active]:bg-[var(--dg-bg-tertiary)]"> <TabsTrigger value="stages" className="data-[state=active]:bg-[var(--dg-bg-tertiary)]">
执行阶段 {t('deploy.run.execStage')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="config" className="data-[state=active]:bg-[var(--dg-bg-tertiary)]"> <TabsTrigger value="config" className="data-[state=active]:bg-[var(--dg-bg-tertiary)]">
配置 {t('deploy.detailDialog.config')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="history" className="data-[state=active]:bg-[var(--dg-bg-tertiary)]"> <TabsTrigger value="history" className="data-[state=active]:bg-[var(--dg-bg-tertiary)]">
历史记录{pipelineRuns.length > 0 && ` (${pipelineRuns.length})`} {t('deploy.detailDialog.history')}{pipelineRuns.length > 0 && ` (${pipelineRuns.length})`}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="stages" className="mt-4"> <TabsContent value="stages" className="mt-4">
<div className="space-y-2"> <div className="space-y-2">
{parseStages(selectedPipeline.stages).length === 0 ? ( {parseStages(selectedPipeline.stages).length === 0 ? (
<div className="text-[var(--dg-text-secondary)] text-center py-8 text-sm">暂无阶段配置</div> <div className="text-[var(--dg-text-secondary)] text-center py-8 text-sm">{t('deploy.pipeline.noStages')}</div>
) : ( ) : (
parseStages(selectedPipeline.stages).map((stage: PipelineStage, index: number) => ( parseStages(selectedPipeline.stages).map((stage: PipelineStage, index: number) => (
<div <div
@@ -103,21 +105,21 @@ export function PipelineDetailDialog({
<Table> <Table>
<TableBody> <TableBody>
<TableRow> <TableRow>
<TableCell className="text-[var(--dg-text-secondary)] font-medium w-32">代码仓库</TableCell> <TableCell className="text-[var(--dg-text-secondary)] font-medium w-32">{t('deploy.table.codeCol')}</TableCell>
<TableCell className="text-[var(--dg-text-primary)]">{selectedPipeline.source || '--'}</TableCell> <TableCell className="text-[var(--dg-text-primary)]">{selectedPipeline.source || '--'}</TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell className="text-[var(--dg-text-secondary)] font-medium">默认分支</TableCell> <TableCell className="text-[var(--dg-text-secondary)] font-medium">{t('deploy.table.defaultBranch')}</TableCell>
<TableCell className="text-[var(--dg-text-primary)]">{selectedPipeline.branch || '--'}</TableCell> <TableCell className="text-[var(--dg-text-primary)]">{selectedPipeline.branch || '--'}</TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell className="text-[var(--dg-text-secondary)] font-medium">流水线类型</TableCell> <TableCell className="text-[var(--dg-text-secondary)] font-medium">{t('deploy.pipeline.type')}</TableCell>
<TableCell className="text-[var(--dg-text-primary)]">{selectedPipeline.type || 'deploy'}</TableCell> <TableCell className="text-[var(--dg-text-primary)]">{selectedPipeline.type || 'deploy'}</TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell className="text-[var(--dg-text-secondary)] font-medium">运行次数</TableCell> <TableCell className="text-[var(--dg-text-secondary)] font-medium">{t('deploy.pipeline.runs')}</TableCell>
<TableCell className="text-[var(--dg-text-primary)]"> <TableCell className="text-[var(--dg-text-primary)]">
{selectedPipeline.run_count || 0} 次(成功 {selectedPipeline.success_count || 0} / 失败 {selectedPipeline.fail_count || 0}) {selectedPipeline.run_count || 0}{t('deploy.detailDialog.runCountPrefix')} {selectedPipeline.success_count || 0} / {t('deploy.detailDialog.runCountMid')} {selectedPipeline.fail_count || 0}{t('deploy.detailDialog.runCountSuffix')}
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
@@ -129,7 +131,7 @@ export function PipelineDetailDialog({
<Loader2 className="h-5 w-5 text-[var(--dg-text-secondary)] animate-spin" /> <Loader2 className="h-5 w-5 text-[var(--dg-text-secondary)] animate-spin" />
</div> </div>
) : pipelineRuns.length === 0 ? ( ) : pipelineRuns.length === 0 ? (
<div className="text-[var(--dg-text-secondary)] text-center py-8 text-sm">暂无执行记录</div> <div className="text-[var(--dg-text-secondary)] text-center py-8 text-sm">{t('deploy.run.noRecords')}</div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{pipelineRuns.map((run: JsonData) => ( {pipelineRuns.map((run: JsonData) => (
+111 -101
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { import {
Plus, Plus,
@@ -60,7 +61,13 @@ interface LogEntry {
time: string; level: string; message: string; stage: string; time: string; level: string; message: string; stage: string;
} }
const stageTypeLabels: Record<string, string> = { source: '拉取代码', build: '构建', deploy: '部署', test: '测试', custom: '自定义' }; const stageTypeLabelKeys: Record<string, string> = {
source: 'deploy.stage.checkoutDesc',
build: 'deploy.stage.build',
deploy: 'deploy.stage.deploy',
test: 'deploy.stage.test',
custom: 'deploy.stage.custom',
};
const taskIcons: JsonData = { GitBranch, Wrench, Server, FlaskConical, Zap, Terminal, FileCode, Container }; const taskIcons: JsonData = { GitBranch, Wrench, Server, FlaskConical, Zap, Terminal, FileCode, Container };
function uid() { return `tsk-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; } function uid() { return `tsk-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; }
@@ -97,24 +104,25 @@ function TaskCard({ task, selected, onSelect, onDelete }: { task: Task; selected
// ---- Task Editor Panel ---- // ---- Task Editor Panel ----
function TaskEditor({ task, onUpdate }: { task: Task | null; onUpdate: (t: Task) => void; onClose: () => void }) { function TaskEditor({ task, onUpdate }: { task: Task | null; onUpdate: (t: Task) => void; onClose: () => void }) {
if (!task) return <div className="p-6 text-center text-[var(--dg-text-secondary)] text-sm">选择一个任务开始编辑</div>; const { t } = useTranslation();
if (!task) return <div className="p-6 text-center text-[var(--dg-text-secondary)] text-sm">{t('deploy.pipeline.selectTaskHint')}</div>;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1"><Label className="text-xs">任务名称</Label><Input value={task.name} onChange={e => onUpdate({ ...task, name: e.target.value })} className="h-8 text-sm" /></div> <div className="space-y-1"><Label className="text-xs">{t('pipeline.taskName')}</Label><Input value={task.name} onChange={e => onUpdate({ ...task, name: e.target.value })} className="h-8 text-sm" /></div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1"><Label className="text-xs">类型</Label> <div className="space-y-1"><Label className="text-xs">{t('common.type')}</Label>
<Select value={task.type} onValueChange={v => onUpdate({ ...task, type: v })}><SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger> <Select value={task.type} onValueChange={v => onUpdate({ ...task, type: v })}><SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="build">构建</SelectItem><SelectItem value="deploy">部署</SelectItem><SelectItem value="test">测试</SelectItem><SelectItem value="custom">自定义</SelectItem></SelectContent></Select> <SelectContent><SelectItem value="build">{t('pipeline.build')}</SelectItem><SelectItem value="deploy">{t('pipeline.deploy')}</SelectItem><SelectItem value="test">{t('pipeline.test')}</SelectItem><SelectItem value="custom">{t('pipeline.custom')}</SelectItem></SelectContent></Select>
</div> </div>
<div className="space-y-1"><Label className="text-xs">图标</Label> <div className="space-y-1"><Label className="text-xs">{t('pipeline.icon')}</Label>
<Select value={task.icon} onValueChange={v => onUpdate({ ...task, icon: v })}><SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger> <Select value={task.icon} onValueChange={v => onUpdate({ ...task, icon: v })}><SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(taskIcons).map(([k]) => (<SelectItem key={k} value={k}>{k}</SelectItem>))}</SelectContent></Select> <SelectContent>{Object.entries(taskIcons).map(([k]) => (<SelectItem key={k} value={k}>{k}</SelectItem>))}</SelectContent></Select>
</div> </div>
</div> </div>
<div className="space-y-1"><Label className="text-xs">脚本类型</Label> <div className="space-y-1"><Label className="text-xs">{t('automation.scriptType')}</Label>
<div className="flex gap-2">{['shell', 'python'].map(t => (<Badge key={t} variant={task.script_type === t ? 'default' : 'outline'} className="cursor-pointer text-xs" onClick={() => onUpdate({ ...task, script_type: t })}>{t}</Badge>))}</div> <div className="flex gap-2">{['shell', 'python'].map(s => (<Badge key={s} variant={task.script_type === s ? 'default' : 'outline'} className="cursor-pointer text-xs" onClick={() => onUpdate({ ...task, script_type: s })}>{s}</Badge>))}</div>
</div> </div>
<div className="space-y-1"><Label className="text-xs">脚本内容</Label> <div className="space-y-1"><Label className="text-xs">{t('automation.scriptContent')}</Label>
<Textarea value={task.script} onChange={e => onUpdate({ ...task, script: e.target.value })} className="font-mono text-xs min-h-[200px] bg-[var(--dg-bg-primary)] dg-text-green" placeholder="#!/bin/bash\nset -e\necho 'hello'" /> <Textarea value={task.script} onChange={e => onUpdate({ ...task, script: e.target.value })} className="font-mono text-xs min-h-[200px] bg-[var(--dg-bg-primary)] dg-text-green" placeholder="#!/bin/bash\nset -e\necho 'hello'" />
</div> </div>
</div> </div>
@@ -123,20 +131,21 @@ function TaskEditor({ task, onUpdate }: { task: Task | null; onUpdate: (t: Task)
// ====== Log Dialog ====== // ====== Log Dialog ======
function LogDialog({ open, onClose, run, logs }: { open: boolean; onClose: () => void; run?: JsonData; logs: LogEntry[] }) { function LogDialog({ open, onClose, run, logs }: { open: boolean; onClose: () => void; run?: JsonData; logs: LogEntry[] }) {
const { t } = useTranslation();
return ( return (
<Dialog open={open} onOpenChange={onClose}> <Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto"> <DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader><DialogTitle className="flex items-center gap-2"><ScrollText className="w-5 h-5" />执行日志 #{run?.build_number || run?.id}</DialogTitle></DialogHeader> <DialogHeader><DialogTitle className="flex items-center gap-2"><ScrollText className="w-5 h-5" />{t('automation.execLog')} #{run?.build_number || run?.id}</DialogTitle></DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap gap-3 text-sm text-[var(--dg-text-secondary)]"> <div className="flex flex-wrap gap-3 text-sm text-[var(--dg-text-secondary)]">
<span>触发方式: {run?.triggered_by || 'manual'}</span> <span>{t('pipeline.triggerMode')}: {run?.triggered_by || 'manual'}</span>
<span>分支: {run?.branch || '-'}</span> <span>{t('pipeline.branch')}: {run?.branch || '-'}</span>
<span>耗时: {fmtDuration(run?.duration)}</span> <span>{t('pipeline.duration')}: {fmtDuration(run?.duration)}</span>
<span>{run?.created_at ? new Date(run.created_at).toLocaleString('zh-CN') : '-'}</span> <span>{run?.created_at ? new Date(run.created_at).toLocaleString('zh-CN') : '-'}</span>
</div> </div>
<div className="bg-[var(--dg-bg-primary)] rounded-md p-3 max-h-[50vh] overflow-y-auto font-mono text-xs"> <div className="bg-[var(--dg-bg-primary)] rounded-md p-3 max-h-[50vh] overflow-y-auto font-mono text-xs">
{logs.length === 0 ? ( {logs.length === 0 ? (
<div className="dg-text-muted py-4 text-center">暂无日志</div> <div className="dg-text-muted py-4 text-center">{t('pipeline.noLog')}</div>
) : ( ) : (
logs.map((l, i) => ( logs.map((l, i) => (
<div key={i} className="flex gap-2 py-0.5"> <div key={i} className="flex gap-2 py-0.5">
@@ -161,6 +170,7 @@ function LogDialog({ open, onClose, run, logs }: { open: boolean; onClose: () =>
// ====== Main Component ====== // ====== Main Component ======
export default function PipelineEditor() { export default function PipelineEditor() {
const { t } = useTranslation();
const { code } = useParams<{ code: string }>(); const { code } = useParams<{ code: string }>();
const pipelineCode = code || ''; const pipelineCode = code || '';
const [pipeline, setPipeline] = useState<JsonData>(null); const [pipeline, setPipeline] = useState<JsonData>(null);
@@ -172,7 +182,7 @@ export default function PipelineEditor() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [runs, setRuns] = useState<JsonData[]>([]); const [runs, setRuns] = useState<JsonData[]>([]);
const [environments, setEnvironments] = useState<JsonData[]>([]); const [environments, setEnvironments] = useState<JsonData[]>([]);
const [members, setMembers] = useState<JsonData[]>([]); const [members, setMembers] = useState<JsonData>([]);
const [loadingMembers, setLoadingMembers] = useState(false); const [loadingMembers, setLoadingMembers] = useState(false);
// trigger config // trigger config
@@ -246,7 +256,7 @@ export default function PipelineEditor() {
stageList.forEach((stg: Stage) => { expanded[stg.id] = true; }); stageList.forEach((stg: Stage) => { expanded[stg.id] = true; });
setExpandedStages(expanded); setExpandedStages(expanded);
} catch { setStages([]); } } catch { setStages([]); }
} catch { toast.error('加载失败'); } } catch { toast.error(t('common.loadFailed')); }
finally { setLoading(false); } finally { setLoading(false); }
})(); })();
loadEnvironments(); loadEnvironments();
@@ -256,16 +266,16 @@ export default function PipelineEditor() {
const data = res as { list?: BuildNode[] }; const data = res as { list?: BuildNode[] };
setBuildNodes(Array.isArray(data?.list) ? data.list.filter((n) => n.enabled) : []); setBuildNodes(Array.isArray(data?.list) ? data.list.filter((n) => n.enabled) : []);
}).catch(() => { /* silent */ }); }).catch(() => { /* silent */ });
}, [pipelineCode, loadEnvironments, loadMembers, loadRuns]); }, [pipelineCode, loadEnvironments, loadMembers, loadRuns, t]);
const selectedTask = stages.flatMap(s => s.tasks).find(t => t.id === selectedTaskId) || null; const selectedTask = stages.flatMap(s => s.tasks).find(t => t.id === selectedTaskId) || null;
const handleUpdateTask = (t: Task) => { setStages(prev => prev.map(s => ({ ...s, tasks: s.tasks.map(x => x.id === t.id ? t : x) }))); }; const handleUpdateTask = (tk: Task) => { setStages(prev => prev.map(s => ({ ...s, tasks: s.tasks.map(x => x.id === tk.id ? tk : x) }))); };
const handleAddTask = (stageIdx: number) => { const handleAddTask = (stageIdx: number) => {
const newTask: Task = { id: uid(), name: '新任务', type: 'build', icon: 'Wrench', config: {}, script: '', script_type: 'shell' }; const newTask: Task = { id: uid(), name: t('pipeline.newTask'), type: 'build', icon: 'Wrench', config: {}, script: '', script_type: 'shell' };
setStages(prev => prev.map((s, i) => i === stageIdx ? { ...s, tasks: [...s.tasks, newTask] } : s)); setStages(prev => prev.map((s, i) => i === stageIdx ? { ...s, tasks: [...s.tasks, newTask] } : s));
}; };
const handleDeleteTask = (taskId: string) => { setStages(prev => prev.map(s => ({ ...s, tasks: s.tasks.filter(t => t.id !== taskId) }))); if (selectedTaskId === taskId) setSelectedTaskId(null); }; const handleDeleteTask = (taskId: string) => { setStages(prev => prev.map(s => ({ ...s, tasks: s.tasks.filter(t => t.id !== taskId) }))); if (selectedTaskId === taskId) setSelectedTaskId(null); };
const handleAddStage = () => { const newStage: Stage = { id: `stg-${Date.now()}`, name: '新阶段', type: 'custom', tasks: [] }; setStages(prev => [...prev, newStage]); }; const handleAddStage = () => { const newStage: Stage = { id: `stg-${Date.now()}`, name: t('pipeline.newStage'), type: 'custom', tasks: [] }; setStages(prev => [...prev, newStage]); };
const handleDeleteStage = (idx: number) => { setStages(prev => prev.filter((_, i) => i !== idx)); }; const handleDeleteStage = (idx: number) => { setStages(prev => prev.filter((_, i) => i !== idx)); };
const handleMoveStage = (idx: number, dir: -1 | 1) => { const nx = idx + dir; if (nx < 0 || nx >= stages.length) return; const arr = [...stages]; [arr[idx], arr[nx]] = [arr[nx], arr[idx]]; setStages(arr); }; const handleMoveStage = (idx: number, dir: -1 | 1) => { const nx = idx + dir; if (nx < 0 || nx >= stages.length) return; const arr = [...stages]; [arr[idx], arr[nx]] = [arr[nx], arr[idx]]; setStages(arr); };
@@ -282,27 +292,27 @@ export default function PipelineEditor() {
variables: JSON.stringify(Object.fromEntries(variables.filter(([k]) => k.trim()))), variables: JSON.stringify(Object.fromEntries(variables.filter(([k]) => k.trim()))),
build_node_id: buildNodeId ? Number(buildNodeId) : null, build_node_id: buildNodeId ? Number(buildNodeId) : null,
}); });
toast.success('已保存'); toast.success(t('common.savedSuccessfully'));
} catch { toast.error('保存失败'); } } catch { toast.error(t('common.saveFailed')); }
finally { setSaving(false); } finally { setSaving(false); }
}; };
const handleTrigger = async () => { const handleTrigger = async () => {
try { try {
await pipelineApi.triggerPipeline(pipelineCode); await pipelineApi.triggerPipeline(pipelineCode);
toast.success('已触发'); toast.success(t('deploy.pipeline.triggered'));
loadRuns(); loadRuns();
} catch { toast.error('触发失败'); } } catch { toast.error(t('deploy.pipeline.triggerFailed')); }
}; };
const handleStop = async (runId: number | string) => { const handleStop = async (runId: number | string) => {
const ok = await _confirm({ title: '停止运行', description: '确定要停止此运行吗?', variant: 'destructive' }); const ok = await _confirm({ title: t('pipeline.stopRun'), description: t('deploy.run.stopConfirm'), variant: 'destructive' });
if (!ok) return; if (!ok) return;
try { try {
await pipelineApi.stopRun(pipelineCode, runId); await pipelineApi.stopRun(pipelineCode, runId);
toast.success('已停止'); toast.success(t('pipeline.stopped'));
loadRuns(); loadRuns();
} catch { toast.error('停止失败'); } } catch { toast.error(t('pipeline.stopFailed')); }
}; };
const handleViewLog = async (run: JsonData) => { const handleViewLog = async (run: JsonData) => {
@@ -315,36 +325,36 @@ export default function PipelineEditor() {
}; };
const handleAddMember = async () => { const handleAddMember = async () => {
if (!newMemberUser.trim()) { toast.error('请输入用户名'); return; } if (!newMemberUser.trim()) { toast.error(t('pipeline.usernameRequired')); return; }
try { try {
await pipelineApi.addMember(pipelineCode, { username: newMemberUser.trim(), role: newMemberRole }); await pipelineApi.addMember(pipelineCode, { username: newMemberUser.trim(), role: newMemberRole });
toast.success('已添加成员'); toast.success(t('pipeline.memberAdded'));
setNewMemberUser(''); setNewMemberUser('');
loadMembers(); loadMembers();
} catch (e) { toast.error(e?.message || '添加失败'); } } catch (e) { toast.error(e?.message || t('common.operationFailed')); }
}; };
const handleRemoveMember = async (memberId: number) => { const handleRemoveMember = async (memberId: number) => {
const ok = await _confirm({ title: '移除成员', description: '确定要移除此成员吗?', variant: 'destructive' }); const ok = await _confirm({ title: t('pipeline.removeMember'), description: t('pipeline.removeMemberConfirm'), variant: 'destructive' });
if (!ok) return; if (!ok) return;
try { await pipelineApi.removeMember(pipelineCode, memberId); toast.success('已移除'); loadMembers(); } try { await pipelineApi.removeMember(pipelineCode, memberId); toast.success(t('pipeline.memberRemoved')); loadMembers(); }
catch { toast.error('移除失败'); } catch { toast.error(t('common.operationFailed')); }
}; };
const envBadge = environments.find(e => String(e.id) === String(environmentId)); const envBadge = environments.find(e => String(e.id) === String(environmentId));
const editorTabs = [ const editorTabs = [
{ id: 'flow', label: '流程配置' }, { id: 'flow', label: t('pipeline.flowConfig') },
{ id: 'triggers', label: '触发设置' }, { id: 'triggers', label: t('pipeline.triggerSettings') },
{ id: 'variables', label: '变量' }, { id: 'variables', label: t('pipeline.variables') },
{ id: 'runs', label: '发布记录', badge: runs.length || undefined }, { id: 'runs', label: t('pipeline.releaseRecords'), badge: runs.length || undefined },
{ id: 'members', label: '成员管理', badge: members.length || undefined }, { id: 'members', label: t('pipeline.memberManage'), badge: members.length || undefined },
]; ];
if (!loading && !pipeline) { if (!loading && !pipeline) {
return ( return (
<PageShell variant="workspace" title="流水线编辑" icon={<GitBranch className="h-5 w-5" />}> <PageShell variant="workspace" title={t('pipeline.pipelineEditor')} icon={<GitBranch className="h-5 w-5" />}>
<PageEmpty title="流水线不存在" description="请检查链接或返回列表重试" /> <PageEmpty title={t('pipeline.pipelineNotExist')} description={t('pipeline.checkLinkOrRetry')} />
</PageShell> </PageShell>
); );
} }
@@ -352,7 +362,7 @@ export default function PipelineEditor() {
return ( return (
<PageShell <PageShell
variant="workspace" variant="workspace"
title={pipeline?.name || '流水线编辑'} title={pipeline?.name || t('pipeline.pipelineEditor')}
subtitle={pipeline ? `code: ${pipeline.code} · ${pipeline.description || ''}` : undefined} subtitle={pipeline ? `code: ${pipeline.code} · ${pipeline.description || ''}` : undefined}
icon={<GitBranch className="h-5 w-5" />} icon={<GitBranch className="h-5 w-5" />}
loading={loading} loading={loading}
@@ -367,10 +377,10 @@ export default function PipelineEditor() {
</Badge> </Badge>
)} )}
<Button variant="outline" className="dg-btn-secondary" onClick={handleSave} disabled={saving}> <Button variant="outline" className="dg-btn-secondary" onClick={handleSave} disabled={saving}>
<Save className="w-4 h-4 mr-1" />{saving ? '保存中...' : '保存'} <Save className="w-4 h-4 mr-1" />{saving ? t('common.loading') : t('common.save')}
</Button> </Button>
<Button className="dg-btn-primary" onClick={handleTrigger}> <Button className="dg-btn-primary" onClick={handleTrigger}>
<Play className="w-4 h-4 mr-1" />运行 <Play className="w-4 h-4 mr-1" />{t('pipeline.run')}
</Button> </Button>
</> </>
)} )}
@@ -379,11 +389,11 @@ export default function PipelineEditor() {
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"> <Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-3 flex items-center gap-3"> <CardContent className="p-3 flex items-center gap-3">
<Shield className="w-4 h-4 text-[var(--dg-text-secondary)]" /> <Shield className="w-4 h-4 text-[var(--dg-text-secondary)]" />
<span className="text-sm text-[var(--dg-text-secondary)]">部署环境:</span> <span className="text-sm text-[var(--dg-text-secondary)]">{t('pipeline.deployEnv')}:</span>
<Select value={String(environmentId)} onValueChange={v => setEnvironmentId(v === '__none' ? '' : v)}> <Select value={String(environmentId)} onValueChange={v => setEnvironmentId(v === '__none' ? '' : v)}>
<SelectTrigger className="w-40 h-8 text-sm"><SelectValue placeholder="选择环境" /></SelectTrigger> <SelectTrigger className="w-40 h-8 text-sm"><SelectValue placeholder={t('pipeline.selectEnv')} /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__none">未指定</SelectItem> <SelectItem value="__none">{t('pipeline.unspecified')}</SelectItem>
{environments.map(env => ( {environments.map(env => (
<SelectItem key={env.id} value={String(env.id)}> <SelectItem key={env.id} value={String(env.id)}>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
@@ -395,11 +405,11 @@ export default function PipelineEditor() {
</SelectContent> </SelectContent>
</Select> </Select>
<Server className="w-4 h-4 text-[var(--dg-text-secondary)] ml-2" /> <Server className="w-4 h-4 text-[var(--dg-text-secondary)] ml-2" />
<span className="text-sm text-[var(--dg-text-secondary)]">构建节点:</span> <span className="text-sm text-[var(--dg-text-secondary)]">{t('pipeline.buildNode')}:</span>
<Select value={buildNodeId || '__auto'} onValueChange={v => setBuildNodeId(v === '__auto' ? '' : v)}> <Select value={buildNodeId || '__auto'} onValueChange={v => setBuildNodeId(v === '__auto' ? '' : v)}>
<SelectTrigger className="w-48 h-8 text-sm"><SelectValue placeholder="自动调度" /></SelectTrigger> <SelectTrigger className="w-48 h-8 text-sm"><SelectValue placeholder={t('pipeline.autoSchedule')} /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__auto">自动调度(Master 优先)</SelectItem> <SelectItem value="__auto">{t('pipeline.autoScheduleMaster')}</SelectItem>
{buildNodes.map(n => ( {buildNodes.map(n => (
<SelectItem key={n.id} value={String(n.id)}> <SelectItem key={n.id} value={String(n.id)}>
{n.name} ({n.running_count || 0}/{n.max_executors}) {n.name} ({n.running_count || 0}/{n.max_executors})
@@ -407,7 +417,7 @@ export default function PipelineEditor() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<span className="text-xs text-[var(--dg-text-secondary)] opacity-70">Master 满载时自动调度到空闲远程节点</span> <span className="text-xs text-[var(--dg-text-secondary)] opacity-70">{t('pipeline.masterFullAutoSchedule')}</span>
</CardContent> </CardContent>
</Card> </Card>
@@ -417,11 +427,11 @@ export default function PipelineEditor() {
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-[var(--dg-text-primary)]">流水线阶段 ({stages.length})</span> <span className="text-sm font-medium text-[var(--dg-text-primary)]">{t('deploy.pipeline.stages', { count: stages.length })}</span>
<Button size="sm" onClick={handleAddStage}><Plus className="w-3.5 h-3.5 mr-1" />添加阶段</Button> <Button size="sm" onClick={handleAddStage}><Plus className="w-3.5 h-3.5 mr-1" />{t('pipeline.addStage')}</Button>
</div> </div>
{stages.length === 0 ? ( {stages.length === 0 ? (
<div className="text-center py-12 border-2 border-dashed rounded-lg text-[var(--dg-text-secondary)]">点击「添加阶段」开始</div> <div className="text-center py-12 border-2 border-dashed rounded-lg text-[var(--dg-text-secondary)]">{t('deploy.pipeline.clickToAddStage')}</div>
) : ( ) : (
stages.map((stage, idx) => { stages.map((stage, idx) => {
const isExpanded = expandedStages[stage.id] !== false; // default expanded const isExpanded = expandedStages[stage.id] !== false; // default expanded
@@ -440,16 +450,16 @@ export default function PipelineEditor() {
className="h-7 text-sm font-medium border-none bg-transparent shadow-none flex-1 px-1" className="h-7 text-sm font-medium border-none bg-transparent shadow-none flex-1 px-1"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
<span className="text-xs text-[var(--dg-text-secondary)]">{stage.tasks.length} 任务</span> <span className="text-xs text-[var(--dg-text-secondary)]">{stage.tasks.length} {t('pipeline.tasks')}</span>
<Badge variant="secondary" className="text-xs">{stageTypeLabels[stage.type] || stage.type}</Badge> <Badge variant="secondary" className="text-xs">{stageTypeLabelKeys[stage.type] ? t(stageTypeLabelKeys[stage.type]) : stage.type}</Badge>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); handleMoveStage(idx, -1); }} disabled={idx === 0}><ChevronUp className="w-3.5 h-3.5" /></Button> <Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); handleMoveStage(idx, -1); }} disabled={idx === 0}><ChevronUp className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); handleMoveStage(idx, 1); }} disabled={idx === stages.length - 1}><ChevronDown className="w-3.5 h-3.5" /></Button> <Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); handleMoveStage(idx, 1); }} disabled={idx === stages.length - 1}><ChevronDown className="w-3.5 h-3.5" /></Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-red-500" onClick={(e) => { e.stopPropagation(); handleDeleteStage(idx); }}><Trash2 className="w-3.5 h-3.5" /></Button> <Button variant="ghost" size="icon" className="h-6 w-6 text-red-500" onClick={(e) => { e.stopPropagation(); handleDeleteStage(idx); }}><Trash2 className="w-3.5 h-3.5" /></Button>
</div> </div>
{isExpanded && ( {isExpanded && (
<div className="border border-[var(--dg-border)] rounded-b-md p-3 space-y-2"> <div className="border border-[var(--dg-border)] rounded-b-md p-3 space-y-2">
{stage.tasks.map(t => (<TaskCard key={t.id} task={t} selected={selectedTaskId === t.id} onSelect={() => setSelectedTaskId(selectedTaskId === t.id ? null : t.id)} onDelete={() => handleDeleteTask(t.id)} />))} {stage.tasks.map(tk => (<TaskCard key={tk.id} task={tk} selected={selectedTaskId === tk.id} onSelect={() => setSelectedTaskId(selectedTaskId === tk.id ? null : tk.id)} onDelete={() => handleDeleteTask(tk.id)} />))}
<Button variant="outline" size="sm" className="w-full h-8 text-xs" onClick={() => handleAddTask(idx)}><Plus className="w-3 h-3 mr-1" />添加任务</Button> <Button variant="outline" size="sm" className="w-full h-8 text-xs" onClick={() => handleAddTask(idx)}><Plus className="w-3 h-3 mr-1" />{t('pipeline.addTask')}</Button>
</div> </div>
)} )}
{idx < stages.length - 1 && <div className="flex justify-center py-1"><ChevronRight className="w-4 h-4 rotate-90 text-[var(--dg-text-secondary)] opacity-30" /></div>} {idx < stages.length - 1 && <div className="flex justify-center py-1"><ChevronRight className="w-4 h-4 rotate-90 text-[var(--dg-text-secondary)] opacity-30" /></div>}
@@ -459,7 +469,7 @@ export default function PipelineEditor() {
)} )}
</div> </div>
<div className="w-[360px] flex-shrink-0 border border-[var(--dg-border)] rounded-lg p-4 bg-[var(--dg-bg-secondary)]"> <div className="w-[360px] flex-shrink-0 border border-[var(--dg-border)] rounded-lg p-4 bg-[var(--dg-bg-secondary)]">
<div className="flex items-center justify-between mb-3"><span className="text-sm font-medium text-[var(--dg-text-primary)]">任务编辑</span>{selectedTask && <Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => setSelectedTaskId(null)}>关闭</Button>}</div> <div className="flex items-center justify-between mb-3"><span className="text-sm font-medium text-[var(--dg-text-primary)]">{t('pipeline.taskEdit')}</span>{selectedTask && <Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => setSelectedTaskId(null)}>{t('common.close')}</Button>}</div>
<TaskEditor task={selectedTask} onUpdate={handleUpdateTask} onClose={() => setSelectedTaskId(null)} /> <TaskEditor task={selectedTask} onUpdate={handleUpdateTask} onClose={() => setSelectedTaskId(null)} />
</div> </div>
</div> </div>
@@ -469,18 +479,18 @@ export default function PipelineEditor() {
<TabsContent value="triggers" className="mt-3"> <TabsContent value="triggers" className="mt-3">
<div className="space-y-4 max-w-lg"> <div className="space-y-4 max-w-lg">
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6 space-y-4"> <Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><GitBranch className="w-5 h-5 dg-text-blue" /><span className="font-medium">Git Push 触发</span></div><Switch checked={triggerMode === 'git_push'} onCheckedChange={v => setTriggerMode(v ? 'git_push' : 'manual')} /></div> <div className="flex items-center justify-between"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><GitBranch className="w-5 h-5 dg-text-blue" /><span className="font-medium">{t('pipeline.gitPushTrigger')}</span></div><Switch checked={triggerMode === 'git_push'} onCheckedChange={v => setTriggerMode(v ? 'git_push' : 'manual')} /></div>
{triggerMode === 'git_push' && <div className="space-y-1"><Label>监听分支</Label><Input value={watchBranches} onChange={e => setWatchBranches(e.target.value)} placeholder="master, release/*" className="h-9" /></div>} {triggerMode === 'git_push' && <div className="space-y-1"><Label>{t('pipeline.listenBranch')}</Label><Input value={watchBranches} onChange={e => setWatchBranches(e.target.value)} placeholder="master, release/*" className="h-9" /></div>}
</CardContent></Card> </CardContent></Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6 space-y-4"> <Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><Clock className="w-5 h-5 dg-text-blue" /><span className="font-medium">定时触发</span></div><Switch checked={triggerMode === 'cron'} onCheckedChange={v => setTriggerMode(v ? 'cron' : 'manual')} /></div> <div className="flex items-center justify-between"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><Clock className="w-5 h-5 dg-text-blue" /><span className="font-medium">{t('pipeline.cronTrigger')}</span></div><Switch checked={triggerMode === 'cron'} onCheckedChange={v => setTriggerMode(v ? 'cron' : 'manual')} /></div>
{triggerMode === 'cron' && <div className="space-y-1"><Label>Cron 表达式</Label><Input value={cronExpr} onChange={e => setCronExpr(e.target.value)} placeholder="0 2 * * *" className="h-9 font-mono" /></div>} {triggerMode === 'cron' && <div className="space-y-1"><Label>{t('pipeline.cronExpr')}</Label><Input value={cronExpr} onChange={e => setCronExpr(e.target.value)} placeholder="0 2 * * *" className="h-9 font-mono" /></div>}
</CardContent></Card> </CardContent></Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6 space-y-4"> <Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6 space-y-4">
<div className="flex items-center justify-between"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><Webhook className="w-5 h-5 dg-text-blue" /><span className="font-medium">Webhook 触发</span></div><Switch checked={triggerMode === 'webhook'} onCheckedChange={v => setTriggerMode(v ? 'webhook' : 'manual')} /></div> <div className="flex items-center justify-between"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><Webhook className="w-5 h-5 dg-text-blue" /><span className="font-medium">{t('pipeline.webhookTrigger')}</span></div><Switch checked={triggerMode === 'webhook'} onCheckedChange={v => setTriggerMode(v ? 'webhook' : 'manual')} /></div>
{triggerMode === 'webhook' && <div className="space-y-1"><Label>Secret Token</Label><Input value={webhookToken} onChange={e => setWebhookToken(e.target.value)} type="password" placeholder="输入签名密钥" className="h-9" /></div>} {triggerMode === 'webhook' && <div className="space-y-1"><Label>Secret Token</Label><Input value={webhookToken} onChange={e => setWebhookToken(e.target.value)} type="password" placeholder={t('pipeline.inputSignKey')} className="h-9" /></div>}
</CardContent></Card> </CardContent></Card>
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><Hand className="w-5 h-5 dg-text-blue" /><span className="font-medium">手动触发</span><Badge variant="secondary" className="text-xs">默认启用</Badge></div></CardContent></Card> <Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><Hand className="w-5 h-5 dg-text-blue" /><span className="font-medium">{t('pipeline.manualTrigger')}</span><Badge variant="secondary" className="text-xs">{t('pipeline.defaultEnabled')}</Badge></div></CardContent></Card>
</div> </div>
</TabsContent> </TabsContent>
@@ -495,28 +505,28 @@ export default function PipelineEditor() {
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500" onClick={() => setVariables(variables.filter((_, j) => j !== i))}><Trash2 className="w-3.5 h-3.5" /></Button> <Button variant="ghost" size="icon" className="h-8 w-8 text-red-500" onClick={() => setVariables(variables.filter((_, j) => j !== i))}><Trash2 className="w-3.5 h-3.5" /></Button>
</div> </div>
))} ))}
<Button variant="outline" size="sm" onClick={() => setVariables([...variables, ['', '']])}><Plus className="w-3.5 h-3.5 mr-1" />添加变量</Button> <Button variant="outline" size="sm" onClick={() => setVariables([...variables, ['', '']])}><Plus className="w-3.5 h-3.5 mr-1" />{t('pipeline.addVar')}</Button>
</div> </div>
</TabsContent> </TabsContent>
{/* ====== Runs Tab ====== */} {/* ====== Runs Tab ====== */}
<TabsContent value="runs" className="mt-3"> <TabsContent value="runs" className="mt-3">
{runs.length === 0 ? ( {runs.length === 0 ? (
<div className="text-center py-12 text-[var(--dg-text-secondary)]">暂无执行记录</div> <div className="text-center py-12 text-[var(--dg-text-secondary)]">{t('pipeline.noRecords')}</div>
) : ( ) : (
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden"> <div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]"> <thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr> <tr>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">#</th> <th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">#</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">状态</th> <th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('common.status')}</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">触发方式</th> <th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('pipeline.triggerMode')}</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">触发人</th> <th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('pipeline.triggerBy')}</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">节点</th> <th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('pipeline.buildNode')}</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">分支</th> <th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('pipeline.branch')}</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">耗时</th> <th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('pipeline.duration')}</th>
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">时间</th> <th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">{t('common.createdAt')}</th>
<th className="px-3 py-2 text-right text-[var(--dg-text-secondary)]">操作</th> <th className="px-3 py-2 text-right text-[var(--dg-text-secondary)]">{t('common.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -524,18 +534,18 @@ export default function PipelineEditor() {
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50"> <tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">#{r.build_number || r.id}</td> <td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">#{r.build_number || r.id}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
{r.status === 'success' ? <Badge className="bg-emerald-500/20 dg-text-green border-emerald-500/30"><Check className="w-3 h-3 mr-0.5" />成功</Badge> : {r.status === 'success' ? <Badge className="bg-emerald-500/20 dg-text-green border-emerald-500/30"><Check className="w-3 h-3 mr-0.5" />{t('common.success')}</Badge> :
r.status === 'failed' ? <Badge className="bg-red-500/20 dg-text-red border-red-500/30"><X className="w-3 h-3 mr-0.5" />失败</Badge> : r.status === 'failed' ? <Badge className="bg-red-500/20 dg-text-red border-red-500/30"><X className="w-3 h-3 mr-0.5" />{t('common.failed')}</Badge> :
r.status === 'running' ? <Badge className="bg-blue-500/20 dg-text-blue border-blue-500/30"><Loader2 className="w-3 h-3 animate-spin mr-0.5" />执行中</Badge> : r.status === 'running' ? <Badge className="bg-blue-500/20 dg-text-blue border-blue-500/30"><Loader2 className="w-3 h-3 animate-spin mr-0.5" />{t('common.executing')}</Badge> :
r.status === 'cancelled' ? <Badge className="bg-yellow-500/20 dg-text-yellow border-yellow-500/30"><StopCircle className="w-3 h-3 mr-0.5" />已停止</Badge> : r.status === 'cancelled' ? <Badge className="bg-yellow-500/20 dg-text-yellow border-yellow-500/30"><StopCircle className="w-3 h-3 mr-0.5" />{t('pipeline.stopped')}</Badge> :
<Badge variant="secondary" className="text-[var(--dg-text-secondary)]">{r.status}</Badge>} <Badge variant="secondary" className="text-[var(--dg-text-secondary)]">{r.status}</Badge>}
</td> </td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs"> <td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">
{r.trigger_mode === 'manual' && <span className="flex items-center gap-1"><Hand className="w-3 h-3" />手动</span>} {r.trigger_mode === 'manual' && <span className="flex items-center gap-1"><Hand className="w-3 h-3" />{t('deploy.trigger.manual')}</span>}
{r.trigger_mode === 'cron' && <span className="flex items-center gap-1"><Clock className="w-3 h-3" />定时</span>} {r.trigger_mode === 'cron' && <span className="flex items-center gap-1"><Clock className="w-3 h-3" />{t('deploy.trigger.cron')}</span>}
{r.trigger_mode === 'webhook' && <span className="flex items-center gap-1"><Webhook className="w-3 h-3" />Webhook</span>} {r.trigger_mode === 'webhook' && <span className="flex items-center gap-1"><Webhook className="w-3 h-3" />Webhook</span>}
{r.trigger_mode === 'git_push' && <span className="flex items-center gap-1"><GitBranch className="w-3 h-3" />Git Push</span>} {r.trigger_mode === 'git_push' && <span className="flex items-center gap-1"><GitBranch className="w-3 h-3" />Git Push</span>}
{!r.trigger_mode && (r.triggered_by || '手动')} {!r.trigger_mode && (r.triggered_by || t('deploy.trigger.manual'))}
</td> </td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">{r.triggered_by || 'admin'}</td> <td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">{r.triggered_by || 'admin'}</td>
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">{r.build_node_name || 'Master'}</td> <td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">{r.build_node_name || 'Master'}</td>
@@ -544,9 +554,9 @@ export default function PipelineEditor() {
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">{r.created_at ? new Date(r.created_at).toLocaleString('zh-CN') : '-'}</td> <td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">{r.created_at ? new Date(r.created_at).toLocaleString('zh-CN') : '-'}</td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-blue hover:text-blue-300" onClick={() => handleViewLog(r)}><FileText className="w-3.5 h-3.5 mr-1" />日志</Button> <Button variant="ghost" size="sm" className="h-7 text-xs dg-text-blue hover:text-blue-300" onClick={() => handleViewLog(r)}><FileText className="w-3.5 h-3.5 mr-1" />{t('pipeline.log')}</Button>
{(r.status === 'running' || r.status === 'pending') && ( {(r.status === 'running' || r.status === 'pending') && (
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red hover:dg-text-red" onClick={() => handleStop(r.id)}><StopCircle className="w-3.5 h-3.5 mr-1" />停止</Button> <Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red hover:dg-text-red" onClick={() => handleStop(r.id)}><StopCircle className="w-3.5 h-3.5 mr-1" />{t('common.stop')}</Button>
)} )}
</div> </div>
</td> </td>
@@ -566,25 +576,25 @@ export default function PipelineEditor() {
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<UserPlus className="w-4 h-4 dg-text-blue" /> <UserPlus className="w-4 h-4 dg-text-blue" />
<span className="text-sm font-medium text-[var(--dg-text-primary)]">添加用户</span> <span className="text-sm font-medium text-[var(--dg-text-primary)]">{t('pipeline.addUser')}</span>
</div> </div>
<div className="flex items-end gap-3 mt-3"> <div className="flex items-end gap-3 mt-3">
<div className="flex-1 space-y-1"> <div className="flex-1 space-y-1">
<Label className="text-xs">用户名</Label> <Label className="text-xs">{t('pipeline.username')}</Label>
<Input value={newMemberUser} onChange={e => setNewMemberUser(e.target.value)} placeholder="输入用户名" className="h-9" /> <Input value={newMemberUser} onChange={e => setNewMemberUser(e.target.value)} placeholder={t('pipeline.usernamePlaceholder')} className="h-9" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">角色</Label> <Label className="text-xs">{t('pipeline.role')}</Label>
<Select value={newMemberRole} onValueChange={setNewMemberRole}> <Select value={newMemberRole} onValueChange={setNewMemberRole}>
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger> <SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="owner">拥有者</SelectItem> <SelectItem value="owner">{t('pipeline.owner')}</SelectItem>
<SelectItem value="editor">编辑者</SelectItem> <SelectItem value="editor">{t('pipeline.editor')}</SelectItem>
<SelectItem value="viewer">查看者</SelectItem> <SelectItem value="viewer">{t('pipeline.viewer')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<Button onClick={handleAddMember} className="h-9"><Plus className="w-4 h-4 mr-1" />添加</Button> <Button onClick={handleAddMember} className="h-9"><Plus className="w-4 h-4 mr-1" />{t('common.add')}</Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -599,12 +609,12 @@ export default function PipelineEditor() {
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"> <Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="px-4 py-2 text-xs font-medium text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]"> <div className="px-4 py-2 text-xs font-medium text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]">
👤 直接用户授权 ({directUsers.length}) 👤 {t('pipeline.directUserAuth')} ({directUsers.length})
</div> </div>
{loadingMembers ? ( {loadingMembers ? (
<div className="flex items-center justify-center py-6"><Loader2 className="w-5 h-5 animate-spin text-[var(--dg-text-secondary)]" /></div> <div className="flex items-center justify-center py-6"><Loader2 className="w-5 h-5 animate-spin text-[var(--dg-text-secondary)]" /></div>
) : directUsers.length === 0 ? ( ) : directUsers.length === 0 ? (
<div className="py-4 text-center text-[var(--dg-text-secondary)] text-xs">暂无直接用户</div> <div className="py-4 text-center text-[var(--dg-text-secondary)] text-xs">{t('pipeline.noDirectUser')}</div>
) : ( ) : (
<div className="divide-y divide-[var(--dg-border)]"> <div className="divide-y divide-[var(--dg-border)]">
{directUsers.map((m: JsonData) => ( {directUsers.map((m: JsonData) => (
@@ -613,10 +623,10 @@ export default function PipelineEditor() {
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center"><Users className="w-4 h-4 dg-text-blue" /></div> <div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center"><Users className="w-4 h-4 dg-text-blue" /></div>
<div> <div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{m.username}</div> <div className="text-sm font-medium text-[var(--dg-text-primary)]">{m.username}</div>
<div className="text-xs text-[var(--dg-text-secondary)]">{m.role === 'owner' ? <span className="dg-text-yellow">拥有者</span> : m.role === 'editor' ? <span className="dg-text-blue">编辑者</span> : <span className="dg-text-secondary">查看者</span>}</div> <div className="text-xs text-[var(--dg-text-secondary)]">{m.role === 'owner' ? <span className="dg-text-yellow">{t('pipeline.owner')}</span> : m.role === 'editor' ? <span className="dg-text-blue">{t('pipeline.editor')}</span> : <span className="dg-text-secondary">{t('pipeline.viewer')}</span>}</div>
</div> </div>
</div> </div>
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red hover:dg-text-red" onClick={() => handleRemoveMember(m.id)}><Trash2 className="w-3.5 h-3.5 mr-1" />移除</Button> <Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red hover:dg-text-red" onClick={() => handleRemoveMember(m.id)}><Trash2 className="w-3.5 h-3.5 mr-1" />{t('pipeline.removeMember')}</Button>
</div> </div>
))} ))}
</div> </div>
@@ -628,11 +638,11 @@ export default function PipelineEditor() {
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"> <Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="px-4 py-2 text-xs font-medium text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]"> <div className="px-4 py-2 text-xs font-medium text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]">
👥 用户组授权 ({groupAssignments.length}) 👥 {t('pipeline.groupAuth')} ({groupAssignments.length})
</div> </div>
{groupAssignments.length === 0 ? ( {groupAssignments.length === 0 ? (
<div className="py-4 text-center text-[var(--dg-text-secondary)] text-xs"> <div className="py-4 text-center text-[var(--dg-text-secondary)] text-xs">
暂无组授权 — 在「<a href="/deploy/groups" className="dg-text-blue underline">用户组管理</a>」页面创建组后,可在此授权 {t('pipeline.noGroupAuthHint', { defaultValue: t('pipeline.noGroupAuthHint') })}
</div> </div>
) : ( ) : (
<div className="divide-y divide-[var(--dg-border)]"> <div className="divide-y divide-[var(--dg-border)]">
@@ -641,11 +651,11 @@ export default function PipelineEditor() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center"><Shield className="w-4 h-4 dg-text-purple" /></div> <div className="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center"><Shield className="w-4 h-4 dg-text-purple" /></div>
<div> <div>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{m.group?.name || `组 #${m.group_id}`}</div> <div className="text-sm font-medium text-[var(--dg-text-primary)]">{m.group?.name || `${t('pipeline.groupLabel')} #${m.group_id}`}</div>
<div className="text-xs text-[var(--dg-text-secondary)]">{m.role === 'owner' ? <span className="dg-text-yellow">拥有者</span> : m.role === 'editor' ? <span className="dg-text-blue">编辑者</span> : <span className="dg-text-secondary">查看者</span>} · 组内全体成员</div> <div className="text-xs text-[var(--dg-text-secondary)]">{m.role === 'owner' ? <span className="dg-text-yellow">{t('pipeline.owner')}</span> : m.role === 'editor' ? <span className="dg-text-blue">{t('pipeline.editor')}</span> : <span className="dg-text-secondary">{t('pipeline.viewer')}</span>} · {t('pipeline.allGroupMembers')}</div>
</div> </div>
</div> </div>
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red hover:dg-text-red" onClick={() => handleRemoveMember(m.id)}><Trash2 className="w-3.5 h-3.5 mr-1" />撤销授权</Button> <Button variant="ghost" size="sm" className="h-7 text-xs dg-text-red hover:dg-text-red" onClick={() => handleRemoveMember(m.id)}><Trash2 className="w-3.5 h-3.5 mr-1" />{t('pipeline.revokeAuth')}</Button>
</div> </div>
))} ))}
</div> </div>
@@ -1,6 +1,7 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { import {
Dialog, Dialog,
@@ -39,36 +40,37 @@ export function PipelineFormDialog({
setFormData: (data: PipelineFormData) => void; setFormData: (data: PipelineFormData) => void;
onSave: () => void; onSave: () => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"> <DialogContent className="max-w-2xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<DialogHeader> <DialogHeader>
<DialogTitle>{editMode ? '编辑流水线' : '新建流水线'}</DialogTitle> <DialogTitle>{editMode ? t('deploy.pipeline.editPipeline') : t('deploy.pipeline.newPipeline')}</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]"> <DialogDescription className="text-[var(--dg-text-secondary)]">
{editMode ? '修改流水线配置' : '配置新的 CI/CD 流水线'} {editMode ? t('deploy.pipeline.modifyConfig') : t('deploy.pipeline.configureNew')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div> <div>
<Label className="text-[var(--dg-text-secondary)]">流水线名称</Label> <Label className="text-[var(--dg-text-secondary)]">{t('deploy.pipeline.name')}</Label>
<Input <Input
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例如: frontend-build" placeholder={t('deploy.formDialog.namePlaceholder')}
className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] mt-1" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] mt-1"
/> />
</div> </div>
<div> <div>
<Label className="text-[var(--dg-text-secondary)]">描述</Label> <Label className="text-[var(--dg-text-secondary)]">{t('common.description')}</Label>
<Textarea <Textarea
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="流水线描述..." placeholder={t('deploy.formDialog.descPlaceholder')}
className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] mt-1" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] mt-1"
/> />
</div> </div>
<div> <div>
<Label className="text-[var(--dg-text-secondary)]">代码仓库</Label> <Label className="text-[var(--dg-text-secondary)]">{t('deploy.table.codeCol')}</Label>
<Input <Input
value={formData.source} value={formData.source}
onChange={(e) => setFormData({ ...formData, source: e.target.value })} onChange={(e) => setFormData({ ...formData, source: e.target.value })}
@@ -77,7 +79,7 @@ export function PipelineFormDialog({
/> />
</div> </div>
<div> <div>
<Label className="text-[var(--dg-text-secondary)]">默认分支</Label> <Label className="text-[var(--dg-text-secondary)]">{t('deploy.table.defaultBranch')}</Label>
<Input <Input
value={formData.branch} value={formData.branch}
onChange={(e) => setFormData({ ...formData, branch: e.target.value })} onChange={(e) => setFormData({ ...formData, branch: e.target.value })}
@@ -87,7 +89,7 @@ export function PipelineFormDialog({
</div> </div>
{!editMode && ( {!editMode && (
<div> <div>
<Label className="text-[var(--dg-text-secondary)]">选择模板</Label> <Label className="text-[var(--dg-text-secondary)]">{t('deploy.template.selectTemplate')}</Label>
<div className="grid grid-cols-2 gap-3 mt-1"> <div className="grid grid-cols-2 gap-3 mt-1">
{pipelineTemplates.map((template) => ( {pipelineTemplates.map((template) => (
<Card <Card
@@ -123,10 +125,10 @@ export function PipelineFormDialog({
</div> </div>
<DialogFooter className="mt-4 pt-4 border-t border-[var(--dg-border)]"> <DialogFooter className="mt-4 pt-4 border-t border-[var(--dg-border)]">
<Button variant="outline" onClick={() => onOpenChange(false)} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]"> <Button variant="outline" onClick={() => onOpenChange(false)} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
取消 {t('common.cancel')}
</Button> </Button>
<Button onClick={onSave}> <Button onClick={onSave}>
{editMode ? '保存修改' : '创建流水线'} {editMode ? t('deploy.pipeline.editMode') : t('deploy.pipeline.createMode')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,5 +1,6 @@
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useTranslation } from 'react-i18next';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -25,24 +26,25 @@ export function PipelineRunDialog({
selectedPipeline?: JsonData; selectedPipeline?: JsonData;
onRun: () => void; onRun: () => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"> <DialogContent className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<DialogHeader> <DialogHeader>
<DialogTitle>执行流水线</DialogTitle> <DialogTitle>{t('deploy.runDialog.title')}</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]">{selectedPipeline?.name}</DialogDescription> <DialogDescription className="text-[var(--dg-text-secondary)]">{selectedPipeline?.name}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div> <div>
<Label className="text-[var(--dg-text-secondary)]">分支</Label> <Label className="text-[var(--dg-text-secondary)]">{t('deploy.run.branch')}</Label>
<Input defaultValue={selectedPipeline?.branch} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] mt-1" /> <Input defaultValue={selectedPipeline?.branch} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] mt-1" />
</div> </div>
<div> <div>
<Label className="text-[var(--dg-text-secondary)]">Commit ID (可选)</Label> <Label className="text-[var(--dg-text-secondary)]">{t('deploy.form.commitId')}</Label>
<Input placeholder="留空则使用最新提交" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] mt-1" /> <Input placeholder={t('deploy.runDialog.commitPlaceholder')} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] mt-1" />
</div> </div>
<div> <div>
<Label className="text-[var(--dg-text-secondary)]">环境变量 (可选)</Label> <Label className="text-[var(--dg-text-secondary)]">{t('deploy.var.envVarOptional')}</Label>
<Textarea <Textarea
placeholder="KEY=value&#10;KEY2=value2" placeholder="KEY=value&#10;KEY2=value2"
className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] mt-1 font-mono text-sm" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] mt-1 font-mono text-sm"
@@ -51,11 +53,11 @@ export function PipelineRunDialog({
</div> </div>
<DialogFooter className="mt-4 pt-4 border-t border-[var(--dg-border)]"> <DialogFooter className="mt-4 pt-4 border-t border-[var(--dg-border)]">
<Button variant="outline" onClick={() => onOpenChange(false)} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]"> <Button variant="outline" onClick={() => onOpenChange(false)} className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
取消 {t('deploy.form.cancel')}
</Button> </Button>
<Button className="bg-emerald-600 hover:bg-emerald-700" onClick={onRun}> <Button className="bg-emerald-600 hover:bg-emerald-700" onClick={onRun}>
<Play className="h-4 w-4 mr-2" /> <Play className="h-4 w-4 mr-2" />
开始执行 {t('deploy.runDialog.startExec')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,5 +1,6 @@
// 新建发布弹窗(应用/版本/环境/Commit/说明 + 提交申请) // Logan.Li // 新建发布弹窗(应用/版本/环境/Commit/说明 + 提交申请) // Logan.Li
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -39,6 +40,7 @@ const EMPTY_FORM = {
}; };
export function ReleaseCreateDialog({ open, onOpenChange, onSuccess }: ReleaseCreateDialogProps) { export function ReleaseCreateDialog({ open, onOpenChange, onSuccess }: ReleaseCreateDialogProps) {
const { t } = useTranslation();
const [form, setForm] = useState({ ...EMPTY_FORM }); const [form, setForm] = useState({ ...EMPTY_FORM });
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@@ -48,7 +50,7 @@ export function ReleaseCreateDialog({ open, onOpenChange, onSuccess }: ReleaseCr
// 提交发布申请 // Logan.Li // 提交发布申请 // Logan.Li
const handleSubmit = async () => { const handleSubmit = async () => {
if (!form.appName.trim() || !form.version.trim()) { if (!form.appName.trim() || !form.version.trim()) {
toast.error('请填写应用名称和版本号'); toast.error(t('deploy.release.nameVerRequired'));
return; return;
} }
setSubmitting(true); setSubmitting(true);
@@ -61,12 +63,12 @@ export function ReleaseCreateDialog({ open, onOpenChange, onSuccess }: ReleaseCr
commit_msg: form.commitMsg.trim(), commit_msg: form.commitMsg.trim(),
description: form.description.trim(), description: form.description.trim(),
}); });
toast.success('发布申请已提交'); toast.success(t('deploy.toast.releaseSubmitted'));
onOpenChange(false); onOpenChange(false);
setForm({ ...EMPTY_FORM }); setForm({ ...EMPTY_FORM });
onSuccess?.(); onSuccess?.();
} catch { } catch {
toast.error('创建失败'); toast.error(t('deploy.toast.createFailed'));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -76,22 +78,22 @@ export function ReleaseCreateDialog({ open, onOpenChange, onSuccess }: ReleaseCr
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl dg-card"> <DialogContent className="max-w-2xl dg-card">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">新建发布</DialogTitle> <DialogTitle className="text-[var(--dg-text-primary)]">{t('deploy.release.newRelease')}</DialogTitle>
<DialogDescription className="dg-text-muted">创建新的应用版本发布申请</DialogDescription> <DialogDescription className="dg-text-muted">{t('deploy.release.createNew')}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div> <div>
<Label className="text-[var(--dg-text-secondary)]">应用名称</Label> <Label className="text-[var(--dg-text-secondary)]">{t('deploy.release.appName')}</Label>
<Input <Input
value={form.appName} value={form.appName}
onChange={(e) => update('appName', e.target.value)} onChange={(e) => update('appName', e.target.value)}
placeholder="如:用户中心服务" placeholder={t('deploy.releaseCreate.appPlaceholder')}
className="mt-1 dg-input" className="mt-1 dg-input"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label className="text-[var(--dg-text-secondary)]">版本号</Label> <Label className="text-[var(--dg-text-secondary)]">{t('deploy.release.versionNo')}</Label>
<Input <Input
value={form.version} value={form.version}
onChange={(e) => update('version', e.target.value)} onChange={(e) => update('version', e.target.value)}
@@ -100,7 +102,7 @@ export function ReleaseCreateDialog({ open, onOpenChange, onSuccess }: ReleaseCr
/> />
</div> </div>
<div> <div>
<Label className="text-[var(--dg-text-secondary)]">目标环境</Label> <Label className="text-[var(--dg-text-secondary)]">{t('deploy.release.targetEnv')}</Label>
<Select <Select
value={form.environment} value={form.environment}
onValueChange={(value) => update('environment', value)} onValueChange={(value) => update('environment', value)}
@@ -109,10 +111,10 @@ export function ReleaseCreateDialog({ open, onOpenChange, onSuccess }: ReleaseCr
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="dg-input"> <SelectContent className="dg-input">
<SelectItem value="dev">开发环境</SelectItem> <SelectItem value="dev">{t('deploy.env.devEnv')}</SelectItem>
<SelectItem value="test">测试环境</SelectItem> <SelectItem value="test">{t('deploy.env.testEnv')}</SelectItem>
<SelectItem value="staging">预发布</SelectItem> <SelectItem value="staging">{t('deploy.env.staging')}</SelectItem>
<SelectItem value="prod">生产环境</SelectItem> <SelectItem value="prod">{t('deploy.env.prodEnv')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -128,32 +130,32 @@ export function ReleaseCreateDialog({ open, onOpenChange, onSuccess }: ReleaseCr
/> />
</div> </div>
<div> <div>
<Label className="text-[var(--dg-text-secondary)]">提交信息</Label> <Label className="text-[var(--dg-text-secondary)]">{t('deploy.releaseCreate.commitMsg')}</Label>
<Input <Input
value={form.commitMsg} value={form.commitMsg}
onChange={(e) => update('commitMsg', e.target.value)} onChange={(e) => update('commitMsg', e.target.value)}
placeholder="feat: 新增功能" placeholder={t('deploy.releaseCreate.commitMsgPlaceholder')}
className="mt-1 dg-input" className="mt-1 dg-input"
/> />
</div> </div>
</div> </div>
<div> <div>
<Label className="text-[var(--dg-text-secondary)]">发布说明</Label> <Label className="text-[var(--dg-text-secondary)]">{t('deploy.release.releaseDesc')}</Label>
<Textarea <Textarea
value={form.description} value={form.description}
onChange={(e) => update('description', e.target.value)} onChange={(e) => update('description', e.target.value)}
placeholder="描述本次发布的内容..." placeholder={t('deploy.releaseCreate.descPlaceholder')}
className="mt-1 dg-input" className="mt-1 dg-input"
/> />
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={() => onOpenChange(false)}> <Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={() => onOpenChange(false)}>
取消 {t('deploy.form.cancel')}
</Button> </Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleSubmit} disabled={submitting}> <Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleSubmit} disabled={submitting}>
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Plus className="h-4 w-4 mr-2" />} {submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Plus className="h-4 w-4 mr-2" />}
提交申请 {t('deploy.release.submitApp')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,5 +1,6 @@
// 发布详情弹窗(流水线进度 + 构建日志 + 重新部署) // Logan.Li // 发布详情弹窗(流水线进度 + 构建日志 + 重新部署) // Logan.Li
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -14,7 +15,7 @@ import {
import { Rocket, CheckCircle, Loader2, RotateCcw } from 'lucide-react'; import { Rocket, CheckCircle, Loader2, RotateCcw } from 'lucide-react';
import { releaseApi } from '@/api'; import { releaseApi } from '@/api';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { PIPELINE_STEPS } from './releaseHelpers'; import { getPipelineSteps } from './releaseHelpers';
import type { JsonData } from '@/types/common'; import type { JsonData } from '@/types/common';
interface ReleaseDetailDialogProps { interface ReleaseDetailDialogProps {
@@ -25,6 +26,7 @@ interface ReleaseDetailDialogProps {
} }
export function ReleaseDetailDialog({ release, open, onOpenChange, onSuccess }: ReleaseDetailDialogProps) { export function ReleaseDetailDialog({ release, open, onOpenChange, onSuccess }: ReleaseDetailDialogProps) {
const { t } = useTranslation();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
// 重新触发部署 // Logan.Li // 重新触发部署 // Logan.Li
@@ -33,16 +35,18 @@ export function ReleaseDetailDialog({ release, open, onOpenChange, onSuccess }:
try { try {
setSubmitting(true); setSubmitting(true);
await releaseApi.deployRelease(release.id); await releaseApi.deployRelease(release.id);
toast.success('已重新触发部署'); toast.success(t('deploy.toast.redeployed'));
onOpenChange(false); onOpenChange(false);
onSuccess?.(); onSuccess?.();
} catch { } catch {
toast.error('重新部署失败'); toast.error(t('deploy.toast.redeployFailed'));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; };
const pipelineSteps = getPipelineSteps(t);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl dg-card"> <DialogContent className="max-w-4xl dg-card">
@@ -55,16 +59,16 @@ export function ReleaseDetailDialog({ release, open, onOpenChange, onSuccess }:
</Badge> </Badge>
</DialogTitle> </DialogTitle>
<DialogDescription className="dg-text-muted"> <DialogDescription className="dg-text-muted">
环境: {release?.environment} {t('deploy.releaseDialog.envLabel')} {release?.environment}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{/* Pipeline Steps */} {/* Pipeline Steps */}
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-sm font-medium text-[var(--dg-text-secondary)]">发布进度</h4> <h4 className="text-sm font-medium text-[var(--dg-text-secondary)]">{t('deploy.release.releaseProgress')}</h4>
<div className="space-y-2"> <div className="space-y-2">
{PIPELINE_STEPS.map((step, index) => ( {pipelineSteps.map((step, index) => (
<div <div
key={index} key={index}
className={cn( className={cn(
@@ -99,7 +103,7 @@ export function ReleaseDetailDialog({ release, open, onOpenChange, onSuccess }:
{/* Build Logs */} {/* Build Logs */}
<div className="space-y-2"> <div className="space-y-2">
<h4 className="text-sm font-medium text-[var(--dg-text-secondary)]">构建日志</h4> <h4 className="text-sm font-medium text-[var(--dg-text-secondary)]">{t('deploy.run.buildLog')}</h4>
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-4 font-mono text-xs dg-text-secondary h-48 overflow-auto"> <div className="bg-[var(--dg-bg-primary)] rounded-lg p-4 font-mono text-xs dg-text-secondary h-48 overflow-auto">
<div className="space-y-1"> <div className="space-y-1">
<p>[10:00:00] Starting pipeline for {release?.app_name}...</p> <p>[10:00:00] Starting pipeline for {release?.app_name}...</p>
@@ -129,7 +133,7 @@ export function ReleaseDetailDialog({ release, open, onOpenChange, onSuccess }:
disabled={submitting} disabled={submitting}
> >
<RotateCcw className="h-4 w-4 mr-2" /> <RotateCcw className="h-4 w-4 mr-2" />
重新部署 {t('deploy.release.redeploy')}
</Button> </Button>
)} )}
</DialogFooter> </DialogFooter>
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -42,6 +43,7 @@ import { PageEmpty } from '@/components/layout/PageEmpty';
import { StatStrip } from '@/components/ui/stat-strip'; import { StatStrip } from '@/components/ui/stat-strip';
export function ReleaseManager() { export function ReleaseManager() {
const { t } = useTranslation();
const _confirm = useConfirm(); const _confirm = useConfirm();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [envFilter, setEnvFilter] = useState<string>('all'); const [envFilter, setEnvFilter] = useState<string>('all');
@@ -188,8 +190,8 @@ export function ReleaseManager() {
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h3 className="font-medium text-[var(--dg-text-primary)]">{release.app_name}</h3> <h3 className="font-medium text-[var(--dg-text-primary)]">{release.app_name}</h3>
{getStatusBadge(release.status)} {getStatusBadge(release.status, t)}
{getEnvironmentBadge(release.environment)} {getEnvironmentBadge(release.environment, t)}
</div> </div>
<div className="flex items-center gap-4 mt-2 text-sm text-[var(--dg-text-secondary)]"> <div className="flex items-center gap-4 mt-2 text-sm text-[var(--dg-text-secondary)]">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
@@ -302,8 +304,8 @@ export function ReleaseManager() {
<ScrollArea className="max-h-[60vh]"> <ScrollArea className="max-h-[60vh]">
<div className="space-y-3 py-2"> <div className="space-y-3 py-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{getStatusBadge(selectedRelease.status)} {getStatusBadge(selectedRelease.status, t)}
{getEnvironmentBadge(selectedRelease.environment)} {getEnvironmentBadge(selectedRelease.environment, t)}
</div> </div>
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm">
<div> <div>
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { RefreshCw, Loader2, Download, CheckCircle, XCircle, Clock, Settings2, ChevronDown, Trash2 } from 'lucide-react'; import { RefreshCw, Loader2, Download, CheckCircle, XCircle, Clock, Settings2, ChevronDown, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -21,8 +22,8 @@ interface VersionOption {
version: string; label: string; version: string; label: string;
} }
const categoryLabel: Record<string, string> = { const categoryLabelKey: Record<string, string> = {
build: '构建', deploy: '部署', automation: '自动化', other: '其他', build: 'pipeline.build', deploy: 'pipeline.deploy', automation: 'pipeline.automation', other: 'pipeline.other',
}; };
const categoryColor: Record<string, string> = { const categoryColor: Record<string, string> = {
build: 'bg-amber-500/20 dg-text-yellow border-amber-500/30', build: 'bg-amber-500/20 dg-text-yellow border-amber-500/30',
@@ -34,6 +35,7 @@ const categoryColor: Record<string, string> = {
const VERSIONED_TOOLS = ['jdk', 'golang', 'nodejs', 'maven', 'nvm']; const VERSIONED_TOOLS = ['jdk', 'golang', 'nodejs', 'maven', 'nvm'];
export default function ToolConfigTab() { export default function ToolConfigTab() {
const { t } = useTranslation();
const confirm = useConfirm(); const confirm = useConfirm();
const [tools, setTools] = useState<ToolConfig[]>([]); const [tools, setTools] = useState<ToolConfig[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -202,7 +204,7 @@ export default function ToolConfigTab() {
</td> </td>
<td className="px-3 py-2 hidden sm:table-cell"> <td className="px-3 py-2 hidden sm:table-cell">
<Badge className={`text-xs ${categoryColor[tool.category] || categoryColor.other}`}> <Badge className={`text-xs ${categoryColor[tool.category] || categoryColor.other}`}>
{categoryLabel[tool.category] || tool.category} {categoryLabelKey[tool.category] || tool.category}
</Badge> </Badge>
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
@@ -1,4 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -50,40 +51,41 @@ export function JobRoleCreateDialog({
onDescriptionChange, onDescriptionChange,
onSubmit, onSubmit,
}: JobRoleCreateDialogProps) { }: JobRoleCreateDialogProps) {
const { t } = useTranslation();
const preview = useMemo(() => matchPipelines(pattern, pipelines), [pattern, pipelines]); const preview = useMemo(() => matchPipelines(pattern, pipelines), [pattern, pipelines]);
const patternHint = useMemo(() => { const patternHint = useMemo(() => {
if (!pattern.trim()) return '输入正则,匹配流水线 code 或 Job 名称'; if (!pattern.trim()) return t('rbac.jobRole.patternHint.empty');
try { try {
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new RegExp(pattern); new RegExp(pattern);
return preview.length > 0 ? `命中 ${preview.length} 个发布任务` : '无匹配任务'; return preview.length > 0 ? t('rbac.jobRole.patternHint.matched', { count: preview.length }) : t('rbac.jobRole.patternHint.noMatch');
} catch { } catch {
return '正则语法错误'; return t('rbac.jobRole.patternHint.syntaxError');
} }
}, [pattern, preview.length]); }, [pattern, preview.length, t]);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"> <DialogContent className="max-w-md bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
<DialogHeader> <DialogHeader>
<DialogTitle>新建 Job 权限</DialogTitle> <DialogTitle>{t('rbac.jobRole.newTitle')}</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]"> <DialogDescription className="text-[var(--dg-text-muted)]">
按正则匹配流水线 code 或 Job 名称,分配 owner / editor / viewer 权限 {t('rbac.jobRole.desc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">名称</Label> <Label className="text-xs">{t('rbac.jobRole.label.name')}</Label>
<Input value={name} onChange={(e) => onNameChange(e.target.value)} placeholder="后端发布" className="h-8 text-sm dg-input" /> <Input value={name} onChange={(e) => onNameChange(e.target.value)} placeholder={t('rbac.jobRole.placeholder.name')} className="h-8 text-sm dg-input" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">标识</Label> <Label className="text-xs">{t('rbac.jobRole.label.code')}</Label>
<Input value={code} onChange={(e) => onCodeChange(e.target.value)} placeholder="backend-jobs" className="h-8 text-sm font-mono dg-input" /> <Input value={code} onChange={(e) => onCodeChange(e.target.value)} placeholder={t('rbac.jobRole.placeholder.code')} className="h-8 text-sm font-mono dg-input" />
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">匹配正则</Label> <Label className="text-xs">{t('rbac.jobRole.label.pattern')}</Label>
<Input <Input
value={pattern} value={pattern}
onChange={(e) => onPatternChange(e.target.value)} onChange={(e) => onPatternChange(e.target.value)}
@@ -93,26 +95,26 @@ export function JobRoleCreateDialog({
<p className="text-[10px] text-[var(--dg-text-muted)]">{patternHint}</p> <p className="text-[10px] text-[var(--dg-text-muted)]">{patternHint}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">权限级别</Label> <Label className="text-xs">{t('rbac.jobRole.label.roleLevel')}</Label>
<Select value={role} onValueChange={onRoleChange}> <Select value={role} onValueChange={onRoleChange}>
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 text-sm dg-input"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="owner">拥有者 — 配置 + 执行 + 授权</SelectItem> <SelectItem value="owner">{t('rbac.jobRole.role.owner')}</SelectItem>
<SelectItem value="editor">编辑者 — 配置 + 执行</SelectItem> <SelectItem value="editor">{t('rbac.jobRole.role.editor')}</SelectItem>
<SelectItem value="viewer">查看者 — 只读</SelectItem> <SelectItem value="viewer">{t('rbac.jobRole.role.viewer')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">说明</Label> <Label className="text-xs">{t('rbac.jobRole.label.description')}</Label>
<Input value={description} onChange={(e) => onDescriptionChange(e.target.value)} placeholder="可选" className="h-8 text-sm dg-input" /> <Input value={description} onChange={(e) => onDescriptionChange(e.target.value)} placeholder={t('rbac.jobRole.placeholder.description')} className="h-8 text-sm dg-input" />
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button> <Button variant="outline" onClick={() => onOpenChange(false)}>{t('rbac.jobRole.cancel')}</Button>
<Button onClick={onSubmit} disabled={saving}> <Button onClick={onSubmit} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Save className="h-4 w-4 mr-1" />} {saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Save className="h-4 w-4 mr-1" />}
创建 {t('rbac.jobRole.create')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,4 +1,5 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -51,6 +52,7 @@ export function PipelineCard({
onStopRun, onStopRun,
}: PipelineCardProps) { }: PipelineCardProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation();
const stages = parseStages(pipeline.stages); const stages = parseStages(pipeline.stages);
const repo = pipeline.repo as Record<string, unknown> | undefined; const repo = pipeline.repo as Record<string, unknown> | undefined;
const registry = pipeline.registry as Record<string, unknown> | undefined; const registry = pipeline.registry as Record<string, unknown> | undefined;
@@ -92,12 +94,12 @@ export function PipelineCard({
)} )}
{pipeline.build_node_id && ( {pipeline.build_node_id && (
<Badge variant="outline" className="text-[10px] gap-1"> <Badge variant="outline" className="text-[10px] gap-1">
<Server className="h-3 w-3" />指定节点 <Server className="h-3 w-3" />{t('deploy.pipelineCard.assignedNode')}
</Badge> </Badge>
)} )}
</div> </div>
<p className="text-xs text-[var(--dg-text-secondary)] mt-1 line-clamp-1"> <p className="text-xs text-[var(--dg-text-secondary)] mt-1 line-clamp-1">
{String(pipeline.description || '暂无描述')} {String(pipeline.description || t('deploy.pipeline.desc'))}
</p> </p>
{/* 最近运行摘要行:branch + commit → image:tag // Logan.Li */} {/* 最近运行摘要行:branch + commit → image:tag // Logan.Li */}
{(pipeline.last_run_branch || pipeline.last_commit_id || pipeline.last_image_tag) && ( {(pipeline.last_run_branch || pipeline.last_commit_id || pipeline.last_image_tag) && (
@@ -134,18 +136,18 @@ export function PipelineCard({
<div className="flex items-center gap-3 mt-1.5 text-xs text-[var(--dg-text-secondary)] opacity-60"> <div className="flex items-center gap-3 mt-1.5 text-xs text-[var(--dg-text-secondary)] opacity-60">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<GitBranch className="h-3 w-3" /> <GitBranch className="h-3 w-3" />
{pipeline.deploy_mode === 'tag' ? 'Tag发版' : String(pipeline.branch || 'main')} {pipeline.deploy_mode === 'tag' ? t('deploy.pipelineCard.tagRelease') : String(pipeline.branch || 'main')}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
{deployTarget === 'k8s' ? ( {deployTarget === 'k8s' ? (
<> <>
<Network className="h-3 w-3" /> <Network className="h-3 w-3" />
{k8sDep ? `${k8sNs}/${k8sDep}` : 'Kubernetes Deployment'} {k8sDep ? `${k8sNs}/${k8sDep}` : t('deploy.pipelineCard.k8sDeployment')}
</> </>
) : ( ) : (
<> <>
<Server className="h-3 w-3" /> <Server className="h-3 w-3" />
{serverCount > 0 ? `${serverCount} 台主机` : 'SSH 主机'} {serverCount > 0 ? `${serverCount} ${t('deploy.pipelineCard.hostCountUnit')}` : t('deploy.banner.sshHost')}
</> </>
)} )}
</span> </span>
@@ -159,16 +161,16 @@ export function PipelineCard({
<div className="flex items-center gap-1 ml-4 shrink-0"> <div className="flex items-center gap-1 ml-4 shrink-0">
{pipeline.last_status === 'running' ? ( {pipeline.last_status === 'running' ? (
<Button variant="destructive" size="sm" className="h-8" onClick={onStopRunning}> <Button variant="destructive" size="sm" className="h-8" onClick={onStopRunning}>
<StopCircle className="h-3.5 w-3.5 mr-1" />停止 <StopCircle className="h-3.5 w-3.5 mr-1" />{t('deploy.pipelineCard.stop')}
</Button> </Button>
) : ( ) : (
<Button variant="default" size="sm" className="h-8" onClick={onTrigger}> <Button variant="default" size="sm" className="h-8" onClick={onTrigger}>
<Play className="h-3.5 w-3.5 mr-1" />执行 <Play className="h-3.5 w-3.5 mr-1" />{t('deploy.label.execute')}
</Button> </Button>
)} )}
<Button variant="outline" size="sm" className="h-8" onClick={() => navigate(`/pipelines/${code}`)}>编辑</Button> <Button variant="outline" size="sm" className="h-8" onClick={() => navigate(`/pipelines/${code}`)}>{t('common.edit')}</Button>
<Button variant="outline" size="sm" className="h-8" onClick={() => navigate(`/pipelines/${code}/runs`)}> <Button variant="outline" size="sm" className="h-8" onClick={() => navigate(`/pipelines/${code}/runs`)}>
<History className="h-3.5 w-3.5 mr-1" />记录 <History className="h-3.5 w-3.5 mr-1" />{t('deploy.label.record')}
</Button> </Button>
<Button variant="ghost" size="sm" className="h-8" onClick={onToggleExpand}> <Button variant="ghost" size="sm" className="h-8" onClick={onToggleExpand}>
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />} {isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
@@ -1,4 +1,5 @@
// 发布相关共享工具函数与常量 // Logan.Li // 发布相关共享工具函数与常量 // Logan.Li
import type { TFunction } from 'i18next';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
CheckCircle, CheckCircle,
@@ -9,48 +10,48 @@ import {
} from 'lucide-react'; } from 'lucide-react';
// 发布状态徽章(列表 + 详情共用) // Logan.Li // 发布状态徽章(列表 + 详情共用) // Logan.Li
export function getStatusBadge( status: string) { export function getStatusBadge(status: string, t: TFunction) {
switch (status) { switch (status) {
case 'success': case 'success':
return ( return (
<Badge variant="outline" className="border-emerald-500/30 dg-text-green bg-emerald-500/10"> <Badge variant="outline" className="border-emerald-500/30 dg-text-green bg-emerald-500/10">
<CheckCircle className="h-3 w-3 mr-1" /> <CheckCircle className="h-3 w-3 mr-1" />
成功 {t('deploy.status.success')}
</Badge> </Badge>
); );
case 'failed': case 'failed':
return ( return (
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10"> <Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10">
<XCircle className="h-3 w-3 mr-1" /> <XCircle className="h-3 w-3 mr-1" />
失败 {t('deploy.status.failed')}
</Badge> </Badge>
); );
case 'deploying': case 'deploying':
return ( return (
<Badge variant="outline" className="border-blue-500/30 dg-text-blue bg-blue-500/10 animate-pulse"> <Badge variant="outline" className="border-blue-500/30 dg-text-blue bg-blue-500/10 animate-pulse">
<Loader2 className="h-3 w-3 mr-1 animate-spin" /> <Loader2 className="h-3 w-3 mr-1 animate-spin" />
发布中 {t('deploy.status.deploying')}
</Badge> </Badge>
); );
case 'building': case 'building':
return ( return (
<Badge variant="outline" className="border-amber-500/30 dg-text-yellow bg-amber-500/10"> <Badge variant="outline" className="border-amber-500/30 dg-text-yellow bg-amber-500/10">
<Loader2 className="h-3 w-3 mr-1 animate-spin" /> <Loader2 className="h-3 w-3 mr-1 animate-spin" />
构建中 {t('deploy.status.building')}
</Badge> </Badge>
); );
case 'rolled_back': case 'rolled_back':
return ( return (
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10"> <Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10">
<RotateCcw className="h-3 w-3 mr-1" /> <RotateCcw className="h-3 w-3 mr-1" />
已回滚 {t('deploy.status.rolledBack')}
</Badge> </Badge>
); );
case 'pending': case 'pending':
return ( return (
<Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10"> <Badge variant="outline" className="border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10">
<Clock className="h-3 w-3 mr-1" /> <Clock className="h-3 w-3 mr-1" />
待发布 {t('deploy.status.pendingRelease')}
</Badge> </Badge>
); );
default: default:
@@ -59,34 +60,36 @@ export function getStatusBadge( status: string) {
} }
// 环境徽章 // Logan.Li // 环境徽章 // Logan.Li
export function getEnvBadge( env: string) { export function getEnvBadge(env: string, t: TFunction) {
const colors: Record<string, string> = { const colors: Record<string, string> = {
dev: 'border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10', dev: 'border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10',
test: 'border-blue-500/30 dg-text-blue bg-blue-500/10', test: 'border-blue-500/30 dg-text-blue bg-blue-500/10',
staging: 'border-amber-500/30 dg-text-yellow bg-amber-500/10', staging: 'border-amber-500/30 dg-text-yellow bg-amber-500/10',
prod: 'border-emerald-500/30 dg-text-green bg-emerald-500/10', prod: 'border-emerald-500/30 dg-text-green bg-emerald-500/10',
}; };
const labels: Record<string, string> = { const labelKeys: Record<string, string> = {
dev: '开发环境', dev: 'deploy.env.devEnv',
test: '测试环境', test: 'deploy.env.testEnv',
staging: '预发布', staging: 'deploy.env.staging',
prod: '生产环境', prod: 'deploy.env.prodEnv',
}; };
return ( return (
<Badge variant="outline" className={colors[env] || colors.dev}> <Badge variant="outline" className={colors[env] || colors.dev}>
{labels[env] || env} {labelKeys[env] ? t(labelKeys[env]) : env}
</Badge> </Badge>
); );
} }
// 模拟流水线步骤(详情弹窗使用) // Logan.Li // 模拟流水线步骤(详情弹窗使用) // Logan.Li
export const PIPELINE_STEPS = [ export function getPipelineSteps(t: TFunction) {
{ name: '代码检出', status: 'success', duration: '15s' }, return [
{ name: '依赖安装', status: 'success', duration: '45s' }, { name: t('deploy.stage.checkout'), status: 'success', duration: '15s' },
{ name: '代码检查', status: 'success', duration: '30s' }, { name: t('deploy.template.dependencyInstall'), status: 'success', duration: '45s' },
{ name: '单元测试', status: 'success', duration: '2m 15s' }, { name: t('deploy.releaseHelpers.pipelineStep.codeCheck'), status: 'success', duration: '30s' },
{ name: '构建镜像', status: 'success', duration: '3m 30s' }, { name: t('deploy.template.unitTest'), status: 'success', duration: '2m 15s' },
{ name: '推送镜像', status: 'success', duration: '1m 20s' }, { name: t('deploy.releaseHelpers.pipelineStep.buildImage'), status: 'success', duration: '3m 30s' },
{ name: '部署到Kubernetes', status: 'running', duration: '--' }, { name: t('deploy.releaseHelpers.pipelineStep.pushImage'), status: 'success', duration: '1m 20s' },
{ name: '健康检查', status: 'pending', duration: '--' }, { name: t('deploy.stage.deployDesc'), status: 'running', duration: '--' },
{ name: t('deploy.template.healthCheck'), status: 'pending', duration: '--' },
]; ];
}
@@ -1,5 +1,6 @@
// 发布管理(审批流)共享工具函数与常量 // Logan.Li // 发布管理(审批流)共享工具函数与常量 // Logan.Li
// 注意:本文件专用于 ReleaseManager(含审批流,状态标签与 AppRelease 的 releaseHelpers 不同) // 注意:本文件专用于 ReleaseManager(含审批流,状态标签与 AppRelease 的 releaseHelpers 不同)
import type { TFunction } from 'i18next';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
Clock, Clock,
@@ -18,16 +19,23 @@ export const ENVIRONMENTS: Record<string, { label: string; color: string }> = {
prod: { label: '生产环境', color: 'border-red-500/30 dg-text-red bg-red-500/10' }, prod: { label: '生产环境', color: 'border-red-500/30 dg-text-red bg-red-500/10' },
}; };
const ENV_LABEL_KEY: Record<string, string> = {
dev: 'deploy.env.devEnv',
test: 'deploy.env.testEnv',
staging: 'deploy.env.stagingEnv',
prod: 'deploy.env.prodEnv',
};
// 发布状态徽章(审批流:待审批/已审批/已驳回/发布中/发布成功/发布失败/已回滚) // Logan.Li // 发布状态徽章(审批流:待审批/已审批/已驳回/发布中/发布成功/发布失败/已回滚) // Logan.Li
export function getStatusBadge( status: string) { export function getStatusBadge(status: string, t: TFunction) {
const map: Record<string, { icon: any; label: string; cls: string }> = { const map: Record<string, { icon: any; labelKey: string; cls: string }> = {
pending: { icon: Clock, label: '待审批', cls: 'border-amber-500/30 dg-text-yellow bg-amber-500/10' }, pending: { icon: Clock, labelKey: 'deploy.status.pendingApproval', cls: 'border-amber-500/30 dg-text-yellow bg-amber-500/10' },
approved: { icon: Check, label: '已审批', cls: 'border-purple-500/30 dg-text-purple bg-purple-500/10' }, approved: { icon: Check, labelKey: 'deploy.status.approved', cls: 'border-purple-500/30 dg-text-purple bg-purple-500/10' },
rejected: { icon: XCircle, label: '已驳回', cls: 'border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10' }, rejected: { icon: XCircle, labelKey: 'deploy.status.rejected', cls: 'border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10' },
deploying: { icon: Loader2, label: '发布中', cls: 'border-blue-500/30 dg-text-blue bg-blue-500/10 animate-pulse' }, deploying: { icon: Loader2, labelKey: 'deploy.status.deploying', cls: 'border-blue-500/30 dg-text-blue bg-blue-500/10 animate-pulse' },
success: { icon: CheckCircle, label: '发布成功', cls: 'border-emerald-500/30 dg-text-green bg-emerald-500/10' }, success: { icon: CheckCircle, labelKey: 'deploy.status.succeeded', cls: 'border-emerald-500/30 dg-text-green bg-emerald-500/10' },
failed: { icon: XCircle, label: '发布失败', cls: 'border-red-500/30 dg-text-red bg-red-500/10' }, failed: { icon: XCircle, labelKey: 'deploy.status.failed_deploy', cls: 'border-red-500/30 dg-text-red bg-red-500/10' },
rolled_back: { icon: RotateCcw, label: '已回滚', cls: 'border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10' }, rolled_back: { icon: RotateCcw, labelKey: 'deploy.status.rolledBack', cls: 'border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10' },
}; };
const cfg = map[status]; const cfg = map[status];
if (!cfg) return null; if (!cfg) return null;
@@ -35,17 +43,17 @@ export function getStatusBadge( status: string) {
return ( return (
<Badge variant="outline" className={cfg.cls}> <Badge variant="outline" className={cfg.cls}>
<Icon className="h-3 w-3 mr-1" /> <Icon className="h-3 w-3 mr-1" />
{cfg.label} {t(cfg.labelKey)}
</Badge> </Badge>
); );
} }
// 环境徽章 // Logan.Li // 环境徽章 // Logan.Li
export function getEnvironmentBadge( env: string) { export function getEnvironmentBadge(env: string, t: TFunction) {
const cfg = ENVIRONMENTS[env]; const cfg = ENVIRONMENTS[env];
return ( return (
<Badge variant="outline" className={cfg?.color || 'border-[var(--dg-border)] dg-text-secondary'}> <Badge variant="outline" className={cfg?.color || 'border-[var(--dg-border)] dg-text-secondary'}>
{cfg?.label || env} {ENV_LABEL_KEY[env] ? t(ENV_LABEL_KEY[env]) : env}
</Badge> </Badge>
); );
} }
@@ -1,4 +1,5 @@
// YAML 查看/编辑统一工具栏:复制 · 下载 · 编辑 · 保存 // Logan.Li // YAML 查看/编辑统一工具栏:复制 · 下载 · 编辑 · 保存 // Logan.Li
import { useTranslation } from 'react-i18next';
import { Copy, Check, Save, Loader2, Pencil, Download } from 'lucide-react'; import { Copy, Check, Save, Loader2, Pencil, Download } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -23,29 +24,30 @@ export function YamlEditorToolbar({
readOnly = false, showRawToggle = true, readOnly = false, showRawToggle = true,
onCopy, onDownload, onEdit, onCancel, onSave, onToggleRaw, onCopy, onDownload, onEdit, onCancel, onSave, onToggleRaw,
}: YamlEditorToolbarProps) { }: YamlEditorToolbarProps) {
const { t } = useTranslation();
const sz = compact ? 'h-7 text-xs' : 'h-8'; const sz = compact ? 'h-7 text-xs' : 'h-8';
return ( return (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" size="sm" className={`${sz} border-[var(--dg-border)] text-[var(--dg-text-secondary)]`} onClick={onCopy}> <Button variant="outline" size="sm" className={`${sz} border-[var(--dg-border)] text-[var(--dg-text-secondary)]`} onClick={onCopy}>
{copied ? <Check className="h-3 w-3 mr-1 dg-text-green" /> : <Copy className="h-3 w-3 mr-1" />} {copied ? <Check className="h-3 w-3 mr-1 dg-text-green" /> : <Copy className="h-3 w-3 mr-1" />}
{copied ? '已复制' : '复制'} {copied ? t('common.copied') : t('common.copy')}
</Button> </Button>
{onDownload && ( {onDownload && (
<Button variant="outline" size="sm" className={`${sz} border-[var(--dg-border)] text-[var(--dg-text-secondary)]`} onClick={onDownload}> <Button variant="outline" size="sm" className={`${sz} border-[var(--dg-border)] text-[var(--dg-text-secondary)]`} onClick={onDownload}>
<Download className="h-3 w-3 mr-1" />下载 <Download className="h-3 w-3 mr-1" />{t('common.download')}
</Button> </Button>
)} )}
{!readOnly && ( {!readOnly && (
!editing ? ( !editing ? (
<Button variant="outline" size="sm" className={`${sz} border-[var(--dg-border)] text-[var(--dg-text-secondary)]`} onClick={onEdit}> <Button variant="outline" size="sm" className={`${sz} border-[var(--dg-border)] text-[var(--dg-text-secondary)]`} onClick={onEdit}>
<Pencil className="h-3 w-3 mr-1" />编辑 <Pencil className="h-3 w-3 mr-1" />{t('common.edit')}
</Button> </Button>
) : ( ) : (
<> <>
<Button variant="outline" size="sm" className={`${sz} border-[var(--dg-border)] text-[var(--dg-text-secondary)]`} onClick={onCancel}>取消</Button> <Button variant="outline" size="sm" className={`${sz} border-[var(--dg-border)] text-[var(--dg-text-secondary)]`} onClick={onCancel}>{t('common.cancel')}</Button>
<Button size="sm" className={`${sz} bg-blue-600 hover:bg-blue-700`} onClick={onSave} disabled={saving}> <Button size="sm" className={`${sz} bg-blue-600 hover:bg-blue-700`} onClick={onSave} disabled={saving}>
{saving ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Save className="h-3 w-3 mr-1" />} {saving ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Save className="h-3 w-3 mr-1" />}
保存 {t('common.save')}
</Button> </Button>
</> </>
) )
@@ -56,7 +58,7 @@ export function YamlEditorToolbar({
onClick={onToggleRaw} onClick={onToggleRaw}
className="text-[11px] dg-text-muted hover:text-[var(--dg-text-secondary)] underline-offset-2 hover:underline" className="text-[11px] dg-text-muted hover:text-[var(--dg-text-secondary)] underline-offset-2 hover:underline"
> >
{showRaw ? '精简视图' : '原始 YAML'} {showRaw ? t('k8s.yamlToolbar.compactView') : t('k8s.yamlToolbar.rawYaml')}
</button> </button>
)} )}
</div> </div>
@@ -1,4 +1,5 @@
// DeploymentHpaTab.tsx — Deployment 详情 HPA Tab // Logan.Li // DeploymentHpaTab.tsx — Deployment 详情 HPA Tab // Logan.Li
import { useTranslation } from 'react-i18next';
import { Gauge, Trash2 } from 'lucide-react'; import { Gauge, Trash2 } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -26,12 +27,13 @@ export function DeploymentHpaTab({
onCreated, onCreated,
onDeleteHpa, onDeleteHpa,
}: DeploymentHpaTabProps) { }: DeploymentHpaTabProps) {
const { t } = useTranslation();
return ( return (
<TabsContent value="hpa"> <TabsContent value="hpa">
<Card className="dg-card"> <Card className="dg-card">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-sm dg-text-secondary">HPA 自动扩缩容</CardTitle> <CardTitle className="text-sm dg-text-secondary">{t('k8s.deploymentDetail.hpaTitle')}</CardTitle>
<CreateHPADialog <CreateHPADialog
clusterCode={clusterCode} clusterCode={clusterCode}
namespace={namespace} namespace={namespace}
@@ -44,18 +46,18 @@ export function DeploymentHpaTab({
{hpas.length === 0 ? ( {hpas.length === 0 ? (
<div className="text-center py-12 dg-text-muted"> <div className="text-center py-12 dg-text-muted">
<Gauge className="h-8 w-8 mx-auto mb-2 opacity-50" /> <Gauge className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>未配置 HPA</p> <p>{t('k8s.deploymentDetail.noHpa')}</p>
<p className="text-xs mt-1">点击右上角「创建 HPA」设置自动扩缩容</p> <p className="text-xs mt-1">{t('k8s.deploymentDetail.createHpaHint')}</p>
</div> </div>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-[var(--dg-border)] hover:bg-transparent"> <TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="dg-text-secondary">名称</TableHead> <TableHead className="dg-text-secondary">{t('k8s.column.name')}</TableHead>
<TableHead className="dg-text-secondary">最小/最大</TableHead> <TableHead className="dg-text-secondary">{t('k8s.hpaCronHpa.col.minMax')}</TableHead>
<TableHead className="dg-text-secondary">指标</TableHead> <TableHead className="dg-text-secondary">{t('k8s.hpaCronHpa.col.metrics')}</TableHead>
<TableHead className="dg-text-secondary">当前/目标</TableHead> <TableHead className="dg-text-secondary">{t('k8s.deploymentDetail.currentTarget')}</TableHead>
<TableHead className="dg-text-secondary">操作</TableHead> <TableHead className="dg-text-secondary">{t('common.actions')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -74,7 +76,7 @@ export function DeploymentHpaTab({
<TableCell className="text-[var(--dg-text-secondary)]">{hpa.current_replicas || 0}</TableCell> <TableCell className="text-[var(--dg-text-secondary)]">{hpa.current_replicas || 0}</TableCell>
<TableCell> <TableCell>
<Button variant="ghost" size="icon" className="h-7 w-7 dg-text-red hover:dg-text-red" <Button variant="ghost" size="icon" className="h-7 w-7 dg-text-red hover:dg-text-red"
onClick={() => onDeleteHpa(hpa)} title="删除 HPA"> onClick={() => onDeleteHpa(hpa)} title={t('k8s.deploymentDetail.deleteHpa')}>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</TableCell> </TableCell>
@@ -1,4 +1,5 @@
// DeploymentLogsTab.tsx — Deployment 详情日志 Tab // Logan.Li // DeploymentLogsTab.tsx — Deployment 详情日志 Tab // Logan.Li
import { useTranslation } from 'react-i18next';
import { FileText, ExternalLink } from 'lucide-react'; import { FileText, ExternalLink } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -31,19 +32,20 @@ export function DeploymentLogsTab({
onLogsTabPodChange, onLogsTabPodChange,
onOpenLogsViewer, onOpenLogsViewer,
}: DeploymentLogsTabProps) { }: DeploymentLogsTabProps) {
const { t } = useTranslation();
return ( return (
<TabsContent value="logs"> <TabsContent value="logs">
<Card className="dg-card"> <Card className="dg-card">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-sm dg-text-secondary">Pod 日志</CardTitle> <CardTitle className="text-sm dg-text-secondary">{t('k8s.deploymentDetail.podLogs')}</CardTitle>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs dg-text-muted">最近</span> <span className="text-xs dg-text-muted">{t('k8s.deploymentDetail.recent')}</span>
<Input <Input
type="number" value={logTail} onChange={(e) => onLogTailChange(Number(e.target.value))} type="number" value={logTail} onChange={(e) => onLogTailChange(Number(e.target.value))}
className="w-20 h-7 dg-input text-xs" className="w-20 h-7 dg-input text-xs"
/> />
<span className="text-xs dg-text-muted">行</span> <span className="text-xs dg-text-muted">{t('k8s.deploymentDetail.lines')}</span>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
@@ -51,15 +53,15 @@ export function DeploymentLogsTab({
{pods.length === 0 ? ( {pods.length === 0 ? (
<div className="text-center py-12 dg-text-muted"> <div className="text-center py-12 dg-text-muted">
<FileText className="h-8 w-8 mx-auto mb-2 opacity-50" /> <FileText className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>暂无 Pod</p> <p>{t('k8s.deploymentDetail.noPod')}</p>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-3 py-10 justify-center flex-wrap"> <div className="flex items-center gap-3 py-10 justify-center flex-wrap">
<FileText className="h-5 w-5 dg-text-blue" /> <FileText className="h-5 w-5 dg-text-blue" />
<span className="text-sm dg-text-secondary">选择 Pod 打开增强版日志查看器</span> <span className="text-sm dg-text-secondary">{t('k8s.deploymentDetail.selectPodHint')}</span>
<Select value={logsTabPod} onValueChange={onLogsTabPodChange}> <Select value={logsTabPod} onValueChange={onLogsTabPodChange}>
<SelectTrigger className="w-[280px] h-8 dg-input text-xs"> <SelectTrigger className="w-[280px] h-8 dg-input text-xs">
<SelectValue placeholder="选择 Pod…" /> <SelectValue placeholder={t('k8s.deploymentDetail.selectPodPlaceholder')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{pods.map((p: JsonData) => ( {pods.map((p: JsonData) => (
@@ -76,10 +78,10 @@ export function DeploymentLogsTab({
onOpenLogsViewer(logsTabPod, pod ? extractContainers(pod) : []); onOpenLogsViewer(logsTabPod, pod ? extractContainers(pod) : []);
}} }}
> >
<ExternalLink className="h-3 w-3 mr-1" />打开日志 <ExternalLink className="h-3 w-3 mr-1" />{t('k8s.deploymentDetail.openLogs')}
</Button> </Button>
<span className="text-xs dg-text-muted block w-full text-center mt-2"> <span className="text-xs dg-text-muted block w-full text-center mt-2">
支持实时跟踪、多容器切换、previous 日志、关键字过滤、导出 {t('k8s.deploymentDetail.logsFeatures')}
</span> </span>
</div> </div>
)} )}
@@ -1,4 +1,5 @@
// DeploymentPodsTab.tsx — Deployment 详情 Pod 列表 Tab // Logan.Li // DeploymentPodsTab.tsx — Deployment 详情 Pod 列表 Tab // Logan.Li
import { useTranslation } from 'react-i18next';
import { FileText, Terminal, FolderOpen, FileCode } from 'lucide-react'; import { FileText, Terminal, FolderOpen, FileCode } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -20,6 +21,7 @@ export interface DeploymentPodsTabProps {
} }
export function DeploymentPodsTab({ pods, onOpenLogsViewer, onOpenExec, onOpenFileBrowser, onOpenYaml }: DeploymentPodsTabProps) { export function DeploymentPodsTab({ pods, onOpenLogsViewer, onOpenExec, onOpenFileBrowser, onOpenYaml }: DeploymentPodsTabProps) {
const { t } = useTranslation();
return ( return (
<TabsContent value="pods"> <TabsContent value="pods">
<Card className="dg-card"> <Card className="dg-card">
@@ -27,19 +29,19 @@ export function DeploymentPodsTab({ pods, onOpenLogsViewer, onOpenExec, onOpenFi
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-[var(--dg-border)] hover:bg-transparent"> <TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="dg-text-secondary">Pod 名称</TableHead> <TableHead className="dg-text-secondary">{t('k8s.deploymentDetail.podName')}</TableHead>
<TableHead className="dg-text-secondary">状态</TableHead> <TableHead className="dg-text-secondary">{t('k8s.column.status')}</TableHead>
<TableHead className="dg-text-secondary">节点</TableHead> <TableHead className="dg-text-secondary">{t('k8s.column.node')}</TableHead>
<TableHead className="dg-text-secondary">IP</TableHead> <TableHead className="dg-text-secondary">IP</TableHead>
<TableHead className="dg-text-secondary">重启</TableHead> <TableHead className="dg-text-secondary">{t('k8s.deploymentDetail.restart')}</TableHead>
<TableHead className="dg-text-secondary">操作</TableHead> <TableHead className="dg-text-secondary">{t('common.actions')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{pods.length === 0 ? ( {pods.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-center dg-text-muted py-8"> <TableCell colSpan={6} className="text-center dg-text-muted py-8">
暂无 Pod {t('k8s.deploymentDetail.noPod')}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
@@ -61,22 +63,22 @@ export function DeploymentPodsTab({ pods, onOpenLogsViewer, onOpenExec, onOpenFi
<TableCell> <TableCell>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7 dg-text-secondary hover:dg-text-blue" <Button variant="ghost" size="icon" className="h-7 w-7 dg-text-secondary hover:dg-text-blue"
title="查看日志" title={t('k8s.deploymentDetail.viewLogs')}
onClick={() => onOpenLogsViewer({ name: pod.name, containers: extractContainers(pod) })}> onClick={() => onOpenLogsViewer({ name: pod.name, containers: extractContainers(pod) })}>
<FileText className="h-3.5 w-3.5" /> <FileText className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7 dg-text-secondary hover:dg-text-green" <Button variant="ghost" size="icon" className="h-7 w-7 dg-text-secondary hover:dg-text-green"
title="exec 终端" title={t('k8s.deploymentDetail.execTerminal')}
onClick={() => onOpenExec({ name: pod.name, containers: extractContainers(pod) })}> onClick={() => onOpenExec({ name: pod.name, containers: extractContainers(pod) })}>
<Terminal className="h-3.5 w-3.5" /> <Terminal className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7 dg-text-secondary hover:dg-text-yellow" <Button variant="ghost" size="icon" className="h-7 w-7 dg-text-secondary hover:dg-text-yellow"
title="文件浏览器" title={t('k8s.deploymentDetail.fileBrowser')}
onClick={() => onOpenFileBrowser({ name: pod.name, containers: extractContainers(pod) })}> onClick={() => onOpenFileBrowser({ name: pod.name, containers: extractContainers(pod) })}>
<FolderOpen className="h-3.5 w-3.5" /> <FolderOpen className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7 dg-text-secondary hover:dg-text-blue" <Button variant="ghost" size="icon" className="h-7 w-7 dg-text-secondary hover:dg-text-blue"
title="查看 YAML" title={t('k8s.deploymentDetail.viewYaml')}
onClick={() => onOpenYaml(pod.name)}> onClick={() => onOpenYaml(pod.name)}>
<FileCode className="h-3.5 w-3.5" /> <FileCode className="h-3.5 w-3.5" />
</Button> </Button>
@@ -1,4 +1,5 @@
// DnsHostSection.tsx — DNS 配置 + 主机别名 // Logan.Li // DnsHostSection.tsx — DNS 配置 + 主机别名 // Logan.Li
import { useTranslation } from 'react-i18next';
import { Globe, MapPin, Plus, Trash2 } from 'lucide-react'; import { Globe, MapPin, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -36,6 +37,7 @@ export function DnsHostSection({
hostAliases, hostAliases,
onHostAliasesChange, onHostAliasesChange,
}: DnsHostSectionProps) { }: DnsHostSectionProps) {
const { t } = useTranslation();
const addDnsOption = () => onDnsOptionsChange([...dnsOptions, { name: '', value: '' }]); const addDnsOption = () => onDnsOptionsChange([...dnsOptions, { name: '', value: '' }]);
const updateDnsOption = (index: number, patch: Partial<DnsOptionEntry>) => const updateDnsOption = (index: number, patch: Partial<DnsOptionEntry>) =>
onDnsOptionsChange(dnsOptions.map((o, i) => (i === index ? { ...o, ...patch } : o))); onDnsOptionsChange(dnsOptions.map((o, i) => (i === index ? { ...o, ...patch } : o)));
@@ -53,16 +55,16 @@ export function DnsHostSection({
<Card className="dg-card"> <Card className="dg-card">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2"> <CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<Globe className="h-4 w-4 text-indigo-400" />DNS 配置 (dnsConfig) <Globe className="h-4 w-4 text-indigo-400" />{t('k8s.dnsHost.dnsConfigTitle')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0 space-y-3"> <CardContent className="pt-0 space-y-3">
<p className="text-[11px] dg-text-muted"> <p className="text-[11px] dg-text-muted">
提示:dnsConfig 通常与 <code className="dg-text-secondary">dnsPolicy: None</code> 搭配使用。每行一个值。 {t('k8s.dnsHost.hintPrefix')} <code className="dg-text-secondary">dnsPolicy: None</code> {t('k8s.dnsHost.hintSuffix')}
</p> </p>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<Label className="dg-text-muted text-xs">nameservers(每行一个 IP)</Label> <Label className="dg-text-muted text-xs">{t('k8s.dnsHost.nameservers')}</Label>
<textarea <textarea
value={dnsNameservers} value={dnsNameservers}
onChange={e => onDnsNameserversChange(e.target.value)} onChange={e => onDnsNameserversChange(e.target.value)}
@@ -72,7 +74,7 @@ export function DnsHostSection({
/> />
</div> </div>
<div> <div>
<Label className="dg-text-muted text-xs">searches(搜索域,每行一个)</Label> <Label className="dg-text-muted text-xs">{t('k8s.dnsHost.searches')}</Label>
<textarea <textarea
value={dnsSearches} value={dnsSearches}
onChange={e => onDnsSearchesChange(e.target.value)} onChange={e => onDnsSearchesChange(e.target.value)}
@@ -84,13 +86,13 @@ export function DnsHostSection({
</div> </div>
<div> <div>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<Label className="dg-text-secondary text-xs">options(DNS 选项)</Label> <Label className="dg-text-secondary text-xs">{t('k8s.dnsHost.options')}</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs dg-text-secondary" onClick={addDnsOption}> <Button size="sm" variant="ghost" className="h-6 text-xs dg-text-secondary" onClick={addDnsOption}>
<Plus className="h-3 w-3 mr-1" />添加 <Plus className="h-3 w-3 mr-1" />{t('common.add')}
</Button> </Button>
</div> </div>
{dnsOptions.length === 0 ? ( {dnsOptions.length === 0 ? (
<p className="text-xs dg-text-muted italic">未配置</p> <p className="text-xs dg-text-muted italic">{t('k8s.dnsHost.notConfigured')}</p>
) : ( ) : (
dnsOptions.map((o, i) => ( dnsOptions.map((o, i) => (
<div key={i} className="flex items-center gap-2 mb-1.5"> <div key={i} className="flex items-center gap-2 mb-1.5">
@@ -104,7 +106,7 @@ export function DnsHostSection({
<Input <Input
value={o.value} value={o.value}
onChange={e => updateDnsOption(i, { value: e.target.value })} onChange={e => updateDnsOption(i, { value: e.target.value })}
placeholder="5(可留空)" placeholder={`5${t('k8s.dnsHost.optionalHint')}`}
className="flex-1 h-7 dg-input text-[var(--dg-text-secondary)] text-xs font-mono" className="flex-1 h-7 dg-input text-[var(--dg-text-secondary)] text-xs font-mono"
/> />
<Button size="icon" variant="ghost" className="h-6 w-6 dg-text-red" onClick={() => removeDnsOption(i)}> <Button size="icon" variant="ghost" className="h-6 w-6 dg-text-red" onClick={() => removeDnsOption(i)}>
@@ -120,18 +122,18 @@ export function DnsHostSection({
<Card className="dg-card"> <Card className="dg-card">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2"> <CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<MapPin className="h-4 w-4 dg-text-yellow" />主机别名 (hostAliases) <MapPin className="h-4 w-4 dg-text-yellow" />{t('k8s.dnsHost.hostAliasTitle')}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="pt-0 space-y-2"> <CardContent className="pt-0 space-y-2">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<Label className="dg-text-secondary text-xs">IP → 主机名映射(注入 Pod 的 /etc/hosts)</Label> <Label className="dg-text-secondary text-xs">{t('k8s.dnsHost.hostAliasHint')}</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs dg-text-secondary" onClick={addHostAlias}> <Button size="sm" variant="ghost" className="h-6 text-xs dg-text-secondary" onClick={addHostAlias}>
<Plus className="h-3 w-3 mr-1" />添加 <Plus className="h-3 w-3 mr-1" />{t('common.add')}
</Button> </Button>
</div> </div>
{hostAliases.length === 0 ? ( {hostAliases.length === 0 ? (
<p className="text-xs dg-text-muted italic">未配置</p> <p className="text-xs dg-text-muted italic">{t('k8s.dnsHost.notConfigured')}</p>
) : ( ) : (
hostAliases.map((a, i) => ( hostAliases.map((a, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
@@ -1,4 +1,5 @@
// SchedulingSection.tsx — 调度:节点名称 / 标签选择器 / 节点亲和性 // Logan.Li // SchedulingSection.tsx — 调度:节点名称 / 标签选择器 / 节点亲和性 // Logan.Li
import { useTranslation } from 'react-i18next';
import { Loader2, Plus, Server, Trash2 } from 'lucide-react'; import { Loader2, Plus, Server, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -31,10 +32,10 @@ export interface SchedulingSectionProps {
nodeLabels: Record<string, string[]>; nodeLabels: Record<string, string[]>;
} }
const SCHEDULE_MODES: Array<{ key: ScheduleMode; label: string }> = [ const SCHEDULE_MODES: Array<{ key: ScheduleMode }> = [
{ key: 'nodeName', label: '节点名称' }, { key: 'nodeName' },
{ key: 'nodeSelector', label: '标签选择器' }, { key: 'nodeSelector' },
{ key: 'nodeAffinity', label: '节点亲和性' }, { key: 'nodeAffinity' },
]; ];
export function SchedulingSection({ export function SchedulingSection({
@@ -50,12 +51,13 @@ export function SchedulingSection({
onUpdateLabelSelector, onUpdateLabelSelector,
nodeLabels, nodeLabels,
}: SchedulingSectionProps) { }: SchedulingSectionProps) {
const { t } = useTranslation();
return ( return (
<Card className="dg-card"> <Card className="dg-card">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2"> <CardTitle className="text-sm text-[var(--dg-text-primary)] flex items-center gap-2">
<Server className="h-4 w-4 dg-text-green" />调度 (Scheduling) <Server className="h-4 w-4 dg-text-green" />{t('k8s.scheduling.title')}
</CardTitle> </CardTitle>
<div className="flex items-center gap-1 bg-[var(--dg-bg-tertiary)] rounded-lg p-0.5 border border-[var(--dg-border)]"> <div className="flex items-center gap-1 bg-[var(--dg-bg-tertiary)] rounded-lg p-0.5 border border-[var(--dg-border)]">
{SCHEDULE_MODES.map(m => ( {SCHEDULE_MODES.map(m => (
@@ -68,24 +70,24 @@ export function SchedulingSection({
: 'dg-text-secondary hover:text-[var(--dg-text-secondary)]' : 'dg-text-secondary hover:text-[var(--dg-text-secondary)]'
}`} }`}
> >
{m.label} {t(`k8s.scheduling.mode.${m.key}`)}
</button> </button>
))} ))}
</div> </div>
</div> </div>
<p className="text-xs dg-text-muted mt-1"> <p className="text-xs dg-text-muted mt-1">
{scheduleMode === 'nodeName' && 'Pod 将直接调度到指定节点'} {scheduleMode === 'nodeName' && t('k8s.scheduling.hint.nodeName')}
{scheduleMode === 'nodeSelector' && 'Pod 将调度到匹配标签的节点'} {scheduleMode === 'nodeSelector' && t('k8s.scheduling.hint.nodeSelector')}
{scheduleMode === 'nodeAffinity' && '通过亲和性规则控制 Pod 调度到指定节点'} {scheduleMode === 'nodeAffinity' && t('k8s.scheduling.hint.nodeAffinity')}
</p> </p>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="pt-0">
{scheduleMode === 'nodeName' && ( {scheduleMode === 'nodeName' && (
<div className="space-y-2"> <div className="space-y-2">
<Label className="dg-text-secondary text-xs">请选择节点</Label> <Label className="dg-text-secondary text-xs">{t('k8s.scheduling.selectNode')}</Label>
{nodesLoading ? ( {nodesLoading ? (
<div className="flex items-center gap-1 dg-text-muted text-xs"> <div className="flex items-center gap-1 dg-text-muted text-xs">
<Loader2 className="h-3 w-3 animate-spin" />加载节点列表... <Loader2 className="h-3 w-3 animate-spin" />{t('k8s.scheduling.loadingNodes')}
</div> </div>
) : ( ) : (
<select <select
@@ -93,7 +95,7 @@ export function SchedulingSection({
onChange={e => onNodeNameChange(e.target.value)} onChange={e => onNodeNameChange(e.target.value)}
className="h-8 w-full bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs text-[var(--dg-text-secondary)] px-2" className="h-8 w-full bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs text-[var(--dg-text-secondary)] px-2"
> >
<option value="">(不指定)</option> <option value="">{t('k8s.scheduling.notSpecified')}</option>
{availableNodes.map(n => ( {availableNodes.map(n => (
<option key={n.name} value={n.name}> <option key={n.name} value={n.name}>
{n.name} {n.ip ? `(${n.ip})` : ''} {n.name} {n.ip ? `(${n.ip})` : ''}
@@ -103,7 +105,7 @@ export function SchedulingSection({
)} )}
{nodeName && ( {nodeName && (
<div className="text-[10px] dg-text-muted"> <div className="text-[10px] dg-text-muted">
已选择: <code className="bg-[var(--dg-bg-tertiary)] px-1 py-0.5 rounded dg-text-green">{nodeName}</code> {t('k8s.scheduling.selected')} <code className="bg-[var(--dg-bg-tertiary)] px-1 py-0.5 rounded dg-text-green">{nodeName}</code>
</div> </div>
)} )}
</div> </div>
@@ -112,13 +114,13 @@ export function SchedulingSection({
{scheduleMode === 'nodeSelector' && ( {scheduleMode === 'nodeSelector' && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="dg-text-secondary text-xs">请选择标签</Label> <Label className="dg-text-secondary text-xs">{t('k8s.scheduling.selectLabel')}</Label>
<Button size="sm" variant="ghost" className="h-6 text-xs dg-text-secondary" onClick={onAddLabelSelector}> <Button size="sm" variant="ghost" className="h-6 text-xs dg-text-secondary" onClick={onAddLabelSelector}>
<Plus className="h-3 w-3 mr-1" />添加 <Plus className="h-3 w-3 mr-1" />{t('k8s.scheduling.add')}
</Button> </Button>
</div> </div>
{labelSelectorKv.length === 0 ? ( {labelSelectorKv.length === 0 ? (
<p className="text-xs dg-text-muted italic">未配置标签选择器</p> <p className="text-xs dg-text-muted italic">{t('k8s.scheduling.noLabelSelector')}</p>
) : ( ) : (
labelSelectorKv.map((n, i) => ( labelSelectorKv.map((n, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
@@ -127,7 +129,7 @@ export function SchedulingSection({
onChange={e => onUpdateLabelSelector(i, { key: e.target.value, value: '' })} onChange={e => onUpdateLabelSelector(i, { key: e.target.value, value: '' })}
className="flex-1 h-7 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs text-[var(--dg-text-secondary)] px-1.5" className="flex-1 h-7 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs text-[var(--dg-text-secondary)] px-1.5"
> >
<option value="">选择标签</option> <option value="">{t('k8s.scheduling.selectLabelPlaceholder')}</option>
{Object.entries(nodeLabels).map(([k, vals]) => ( {Object.entries(nodeLabels).map(([k, vals]) => (
<option key={k} value={k}>{k} ({vals.length})</option> <option key={k} value={k}>{k} ({vals.length})</option>
))} ))}
@@ -138,7 +140,7 @@ export function SchedulingSection({
className="flex-1 h-7 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs text-[var(--dg-text-secondary)] px-1.5" className="flex-1 h-7 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs text-[var(--dg-text-secondary)] px-1.5"
disabled={!n.key} disabled={!n.key}
> >
<option value="">选择值</option> <option value="">{t('k8s.scheduling.selectValuePlaceholder')}</option>
{(nodeLabels[n.key] || []).map(v => ( {(nodeLabels[n.key] || []).map(v => (
<option key={v} value={v}>{v}</option> <option key={v} value={v}>{v}</option>
))} ))}
@@ -161,14 +163,14 @@ export function SchedulingSection({
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="dg-text-secondary text-xs"> <Label className="dg-text-secondary text-xs">
请选择标签 <span className="dg-text-muted">(生成 requiredDuringScheduling 规则)</span> {t('k8s.scheduling.selectLabelAffinity')} <span className="dg-text-muted">{t('k8s.scheduling.affinityRuleHint')}</span>
</Label> </Label>
<Button size="sm" variant="ghost" className="h-6 text-xs dg-text-secondary" onClick={onAddLabelSelector}> <Button size="sm" variant="ghost" className="h-6 text-xs dg-text-secondary" onClick={onAddLabelSelector}>
<Plus className="h-3 w-3 mr-1" />添加 <Plus className="h-3 w-3 mr-1" />{t('k8s.scheduling.add')}
</Button> </Button>
</div> </div>
{labelSelectorKv.length === 0 ? ( {labelSelectorKv.length === 0 ? (
<p className="text-xs dg-text-muted italic">未配置亲和性规则</p> <p className="text-xs dg-text-muted italic">{t('k8s.scheduling.noAffinityRule')}</p>
) : ( ) : (
labelSelectorKv.map((n, i) => ( labelSelectorKv.map((n, i) => (
<div key={i} className="flex items-center gap-2"> <div key={i} className="flex items-center gap-2">
@@ -177,7 +179,7 @@ export function SchedulingSection({
onChange={e => onUpdateLabelSelector(i, { key: e.target.value, value: '' })} onChange={e => onUpdateLabelSelector(i, { key: e.target.value, value: '' })}
className="flex-1 h-7 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs text-[var(--dg-text-secondary)] px-1.5" className="flex-1 h-7 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs text-[var(--dg-text-secondary)] px-1.5"
> >
<option value="">选择标签</option> <option value="">{t('k8s.scheduling.selectLabelPlaceholder')}</option>
{Object.entries(nodeLabels).map(([k, vals]) => ( {Object.entries(nodeLabels).map(([k, vals]) => (
<option key={k} value={k}>{k} ({vals.length})</option> <option key={k} value={k}>{k} ({vals.length})</option>
))} ))}
@@ -188,7 +190,7 @@ export function SchedulingSection({
className="flex-1 h-7 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs text-[var(--dg-text-secondary)] px-1.5" className="flex-1 h-7 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded text-xs text-[var(--dg-text-secondary)] px-1.5"
disabled={!n.key} disabled={!n.key}
> >
<option value="">选择值</option> <option value="">{t('k8s.scheduling.selectValuePlaceholder')}</option>
{(nodeLabels[n.key] || []).map(v => ( {(nodeLabels[n.key] || []).map(v => (
<option key={v} value={v}>{v}</option> <option key={v} value={v}>{v}</option>
))} ))}
@@ -1,13 +1,15 @@
// deployment-view/EventsTab.tsx — 事件 Tab // Logan.Li // deployment-view/EventsTab.tsx — 事件 Tab // Logan.Li
import { useTranslation } from 'react-i18next';
import { EventTable } from '../EventTable'; import { EventTable } from '../EventTable';
import type { EventItem } from './types'; import type { EventItem } from './types';
export function EventsTab({ events, loading }: { events: EventItem[]; loading: string }) { export function EventsTab({ events, loading }: { events: EventItem[]; loading: string }) {
const { t } = useTranslation();
return ( return (
<EventTable <EventTable
events={events} events={events}
loading={loading} loading={loading}
emptyText="暂无事件" emptyText={t('k8s.empty.events')}
/> />
); );
} }
@@ -1,4 +1,5 @@
// deployment-view/HPACronHPATab.tsx — 容器伸缩 Tab (HPA + CronHPA) // Logan.Li // deployment-view/HPACronHPATab.tsx — 容器伸缩 Tab (HPA + CronHPA) // Logan.Li
import { useTranslation } from 'react-i18next';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -9,6 +10,7 @@ import { Activity, Clock, Loader2 } from 'lucide-react';
import type { JsonData } from '@/types/common'; import type { JsonData } from '@/types/common';
export function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: JsonData[]; cronHpas: JsonData[]; loading: string; replicas: number }) { export function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: JsonData[]; cronHpas: JsonData[]; loading: string; replicas: number }) {
const { t } = useTranslation();
if (loading === 'scale') { if (loading === 'scale') {
return <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>; return <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>;
} }
@@ -17,29 +19,29 @@ export function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: Jso
<div className="space-y-4"> <div className="space-y-4">
{/* 当前副本数提示 */} {/* 当前副本数提示 */}
<div className="flex items-center gap-2 px-2"> <div className="flex items-center gap-2 px-2">
<span className="text-sm text-[var(--dg-text-muted)]">当前副本:</span> <span className="text-sm text-[var(--dg-text-muted)]">{t('k8s.hpaCronHpa.currentReplicas')}</span>
<span className="text-sm font-bold text-[var(--dg-text-primary)]">{replicas}</span> <span className="text-sm font-bold text-[var(--dg-text-primary)]">{replicas}</span>
<span className="text-xs text-[var(--dg-text-muted)]">(可通过 Tab 栏右侧 +/- 手动调整)</span> <span className="text-xs text-[var(--dg-text-muted)]">{t('k8s.hpaCronHpa.scaleHint')}</span>
</div> </div>
{/* HPA 策略 */} {/* HPA 策略 */}
<Card className="dg-card"> <Card className="dg-card">
<CardHeader className="pb-2"><CardTitle className="text-sm flex items-center gap-2"><Activity className="h-4 w-4 text-[var(--dg-orange)]" />HPA — 水平自动伸缩</CardTitle></CardHeader> <CardHeader className="pb-2"><CardTitle className="text-sm flex items-center gap-2"><Activity className="h-4 w-4 text-[var(--dg-orange)]" />{t('k8s.hpaCronHpa.hpaTitle')}</CardTitle></CardHeader>
<CardContent> <CardContent>
{hpas.length === 0 ? ( {hpas.length === 0 ? (
<div className="text-sm text-[var(--dg-text-muted)] py-4"> <div className="text-sm text-[var(--dg-text-muted)] py-4">
未关联 HPA — <Button variant="link" className="h-auto p-0 text-[var(--dg-blue)] text-sm">创建 HPA</Button> {t('k8s.hpaCronHpa.noHpaLinked')}<Button variant="link" className="h-auto p-0 text-[var(--dg-blue)] text-sm">{t('k8s.hpaCronHpa.createHpa')}</Button>
</div> </div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-[var(--dg-border)]"> <TableRow className="border-[var(--dg-border)]">
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead> <TableHead className="text-[var(--dg-text-muted)]">{t('k8s.column.name')}</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">最小/最大</TableHead> <TableHead className="text-[var(--dg-text-muted)]">{t('k8s.hpaCronHpa.col.minMax')}</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">指标</TableHead> <TableHead className="text-[var(--dg-text-muted)]">{t('k8s.hpaCronHpa.col.metrics')}</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">当前负载</TableHead> <TableHead className="text-[var(--dg-text-muted)]">{t('k8s.hpaCronHpa.col.currentLoad')}</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">状态</TableHead> <TableHead className="text-[var(--dg-text-muted)]">{t('k8s.hpaCronHpa.col.status')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -65,7 +67,7 @@ export function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: Jso
<TableCell className="text-sm font-mono text-[var(--dg-text-secondary)]">{curR}</TableCell> <TableCell className="text-sm font-mono text-[var(--dg-text-secondary)]">{curR}</TableCell>
<TableCell> <TableCell>
<Badge className={ok ? 'bg-[var(--dg-green)]/20 text-[var(--dg-green)]' : 'bg-[var(--dg-red)]/20 text-[var(--dg-red)]'}> <Badge className={ok ? 'bg-[var(--dg-green)]/20 text-[var(--dg-green)]' : 'bg-[var(--dg-red)]/20 text-[var(--dg-red)]'}>
{ok ? '正常' : '异常'} {ok ? t('k8s.hpaCronHpa.status.normal') : t('k8s.hpaCronHpa.status.abnormal')}
</Badge> </Badge>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -80,22 +82,22 @@ export function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: Jso
{/* CronHPA 策略 */} {/* CronHPA 策略 */}
<Card className="dg-card"> <Card className="dg-card">
<CardHeader className="pb-2"><CardTitle className="text-sm flex items-center gap-2"><Clock className="h-4 w-4 text-[var(--dg-blue)]" />CronHPA — 定时伸缩</CardTitle></CardHeader> <CardHeader className="pb-2"><CardTitle className="text-sm flex items-center gap-2"><Clock className="h-4 w-4 text-[var(--dg-blue)]" />{t('k8s.hpaCronHpa.cronHpaTitle')}</CardTitle></CardHeader>
<CardContent> <CardContent>
{cronHpas.length === 0 ? ( {cronHpas.length === 0 ? (
<div className="text-sm text-[var(--dg-text-muted)] py-4"> <div className="text-sm text-[var(--dg-text-muted)] py-4">
未关联 CronHPA — <Button variant="link" className="h-auto p-0 text-[var(--dg-blue)] text-sm">创建 CronHPA</Button> {t('k8s.hpaCronHpa.noCronHpaLinked')}<Button variant="link" className="h-auto p-0 text-[var(--dg-blue)] text-sm">{t('k8s.hpaCronHpa.createCronHpa')}</Button>
</div> </div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-[var(--dg-border)]"> <TableRow className="border-[var(--dg-border)]">
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead> <TableHead className="text-[var(--dg-text-muted)]">{t('k8s.column.name')}</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">目标副本</TableHead> <TableHead className="text-[var(--dg-text-muted)]">{t('k8s.hpaCronHpa.cronCol.targetReplicas')}</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">调度规则</TableHead> <TableHead className="text-[var(--dg-text-muted)]">{t('k8s.hpaCronHpa.cronCol.scheduleRule')}</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">时区</TableHead> <TableHead className="text-[var(--dg-text-muted)]">{t('k8s.hpaCronHpa.cronCol.timezone')}</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">状态</TableHead> <TableHead className="text-[var(--dg-text-muted)]">{t('k8s.hpaCronHpa.cronCol.status')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -111,7 +113,7 @@ export function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: Jso
<TableCell className="text-xs font-mono text-[var(--dg-text-muted)]">{jobs.map((j: JsonData) => j.schedule || '?').join(', ')}</TableCell> <TableCell className="text-xs font-mono text-[var(--dg-text-muted)]">{jobs.map((j: JsonData) => j.schedule || '?').join(', ')}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)]">{tz}</TableCell> <TableCell className="text-xs text-[var(--dg-text-muted)]">{tz}</TableCell>
<TableCell> <TableCell>
<Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)]">已启用</Badge> <Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)]">{t('k8s.label.enabled')}</Badge>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
@@ -1,4 +1,5 @@
// deployment-view/HistoryTab.tsx — 历史版本 Tab // Logan.Li // deployment-view/HistoryTab.tsx — 历史版本 Tab // Logan.Li
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
@@ -9,8 +10,9 @@ import { formatK8sTime } from '../k8s-yaml-utils';
import type { JsonData } from '@/types/common'; import type { JsonData } from '@/types/common';
export function HistoryTab({ revisions, rollbackLoading, onRollback }: { revisions: JsonData[]; rollbackLoading: number | null; onRollback: (rev: JsonData) => void }) { export function HistoryTab({ revisions, rollbackLoading, onRollback }: { revisions: JsonData[]; rollbackLoading: number | null; onRollback: (rev: JsonData) => void }) {
const { t } = useTranslation();
if (revisions.length === 0) { if (revisions.length === 0) {
return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无历史版本</div>; return <div className="text-center text-[var(--dg-text-muted)] py-12">{t('k8s.history.empty')}</div>;
} }
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -18,10 +20,10 @@ export function HistoryTab({ revisions, rollbackLoading, onRollback }: { revisio
<TableHeader> <TableHeader>
<TableRow className="border-[var(--dg-border)]"> <TableRow className="border-[var(--dg-border)]">
<TableHead className="text-[var(--dg-text-muted)] w-24">Revision</TableHead> <TableHead className="text-[var(--dg-text-muted)] w-24">Revision</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">镜像</TableHead> <TableHead className="text-[var(--dg-text-muted)]">{t('k8s.column.image')}</TableHead>
<TableHead className="text-[var(--dg-text-muted)] w-24">状态</TableHead> <TableHead className="text-[var(--dg-text-muted)] w-24">{t('k8s.column.status')}</TableHead>
<TableHead className="text-[var(--dg-text-muted)] w-40">更新时间</TableHead> <TableHead className="text-[var(--dg-text-muted)] w-40">{t('k8s.history.updateTime')}</TableHead>
<TableHead className="text-[var(--dg-text-muted)] w-24">操作</TableHead> <TableHead className="text-[var(--dg-text-muted)] w-24">{t('common.actions')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -34,14 +36,14 @@ export function HistoryTab({ revisions, rollbackLoading, onRollback }: { revisio
<TableCell className="text-xs font-mono text-[var(--dg-text-muted)] max-w-[300px] truncate">{rev.image || rev.images?.[0] || '-'}</TableCell> <TableCell className="text-xs font-mono text-[var(--dg-text-muted)] max-w-[300px] truncate">{rev.image || rev.images?.[0] || '-'}</TableCell>
<TableCell> <TableCell>
{isLatest {isLatest
? <Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)] border-[var(--dg-green)]/30">当前</Badge> ? <Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)] border-[var(--dg-green)]/30">{t('k8s.history.current')}</Badge>
: <Badge variant="outline" className="text-[var(--dg-text-muted)]">历史</Badge>} : <Badge variant="outline" className="text-[var(--dg-text-muted)]">{t('k8s.history.historical')}</Badge>}
</TableCell> </TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)]">{formatK8sTime(rev.created_at || rev.time)}</TableCell> <TableCell className="text-xs text-[var(--dg-text-muted)]">{formatK8sTime(rev.created_at || rev.time)}</TableCell>
<TableCell> <TableCell>
<Button variant="ghost" size="sm" className="h-7 dg-text-yellow" disabled={isLatest || rollbackLoading !== null} onClick={() => onRollback(rev)}> <Button variant="ghost" size="sm" className="h-7 dg-text-yellow" disabled={isLatest || rollbackLoading !== null} onClick={() => onRollback(rev)}>
{rollbackLoading === revision ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <RotateCcw className="h-3 w-3 mr-1" />} {rollbackLoading === revision ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <RotateCcw className="h-3 w-3 mr-1" />}
回滚 {t('k8s.history.rollback')}
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -1,6 +1,7 @@
// deployment-view/PodsTab.tsx — 容器组 Tab // Logan.Li // deployment-view/PodsTab.tsx — 容器组 Tab // Logan.Li
// v2: 虚拟滚动,支持 1000+ Pod 场景流畅渲染 // v2: 虚拟滚动,支持 1000+ Pod 场景流畅渲染
import { useRef } from 'react'; import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -27,6 +28,7 @@ export function PodsTab({
onDeletePod, onDeletePod,
expandedPod, setExpandedPod, expandedPod, setExpandedPod,
}: DeploymentViewProps & { expandedPod: string | null; setExpandedPod: (v: string | null) => void }) { }: DeploymentViewProps & { expandedPod: string | null; setExpandedPod: (v: string | null) => void }) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
@@ -37,7 +39,7 @@ export function PodsTab({
}); });
if (pods.length === 0) { if (pods.length === 0) {
return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无容器组</div>; return <div className="text-center text-[var(--dg-text-muted)] py-12">{t('k8s.pods.empty')}</div>;
} }
const virtualRows = virtualizer.getVirtualItems(); const virtualRows = virtualizer.getVirtualItems();
@@ -52,13 +54,13 @@ export function PodsTab({
style={{ gridTemplateColumns: GRID_COLS }} style={{ gridTemplateColumns: GRID_COLS }}
> >
<span /> <span />
<span>名称</span> <span>{t('k8s.pods.col.name')}</span>
<span>状态</span> <span>{t('k8s.pods.col.status')}</span>
<span>节点</span> <span>{t('k8s.pods.col.node')}</span>
<span>IP</span> <span>IP</span>
<span className="text-center">重启</span> <span className="text-center">{t('k8s.pods.col.restart')}</span>
<span>Age</span> <span>Age</span>
<span className="text-right">操作</span> <span className="text-right">{t('k8s.pods.col.actions')}</span>
</div> </div>
{/* 虚拟滚动列表 */} {/* 虚拟滚动列表 */}
@@ -134,10 +136,10 @@ export function PodsTab({
</span> </span>
<div className="flex items-center justify-end gap-0.5 shrink-0 w-[88px]"> <div className="flex items-center justify-end gap-0.5 shrink-0 w-[88px]">
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 dg-text-green" title="终端" onClick={() => onShowPodTerminal(pod)}> <Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 dg-text-green" title={t('k8s.pods.action.terminal')} onClick={() => onShowPodTerminal(pod)}>
<Terminal className="h-3.5 w-3.5" /> <Terminal className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 dg-text-yellow" title="文件" onClick={() => onShowPodFileBrowser(pod)}> <Button variant="ghost" size="sm" className="h-7 w-7 p-0 shrink-0 dg-text-yellow" title={t('k8s.pods.action.file')} onClick={() => onShowPodFileBrowser(pod)}>
<HardDrive className="h-3.5 w-3.5" /> <HardDrive className="h-3.5 w-3.5" />
</Button> </Button>
<DropdownMenu> <DropdownMenu>
@@ -146,21 +148,21 @@ export function PodsTab({
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 w-7 p-0 shrink-0 text-[var(--dg-text-muted)] hover:text-[var(--dg-blue)]" className="h-7 w-7 p-0 shrink-0 text-[var(--dg-text-muted)] hover:text-[var(--dg-blue)]"
title="更多" title={t('k8s.pods.action.more')}
aria-label="更多" aria-label={t('k8s.pods.action.more')}
> >
<MoreVertical className="h-3.5 w-3.5" /> <MoreVertical className="h-3.5 w-3.5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"> <DropdownMenuContent align="end" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DropdownMenuItem onClick={() => onShowPodLogs(pod)} className="text-[var(--dg-text-primary)]"> <DropdownMenuItem onClick={() => onShowPodLogs(pod)} className="text-[var(--dg-text-primary)]">
<FileText className="h-3.5 w-3.5 mr-2" /> 日志 <FileText className="h-3.5 w-3.5 mr-2" /> {t('k8s.pods.action.logs')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => onShowPodYaml(pod)} className="text-[var(--dg-text-primary)]"> <DropdownMenuItem onClick={() => onShowPodYaml(pod)} className="text-[var(--dg-text-primary)]">
<FileCode className="h-3.5 w-3.5 mr-2" /> YAML <FileCode className="h-3.5 w-3.5 mr-2" /> YAML
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => onDeletePod?.(pod)} className="dg-text-red focus:dg-text-red"> <DropdownMenuItem onClick={() => onDeletePod?.(pod)} className="dg-text-red focus:dg-text-red">
<Trash2 className="h-3.5 w-3.5 mr-2" /> 删除 <Trash2 className="h-3.5 w-3.5 mr-2" /> {t('k8s.pods.action.delete')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -173,7 +175,7 @@ export function PodsTab({
<PodConditionsRow pod={pod} /> <PodConditionsRow pod={pod} />
{containers && containers.length > 0 && ( {containers && containers.length > 0 && (
<div> <div>
<p className="text-xs text-[var(--dg-text-muted)] mb-2">容器</p> <p className="text-xs text-[var(--dg-text-muted)] mb-2">{t('k8s.pods.containers')}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{containers.map((c, idx) => ( {containers.map((c, idx) => (
<div key={idx} className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2.5 text-xs"> <div key={idx} className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2.5 text-xs">
@@ -187,7 +189,7 @@ export function PodsTab({
{c.resources && ( {c.resources && (
<div className="mt-1.5 flex gap-4 text-[var(--dg-text-muted)]"> <div className="mt-1.5 flex gap-4 text-[var(--dg-text-muted)]">
<span>CPU {String((c.resources as Record<string, Record<string, string>>)?.requests?.cpu ?? '0')} / {String((c.resources as Record<string, Record<string, string>>)?.limits?.cpu ?? '—')}</span> <span>CPU {String((c.resources as Record<string, Record<string, string>>)?.requests?.cpu ?? '0')} / {String((c.resources as Record<string, Record<string, string>>)?.limits?.cpu ?? '—')}</span>
<span>内存 {String((c.resources as Record<string, Record<string, string>>)?.requests?.memory ?? '0')} / {String((c.resources as Record<string, Record<string, string>>)?.limits?.memory ?? '—')}</span> <span>{t('k8s.pods.memory')} {String((c.resources as Record<string, Record<string, string>>)?.requests?.memory ?? '0')} / {String((c.resources as Record<string, Record<string, string>>)?.limits?.memory ?? '—')}</span>
</div> </div>
)} )}
</div> </div>
@@ -206,7 +208,7 @@ export function PodsTab({
{/* 行数统计 */} {/* 行数统计 */}
{pods.length > 0 && ( {pods.length > 0 && (
<div className="text-center py-2 text-xs text-[var(--dg-text-muted)] border-t border-[var(--dg-border)]"> <div className="text-center py-2 text-xs text-[var(--dg-text-muted)] border-t border-[var(--dg-border)]">
共 {pods.length} 个容器组 {pods.length > 200 && '· 虚拟滚动渲染'} {t('k8s.pods.totalCount', { count: pods.length })} {pods.length > 200 && `· ${t('k8s.pods.virtualScroll')}`}
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -1,4 +1,5 @@
// deployment-view/ServicesTab.tsx — 访问方式 Tab // Logan.Li // deployment-view/ServicesTab.tsx — 访问方式 Tab // Logan.Li
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
@@ -8,6 +9,7 @@ import type { SvcItem, IngItem } from './types';
import type { JsonData } from '@/types/common'; import type { JsonData } from '@/types/common';
export function ServicesTab({ services, ingresses, loading }: { services: SvcItem[]; ingresses: IngItem[]; loading: string; namespace: string }) { export function ServicesTab({ services, ingresses, loading }: { services: SvcItem[]; ingresses: IngItem[]; loading: string; namespace: string }) {
const { t } = useTranslation();
if (loading === 'services') { if (loading === 'services') {
return <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>; return <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>;
} }
@@ -16,20 +18,20 @@ export function ServicesTab({ services, ingresses, loading }: { services: SvcIte
{/* Services */} {/* Services */}
<div> <div>
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)] mb-3 flex items-center gap-2"> <h3 className="text-sm font-semibold text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
<Network className="h-4 w-4 text-[var(--dg-blue)]" />关联 Service <Network className="h-4 w-4 text-[var(--dg-blue)]" />{t('k8s.services.relatedService')}
</h3> </h3>
{services.length === 0 ? ( {services.length === 0 ? (
<p className="text-sm text-[var(--dg-text-muted)] py-4">暂无匹配的 Service(Deployment 选择器未关联任何 Service)</p> <p className="text-sm text-[var(--dg-text-muted)] py-4">{t('k8s.services.emptyService')}</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-[var(--dg-border)]"> <TableRow className="border-[var(--dg-border)]">
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead> <TableHead className="text-[var(--dg-text-muted)]">{t('k8s.column.name')}</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">类型</TableHead> <TableHead className="text-[var(--dg-text-muted)]">{t('k8s.column.type')}</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">ClusterIP</TableHead> <TableHead className="text-[var(--dg-text-muted)]">ClusterIP</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">端口</TableHead> <TableHead className="text-[var(--dg-text-muted)]">{t('k8s.column.ports')}</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">Selector</TableHead> <TableHead className="text-[var(--dg-text-muted)]">{t('k8s.column.selector')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -55,19 +57,19 @@ export function ServicesTab({ services, ingresses, loading }: { services: SvcIte
{/* Ingresses */} {/* Ingresses */}
<div> <div>
<h3 className="text-sm font-semibold text-[var(--dg-text-primary)] mb-3 flex items-center gap-2"> <h3 className="text-sm font-semibold text-[var(--dg-text-primary)] mb-3 flex items-center gap-2">
<ExternalLink className="h-4 w-4 text-[var(--dg-purple)]" />关联 Ingress <ExternalLink className="h-4 w-4 text-[var(--dg-purple)]" />{t('k8s.services.relatedIngress')}
</h3> </h3>
{ingresses.length === 0 ? ( {ingresses.length === 0 ? (
<p className="text-sm text-[var(--dg-text-muted)] py-4">暂无匹配的 Ingress</p> <p className="text-sm text-[var(--dg-text-muted)] py-4">{t('k8s.services.emptyIngress')}</p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-[var(--dg-border)]"> <TableRow className="border-[var(--dg-border)]">
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead> <TableHead className="text-[var(--dg-text-muted)]">{t('k8s.column.name')}</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">Class</TableHead> <TableHead className="text-[var(--dg-text-muted)]">Class</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">Host</TableHead> <TableHead className="text-[var(--dg-text-muted)]">Host</TableHead>
<TableHead className="text-[var(--dg-text-muted)]">路径</TableHead> <TableHead className="text-[var(--dg-text-muted)]">{t('k8s.column.path')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -1,5 +1,6 @@
// ActionModals.tsx — 调整镜像 / 伸缩 / 删除 弹窗 // ActionModals.tsx — 调整镜像 / 伸缩 / 删除 弹窗
// 作者: Logan.Li // 作者: Logan.Li
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { AlertTriangle, Loader2, Minus, Plus, Trash2 } from 'lucide-react'; import { AlertTriangle, Loader2, Minus, Plus, Trash2 } from 'lucide-react';
@@ -39,6 +40,7 @@ export function ActionModals({
onScaleTo, onScaleTo,
onDelete, onDelete,
}: ActionModalsProps) { }: ActionModalsProps) {
const { t } = useTranslation();
return ( return (
<> <>
{/* ============ 弹窗:调整镜像版本 ============ */} {/* ============ 弹窗:调整镜像版本 ============ */}
@@ -55,9 +57,9 @@ export function ActionModals({
{/* ============ 弹窗:伸缩 ============ */} {/* ============ 弹窗:伸缩 ============ */}
{modal?.kind === 'scale' && ( {modal?.kind === 'scale' && (
<ModalShell title="伸缩 (Scale)" onClose={onClose}> <ModalShell title={t('k8s.actionModal.scaleTitle')} onClose={onClose}>
<div className="space-y-3"> <div className="space-y-3">
<p className="text-xs dg-text-muted">当前副本: {deployment.replicas || 0}</p> <p className="text-xs dg-text-muted">{t('k8s.actionModal.currentReplicas')} {deployment.replicas || 0}</p>
<div className="flex items-center justify-center gap-3 py-3"> <div className="flex items-center justify-center gap-3 py-3">
<Button variant="outline" size="icon" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" <Button variant="outline" size="icon" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
disabled={actionLoading === 'scale' || scaleValue <= 0} disabled={actionLoading === 'scale' || scaleValue <= 0}
@@ -74,9 +76,9 @@ export function ActionModals({
</Button> </Button>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button variant="outline" className="border-[var(--dg-border)] dg-text-secondary" onClick={onClose}>取消</Button> <Button variant="outline" className="border-[var(--dg-border)] dg-text-secondary" onClick={onClose}>{t('common.cancel')}</Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={actionLoading === 'scale'} onClick={onScaleTo}> <Button className="bg-blue-600 hover:bg-blue-700" disabled={actionLoading === 'scale'} onClick={onScaleTo}>
{actionLoading === 'scale' ? <Loader2 className="h-4 w-4 animate-spin" /> : '确认'} {actionLoading === 'scale' ? <Loader2 className="h-4 w-4 animate-spin" /> : t('common.confirm')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -85,21 +87,21 @@ export function ActionModals({
{/* ============ 弹窗:删除 ============ */} {/* ============ 弹窗:删除 ============ */}
{modal?.kind === 'delete' && ( {modal?.kind === 'delete' && (
<ModalShell title="删除 Deployment" onClose={onClose}> <ModalShell title={t('k8s.actionModal.deleteTitle')} onClose={onClose}>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2 dg-text-red"> <div className="flex items-center gap-2 dg-text-red">
<AlertTriangle className="h-5 w-5" /> <AlertTriangle className="h-5 w-5" />
<span className="font-semibold">此操作不可撤销</span> <span className="font-semibold">{t('k8s.actionModal.irreversible')}</span>
</div> </div>
<p className="text-sm dg-text-secondary"> <p className="text-sm dg-text-secondary">
确认删除 Deployment <code className="text-[var(--dg-text-primary)] font-mono">{namespace}/{deploymentName}</code>? {t('k8s.actionModal.deleteConfirm', { namespace, name: deploymentName })}
其管理的所有 Pod 将被一并回收。 {t('k8s.actionModal.deleteConfirmDesc')}
</p> </p>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button variant="outline" className="border-[var(--dg-border)] dg-text-secondary" onClick={onClose}>取消</Button> <Button variant="outline" className="border-[var(--dg-border)] dg-text-secondary" onClick={onClose}>{t('common.cancel')}</Button>
<Button variant="destructive" disabled={actionLoading === 'delete'} onClick={onDelete}> <Button variant="destructive" disabled={actionLoading === 'delete'} onClick={onDelete}>
{actionLoading === 'delete' ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Trash2 className="h-4 w-4 mr-1" />} {actionLoading === 'delete' ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Trash2 className="h-4 w-4 mr-1" />}
确认删除 {t('k8s.actionModal.confirmDelete')}
</Button> </Button>
</div> </div>
</div> </div>
+5 -2
View File
@@ -1,5 +1,6 @@
// 页面级空状态 // Logan.Li // 页面级空状态 // Logan.Li
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface PageEmptyProps { interface PageEmptyProps {
@@ -12,15 +13,17 @@ interface PageEmptyProps {
export function PageEmpty({ export function PageEmpty({
icon, icon,
title = '暂无数据', title,
description, description,
action, action,
className, className,
}: PageEmptyProps) { }: PageEmptyProps) {
const { t } = useTranslation();
const resolvedTitle = title ?? t('common.noData');
return ( return (
<div className={cn('flex flex-col items-center justify-center py-16 text-center', className)}> <div className={cn('flex flex-col items-center justify-center py-16 text-center', className)}>
{icon && <div className="mb-3 opacity-40 text-[var(--text-tertiary)]">{icon}</div>} {icon && <div className="mb-3 opacity-40 text-[var(--text-tertiary)]">{icon}</div>}
<p className="text-sm font-medium text-[var(--text-primary)]">{title}</p> <p className="text-sm font-medium text-[var(--text-primary)]">{resolvedTitle}</p>
{description && <p className="text-sm dg-text-muted mt-1 max-w-sm">{description}</p>} {description && <p className="text-sm dg-text-muted mt-1 max-w-sm">{description}</p>}
{action && <div className="mt-4">{action}</div>} {action && <div className="mt-4">{action}</div>}
</div> </div>
@@ -1,5 +1,6 @@
// 页面级加载态 // Logan.Li // 页面级加载态 // Logan.Li
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface PageLoadingProps { interface PageLoadingProps {
@@ -8,7 +9,9 @@ interface PageLoadingProps {
compact?: boolean; compact?: boolean;
} }
export function PageLoading({ label = '加载中...', className, compact }: PageLoadingProps) { export function PageLoading({ label, className, compact }: PageLoadingProps) {
const { t } = useTranslation();
const text = label ?? t('common.loading');
return ( return (
<div <div
className={cn( className={cn(
@@ -18,7 +21,7 @@ export function PageLoading({ label = '加载中...', className, compact }: Page
)} )}
> >
<Loader2 className="h-6 w-6 animate-spin mb-2" /> <Loader2 className="h-6 w-6 animate-spin mb-2" />
<span className="text-sm">{label}</span> <span className="text-sm">{text}</span>
</div> </div>
); );
} }
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useConfirm } from '@/components/ui/confirm-dialog'; import { useConfirm } from '@/components/ui/confirm-dialog';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -25,6 +26,7 @@ export function AlertEventsTab({
loading: boolean; loading: boolean;
onReload: () => void; onReload: () => void;
}) { }) {
const { t } = useTranslation();
const _confirm = useConfirm(); const _confirm = useConfirm();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [severityFilter, setSeverityFilter] = useState<string>('all'); const [severityFilter, setSeverityFilter] = useState<string>('all');
@@ -45,21 +47,21 @@ export function AlertEventsTab({
return ( return (
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10"> <Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10">
<AlertTriangle className="h-3 w-3 mr-1" /> <AlertTriangle className="h-3 w-3 mr-1" />
严重 {t('monitor.alertEvents.severity.critical')}
</Badge> </Badge>
); );
case 'warning': case 'warning':
return ( return (
<Badge variant="outline" className="border-amber-500/30 dg-text-yellow bg-amber-500/10"> <Badge variant="outline" className="border-amber-500/30 dg-text-yellow bg-amber-500/10">
<AlertTriangle className="h-3 w-3 mr-1" /> <AlertTriangle className="h-3 w-3 mr-1" />
警告 {t('monitor.alertEvents.severity.warning')}
</Badge> </Badge>
); );
case 'info': case 'info':
return ( return (
<Badge variant="outline" className="border-blue-500/30 dg-text-blue bg-blue-500/10"> <Badge variant="outline" className="border-blue-500/30 dg-text-blue bg-blue-500/10">
<Bell className="h-3 w-3 mr-1" /> <Bell className="h-3 w-3 mr-1" />
提示 {t('monitor.alertEvents.severity.info')}
</Badge> </Badge>
); );
default: default:
@@ -72,13 +74,13 @@ export function AlertEventsTab({
case 'firing': case 'firing':
return ( return (
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10 animate-pulse"> <Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10 animate-pulse">
触发中 {t('monitor.alertEvents.status.firing')}
</Badge> </Badge>
); );
case 'resolved': case 'resolved':
return ( return (
<Badge variant="outline" className="border-emerald-500/30 dg-text-green bg-emerald-500/10"> <Badge variant="outline" className="border-emerald-500/30 dg-text-green bg-emerald-500/10">
已恢复 {t('monitor.alertEvents.status.resolved')}
</Badge> </Badge>
); );
default: default:
@@ -102,7 +104,7 @@ export function AlertEventsTab({
<div className="relative w-64"> <div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
<Input <Input
placeholder="搜索告警名称或目标..." placeholder={t('monitor.alertEvents.searchPlaceholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 dg-input" className="pl-9 h-9 dg-input"
@@ -113,19 +115,19 @@ export function AlertEventsTab({
onChange={(e) => setSeverityFilter(e.target.value)} onChange={(e) => setSeverityFilter(e.target.value)}
className="h-9 px-3 rounded-md bg-[var(--dg-bg-tertiary)]/50 border border-[var(--dg-border)] text-[var(--dg-text-primary)] text-sm" className="h-9 px-3 rounded-md bg-[var(--dg-bg-tertiary)]/50 border border-[var(--dg-border)] text-[var(--dg-text-primary)] text-sm"
> >
<option value="all">全部级别</option> <option value="all">{t('monitor.alertEvents.filter.allSeverity')}</option>
<option value="critical">严重</option> <option value="critical">{t('monitor.alertEvents.severity.critical')}</option>
<option value="warning">警告</option> <option value="warning">{t('monitor.alertEvents.severity.warning')}</option>
<option value="info">提示</option> <option value="info">{t('monitor.alertEvents.severity.info')}</option>
</select> </select>
<select <select
value={statusFilter} value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)} onChange={(e) => setStatusFilter(e.target.value)}
className="h-9 px-3 rounded-md bg-[var(--dg-bg-tertiary)]/50 border border-[var(--dg-border)] text-[var(--dg-text-primary)] text-sm" className="h-9 px-3 rounded-md bg-[var(--dg-bg-tertiary)]/50 border border-[var(--dg-border)] text-[var(--dg-text-primary)] text-sm"
> >
<option value="all">全部状态</option> <option value="all">{t('monitor.alertEvents.filter.allStatus')}</option>
<option value="firing">触发中</option> <option value="firing">{t('monitor.alertEvents.status.firing')}</option>
<option value="resolved">已恢复</option> <option value="resolved">{t('monitor.alertEvents.status.resolved')}</option>
</select> </select>
</div> </div>
@@ -136,14 +138,14 @@ export function AlertEventsTab({
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-[var(--dg-border)] hover:bg-transparent"> <TableRow className="border-[var(--dg-border)] hover:bg-transparent">
<TableHead className="dg-text-secondary">级别</TableHead> <TableHead className="dg-text-secondary">{t('monitor.alertEvents.col.severity')}</TableHead>
<TableHead className="dg-text-secondary">告警名称</TableHead> <TableHead className="dg-text-secondary">{t('monitor.alertEvents.col.alertName')}</TableHead>
<TableHead className="dg-text-secondary">状态</TableHead> <TableHead className="dg-text-secondary">{t('monitor.alertEvents.col.status')}</TableHead>
<TableHead className="dg-text-secondary">目标</TableHead> <TableHead className="dg-text-secondary">{t('monitor.alertEvents.col.target')}</TableHead>
<TableHead className="dg-text-secondary">当前值</TableHead> <TableHead className="dg-text-secondary">{t('monitor.alertEvents.col.currentValue')}</TableHead>
<TableHead className="dg-text-secondary">阈值</TableHead> <TableHead className="dg-text-secondary">{t('monitor.alertEvents.col.threshold')}</TableHead>
<TableHead className="dg-text-secondary">开始时间</TableHead> <TableHead className="dg-text-secondary">{t('monitor.alertEvents.col.startTime')}</TableHead>
<TableHead className="dg-text-secondary text-right">操作</TableHead> <TableHead className="dg-text-secondary text-right">{t('monitor.alertEvents.col.actions')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -151,7 +153,7 @@ export function AlertEventsTab({
<TableRow className="border-[var(--dg-border)]"> <TableRow className="border-[var(--dg-border)]">
<TableCell colSpan={8} className="text-center dg-text-muted py-8"> <TableCell colSpan={8} className="text-center dg-text-muted py-8">
<Loader2 className="h-5 w-5 animate-spin inline mr-2" /> <Loader2 className="h-5 w-5 animate-spin inline mr-2" />
加载中... {t('monitor.alertEvents.loading')}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : filteredEvents.map((event) => ( ) : filteredEvents.map((event) => (
@@ -187,7 +189,7 @@ export function AlertEventsTab({
onClick={() => handleAcknowledge(event.id)} onClick={() => handleAcknowledge(event.id)}
> >
<CheckCircle className="h-4 w-4 mr-1" /> <CheckCircle className="h-4 w-4 mr-1" />
确认 {t('monitor.alertEvents.acknowledge')}
</Button> </Button>
)} )}
</div> </div>
@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { Check, ChevronRight } from 'lucide-react'; import { Check, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -18,6 +19,7 @@ export function AlertDetailDialog({
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onAcknowledge: (id: string) => void; onAcknowledge: (id: string) => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl"> <DialogContent className="max-w-3xl">
@@ -26,44 +28,44 @@ export function AlertDetailDialog({
{alert?.title} {alert?.title}
{alert && getSeverityBadge(alert.severity)} {alert && getSeverityBadge(alert.severity)}
</DialogTitle> </DialogTitle>
<DialogDescription className="sr-only">查看告警详情</DialogDescription> <DialogDescription className="sr-only">{t('monitor.alertDetail.viewDetail')}</DialogDescription>
</DialogHeader> </DialogHeader>
{alert && ( {alert && (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label className="text-muted-foreground">描述</Label> <Label className="text-muted-foreground">{t('monitor.alertDetail.description')}</Label>
<p className="mt-1">{alert.description}</p> <p className="mt-1">{alert.description}</p>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label className="text-muted-foreground">来源</Label> <Label className="text-muted-foreground">{t('monitor.alertDetail.source')}</Label>
<p className="font-medium">{alert.source}</p> <p className="font-medium">{alert.source}</p>
</div> </div>
<div> <div>
<Label className="text-muted-foreground">服务</Label> <Label className="text-muted-foreground">{t('monitor.alertDetail.service')}</Label>
<p className="font-medium">{alert.service}</p> <p className="font-medium">{alert.service}</p>
</div> </div>
<div> <div>
<Label className="text-muted-foreground">指标</Label> <Label className="text-muted-foreground">{t('monitor.alertDetail.metric')}</Label>
<p className="font-medium">{alert.metric}</p> <p className="font-medium">{alert.metric}</p>
</div> </div>
<div> <div>
<Label className="text-muted-foreground">当前值 / 阈值</Label> <Label className="text-muted-foreground">{t('monitor.alertDetail.currentValueThreshold')}</Label>
<p className="font-medium">{alert.value} / {alert.threshold}</p> <p className="font-medium">{alert.value} / {alert.threshold}</p>
</div> </div>
<div> <div>
<Label className="text-muted-foreground">开始时间</Label> <Label className="text-muted-foreground">{t('monitor.alertDetail.startTime')}</Label>
<p className="font-medium">{alert.startedAt}</p> <p className="font-medium">{alert.startedAt}</p>
</div> </div>
{alert.resolvedAt && ( {alert.resolvedAt && (
<div> <div>
<Label className="text-muted-foreground">解决时间</Label> <Label className="text-muted-foreground">{t('monitor.alertDetail.resolvedTime')}</Label>
<p className="font-medium">{alert.resolvedAt}</p> <p className="font-medium">{alert.resolvedAt}</p>
</div> </div>
)} )}
</div> </div>
<div> <div>
<Label className="text-muted-foreground">标签</Label> <Label className="text-muted-foreground">{t('monitor.alertDetail.labels')}</Label>
<div className="flex flex-wrap gap-2 mt-1"> <div className="flex flex-wrap gap-2 mt-1">
{Object.entries(alert.labels).map(([k, v]) => ( {Object.entries(alert.labels).map(([k, v]) => (
<Badge key={k} variant="outline">{k}={v}</Badge> <Badge key={k} variant="outline">{k}={v}</Badge>
@@ -72,20 +74,20 @@ export function AlertDetailDialog({
</div> </div>
{alert.runbookUrl && ( {alert.runbookUrl && (
<div> <div>
<Label className="text-muted-foreground">处理手册</Label> <Label className="text-muted-foreground">{t('monitor.alertDetail.runbook')}</Label>
<a href={alert.runbookUrl} target="_blank" rel="noopener noreferrer" className="dg-text-blue hover:underline block mt-1"> <a href={alert.runbookUrl} target="_blank" rel="noopener noreferrer" className="dg-text-blue hover:underline block mt-1">
查看处理手册 → {t('monitor.alertDetail.viewRunbook')} →
</a> </a>
</div> </div>
)} )}
</div> </div>
)} )}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>关闭</Button> <Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.close')}</Button>
{alert?.status === 'firing' && ( {alert?.status === 'firing' && (
<Button onClick={() => { onAcknowledge(alert.id); onOpenChange(false); }}> <Button onClick={() => { onAcknowledge(alert.id); onOpenChange(false); }}>
<Check className="w-4 h-4 mr-2" /> <Check className="w-4 h-4 mr-2" />
确认告警 {t('monitor.acknowledge')}
</Button> </Button>
)} )}
</DialogFooter> </DialogFooter>
@@ -105,12 +107,13 @@ export function AlertSilenceDialog({
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSilence: (id: string, duration: string) => void; onSilence: (id: string, duration: string) => void;
}) { }) {
const { t } = useTranslation();
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>静默告警</DialogTitle> <DialogTitle>{t('monitor.silenceAlert')}</DialogTitle>
<DialogDescription>选择静默持续时间</DialogDescription> <DialogDescription>{t('monitor.alertDetail.selectSilenceDuration')}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-2"> <div className="space-y-2">
{['1h', '4h', '8h', '24h', '7d'].map((duration) => ( {['1h', '4h', '8h', '24h', '7d'].map((duration) => (
@@ -120,7 +123,7 @@ export function AlertSilenceDialog({
className="w-full justify-between" className="w-full justify-between"
onClick={() => alert && onSilence(alert.id, duration)} onClick={() => alert && onSilence(alert.id, duration)}
> >
<span>静默 {duration}</span> <span>{t('monitor.alertDetail.silenceDuration', { duration })}</span>
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</Button> </Button>
))} ))}
@@ -1,5 +1,6 @@
// 数据源管理页面 — Prometheus + Zabbix 双 Tab // Logan.Li // 数据源管理页面 — Prometheus + Zabbix 双 Tab // Logan.Li
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Database } from 'lucide-react'; import { Database } from 'lucide-react';
import { PageShell } from '@/components/layout/PageShell'; import { PageShell } from '@/components/layout/PageShell';
import PrometheusDatasourceManager from '@/components/monitor/datasource/PrometheusDatasourceManager'; import PrometheusDatasourceManager from '@/components/monitor/datasource/PrometheusDatasourceManager';
@@ -7,6 +8,7 @@ import ZabbixDatasourceManager from '@/components/monitor/datasource/ZabbixDatas
export default function DatasourcePage() { export default function DatasourcePage() {
const [activeTab, setActiveTab] = useState('prometheus'); const [activeTab, setActiveTab] = useState('prometheus');
const { t } = useTranslation();
const pageTabs = [ const pageTabs = [
{ id: 'prometheus', label: 'Prometheus' }, { id: 'prometheus', label: 'Prometheus' },
@@ -16,8 +18,8 @@ export default function DatasourcePage() {
return ( return (
<PageShell <PageShell
variant="list" variant="list"
title="数据源管理" title={t('monitor.datasource.manageTitle')}
subtitle="管理 Prometheus 与 Zabbix 监控数据源" subtitle={t('monitor.datasource.manageSubtitle')}
icon={<Database className="h-5 w-5" />} icon={<Database className="h-5 w-5" />}
tabs={pageTabs} tabs={pageTabs}
activeTab={activeTab} activeTab={activeTab}
@@ -1,4 +1,5 @@
// LogAlertBell.tsx — 小铃铛告警关联组件 // Logan.Li // LogAlertBell.tsx — 小铃铛告警关联组件 // Logan.Li
import { useTranslation } from 'react-i18next';
import { Bell } from 'lucide-react'; import { Bell } from 'lucide-react';
interface Props { interface Props {
@@ -14,13 +15,14 @@ const SIZES = {
}; };
export function LogAlertBell({ hasAlertRule, onClick, size = 'md' }: Props) { export function LogAlertBell({ hasAlertRule, onClick, size = 'md' }: Props) {
const { t } = useTranslation();
const s = SIZES[size]; const s = SIZES[size];
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className={`relative ${s.btn} flex items-center justify-center rounded-md border transition-colors hover:opacity-80`} className={`relative ${s.btn} flex items-center justify-center rounded-md border transition-colors hover:opacity-80`}
style={{ borderColor: 'var(--dg-border)', background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-muted)' }} style={{ borderColor: 'var(--dg-border)', background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-muted)' }}
title={hasAlertRule ? '已关联告警策略' : '关联告警策略'} title={hasAlertRule ? t('monitor.logAlertBell.linked') : t('monitor.logAlertBell.link')}
> >
<Bell style={{ width: s.icon, height: s.icon }} /> <Bell style={{ width: s.icon, height: s.icon }} />
{hasAlertRule && ( {hasAlertRule && (
@@ -1,5 +1,6 @@
// LogCenter — 日志中心 v4(侧边栏子菜单驱动:Loki查询 / ES查询 / 日志库 / 日志告警)// Logan.Li // LogCenter — 日志中心 v4(侧边栏子菜单驱动:Loki查询 / ES查询 / 日志库 / 日志告警)// Logan.Li
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Navigate, useParams } from 'react-router-dom'; import { Navigate, useParams } from 'react-router-dom';
import { ScrollText } from 'lucide-react'; import { ScrollText } from 'lucide-react';
import { PageShell } from '@/components/layout/PageShell'; import { PageShell } from '@/components/layout/PageShell';
@@ -10,17 +11,18 @@ import { LogAlertsPanel } from './LogAlertsPanel';
import LogRBAC from './LogRBAC'; import LogRBAC from './LogRBAC';
import type { LogEngine } from './types-v3'; import type { LogEngine } from './types-v3';
const SECTION_META: Record<string, { title: string; description: string; engine?: LogEngine }> = { const SECTION_KEYS: Record<string, { titleKey: string; descriptionKey: string; engine?: LogEngine }> = {
loki: { title: 'Loki 查询', description: '对接自建 Loki · LogQL 实时检索', engine: 'loki' }, loki: { titleKey: 'monitor.logCenter.loki', descriptionKey: 'monitor.logCenter.lokiDesc', engine: 'loki' },
es: { title: 'ES 查询', description: '对接自建 Elasticsearch · 索引日志检索', engine: 'elasticsearch' }, es: { titleKey: 'monitor.logCenter.es', descriptionKey: 'monitor.logCenter.esDesc', engine: 'elasticsearch' },
datasources: { title: '日志库', description: '管理日志数据源连接(Loki / Elasticsearch)' }, datasources: { titleKey: 'monitor.logCenter.datasources', descriptionKey: 'monitor.logCenter.datasourcesDesc' },
alerts: { title: '日志告警', description: '基于查询条件的日志告警策略' }, alerts: { titleKey: 'monitor.logCenter.alerts', descriptionKey: 'monitor.logCenter.alertsDesc' },
rbac: { title: '日志权限', description: '按 ES 索引 / Loki Label 正则限制查询范围' }, rbac: { titleKey: 'monitor.logCenter.rbac', descriptionKey: 'monitor.logCenter.rbacDesc' },
}; };
const VALID_TABS = ['loki', 'es', 'datasources', 'alerts', 'rbac']; const VALID_TABS = ['loki', 'es', 'datasources', 'alerts', 'rbac'];
export default function LogCenter() { export default function LogCenter() {
const { t } = useTranslation();
const { section } = useParams<{ section: string }>(); const { section } = useParams<{ section: string }>();
const tab = section && VALID_TABS.includes(section) ? section : null; const tab = section && VALID_TABS.includes(section) ? section : null;
const [alertReloadKey, setAlertReloadKey] = useState(0); const [alertReloadKey, setAlertReloadKey] = useState(0);
@@ -29,14 +31,14 @@ export default function LogCenter() {
if (tab === 'rbac') return <LogRBAC />; if (tab === 'rbac') return <LogRBAC />;
const meta = SECTION_META[tab]; const meta = SECTION_KEYS[tab];
return ( return (
<LogQueryProvider> <LogQueryProvider>
<PageShell <PageShell
variant="workspace" variant="workspace"
title={meta.title} title={t(meta.titleKey)}
subtitle={meta.description} subtitle={t(meta.descriptionKey)}
icon={<ScrollText className="h-5 w-5" />} icon={<ScrollText className="h-5 w-5" />}
contentClassName="flex-1 min-h-0 pb-20" contentClassName="flex-1 min-h-0 pb-20"
> >
@@ -1,15 +1,17 @@
// LogCenterArchBanner — 架构说明:平台不落库海量日志 // Logan.Li // LogCenterArchBanner — 架构说明:平台不落库海量日志 // Logan.Li
import { useTranslation } from 'react-i18next';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
export function LogCenterArchBanner() { export function LogCenterArchBanner() {
const { t } = useTranslation();
return ( return (
<div className="flex gap-3 rounded-lg border border-cyan-500/20 bg-cyan-500/5 px-4 py-3 text-xs text-[var(--dg-text-secondary)] leading-relaxed"> <div className="flex gap-3 rounded-lg border border-cyan-500/20 bg-cyan-500/5 px-4 py-3 text-xs text-[var(--dg-text-secondary)] leading-relaxed">
<Info className="h-4 w-4 shrink-0 dg-text-cyan mt-0.5" /> <Info className="h-4 w-4 shrink-0 dg-text-cyan mt-0.5" />
<div> <div>
<p className="text-[var(--dg-text-primary)] font-medium mb-1">无状态查询网关</p> <p className="text-[var(--dg-text-primary)] font-medium mb-1">{t('monitor.logCenterArch.title')}</p>
<p> <p>
AIOps 不存储 Loki / Elasticsearch 中的海量日志,仅保存<strong className="text-[var(--dg-text-primary)] font-normal">数据源连接</strong>与 {t('monitor.logCenterArch.bodyPrefix')}<strong className="text-[var(--dg-text-primary)] font-normal">{t('monitor.logCenterArch.datasourceConn')}</strong>{t('monitor.logCenterArch.connector')}
<strong className="text-[var(--dg-text-primary)] font-normal">告警策略</strong>。查询时实时转发至外部集群,结果仅在当前页面展示,刷新即清空。 <strong className="text-[var(--dg-text-primary)] font-normal">{t('monitor.logCenterArch.alertPolicy')}</strong>{t('monitor.logCenterArch.bodySuffix')}
</p> </p>
</div> </div>
</div> </div>
@@ -1,5 +1,6 @@
// StatChartPanel.tsx — 统计图表面板(SLS风格)// Logan.Li // StatChartPanel.tsx — 统计图表面板(SLS风格)// Logan.Li
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; import { BarChart, Bar, LineChart, Line, PieChart, Pie, Cell, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { Save, Play, AlertCircle } from 'lucide-react'; import { Save, Play, AlertCircle } from 'lucide-react';
import type { StatTable, ChartType, LogEngine } from './types-v3'; import type { StatTable, ChartType, LogEngine } from './types-v3';
@@ -13,17 +14,10 @@ interface Props {
onSaveAsQuery?: () => void; onSaveAsQuery?: () => void;
} }
const CHART_TYPE_LABELS: Record<ChartType, string> = {
table: '表格',
line: '线图',
bar: '柱状图',
pie: '饼图',
area: '面积图',
};
const PIE_COLORS = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#a855f7', '#06b6d4', '#f97316', '#84cc16']; const PIE_COLORS = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#a855f7', '#06b6d4', '#f97316', '#84cc16'];
export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSaveAsQuery }: Props) { export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSaveAsQuery }: Props) {
const { t } = useTranslation();
const [chartType, setChartType] = useState<ChartType>('table'); const [chartType, setChartType] = useState<ChartType>('table');
const [xAxisField, setXAxisField] = useState(''); const [xAxisField, setXAxisField] = useState('');
const [yAxisField, setYAxisField] = useState(''); const [yAxisField, setYAxisField] = useState('');
@@ -31,6 +25,14 @@ export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSave
const meta = ENGINE_META[engine]; const meta = ENGINE_META[engine];
const chartTypeLabels: Record<ChartType, string> = {
table: t('monitor.statChart.chartType.table'),
line: t('monitor.statChart.chartType.line'),
bar: t('monitor.statChart.chartType.bar'),
pie: t('monitor.statChart.chartType.pie'),
area: t('monitor.statChart.chartType.area'),
};
// 自动检测字段 // Logan.Li // 自动检测字段 // Logan.Li
const columns = statTable?.columns || []; const columns = statTable?.columns || [];
const rows = statTable?.rows || []; const rows = statTable?.rows || [];
@@ -80,20 +82,20 @@ export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSave
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" style={{ color: '#f59e0b' }} /> <AlertCircle className="h-4 w-4" style={{ color: '#f59e0b' }} />
<span className="text-xs" style={{ color: '#f59e0b' }}>当前暂无统计信息,您可以按照样例进行操作</span> <span className="text-xs" style={{ color: '#f59e0b' }}>{t('monitor.statChart.emptyHint')}</span>
</div> </div>
<button <button
onClick={onSaveAsQuery} onClick={onSaveAsQuery}
className="h-7 px-3 rounded text-[11px] flex items-center gap-1" className="h-7 px-3 rounded text-[11px] flex items-center gap-1"
style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-secondary)', background: 'var(--dg-bg-tertiary)' }} style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-secondary)', background: 'var(--dg-bg-tertiary)' }}
> >
<Save className="h-3 w-3" /> 另存为查询 <Save className="h-3 w-3" /> {t('monitor.statChart.saveAsQuery')}
</button> </button>
</div> </div>
{/* 查询样例 */} {/* 查询样例 */}
<div className="mb-4"> <div className="mb-4">
<p className="text-[10px] mb-2" style={{ color: 'var(--dg-text-muted)' }}>查询样例:</p> <p className="text-[10px] mb-2" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.statChart.querySamples')}</p>
<div className="space-y-1"> <div className="space-y-1">
{querySamples.map((q, i) => ( {querySamples.map((q, i) => (
<button <button
@@ -111,7 +113,7 @@ export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSave
{sampleQuery && ( {sampleQuery && (
<div className="flex gap-2"> <div className="flex gap-2">
<button className="h-8 px-4 rounded text-xs font-medium text-white flex items-center gap-1" style={{ background: meta.color }}> <button className="h-8 px-4 rounded text-xs font-medium text-white flex items-center gap-1" style={{ background: meta.color }}>
<Play className="h-3.5 w-3.5" /> 执行分析 <Play className="h-3.5 w-3.5" /> {t('monitor.statChart.runAnalysis')}
</button> </button>
</div> </div>
)} )}
@@ -125,21 +127,21 @@ export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSave
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* 图表类型切换 */} {/* 图表类型切换 */}
<div className="flex items-center gap-1 mb-3"> <div className="flex items-center gap-1 mb-3">
{(Object.keys(CHART_TYPE_LABELS) as ChartType[]).map(t => ( {(Object.keys(chartTypeLabels) as ChartType[]).map(t2 => (
<button <button
key={t} key={t2}
onClick={() => setChartType(t)} onClick={() => setChartType(t2)}
className="px-2.5 py-1 rounded text-[11px] transition-colors" className="px-2.5 py-1 rounded text-[11px] transition-colors"
style={{ style={{
background: chartType === t ? meta.color : 'var(--dg-bg-tertiary)', background: chartType === t2 ? meta.color : 'var(--dg-bg-tertiary)',
color: chartType === t ? '#fff' : 'var(--dg-text-muted)', color: chartType === t2 ? '#fff' : 'var(--dg-text-muted)',
}} }}
> >
{CHART_TYPE_LABELS[t]} {chartTypeLabels[t2]}
</button> </button>
))} ))}
<button onClick={onSaveAsAlert} className="ml-auto h-7 px-2.5 rounded text-[11px] flex items-center gap-1" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}> <button onClick={onSaveAsAlert} className="ml-auto h-7 px-2.5 rounded text-[11px] flex items-center gap-1" style={{ border: '1px solid var(--dg-border)', color: 'var(--dg-text-muted)', background: 'var(--dg-bg-tertiary)' }}>
<Save className="h-3 w-3" /> 另存为告警 <Save className="h-3 w-3" /> {t('monitor.statChart.saveAsAlert')}
</button> </button>
</div> </div>
@@ -168,7 +170,7 @@ export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSave
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="px-3 py-2 text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>总数:{rows.length}</div> <div className="px-3 py-2 text-[10px]" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.statChart.totalCount')}{rows.length}</div>
</div> </div>
)} )}
@@ -223,25 +225,25 @@ export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSave
{/* 右侧:通用配置面板 */} {/* 右侧:通用配置面板 */}
<div className="w-[260px] flex-shrink-0 border rounded-lg p-3" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}> <div className="w-[260px] flex-shrink-0 border rounded-lg p-3" style={{ background: 'var(--dg-bg-secondary)', borderColor: 'var(--dg-border)' }}>
<div className="flex border-b mb-3" style={{ borderColor: 'var(--dg-border)' }}> <div className="flex border-b mb-3" style={{ borderColor: 'var(--dg-border)' }}>
<button className="pb-2 text-xs font-medium" style={{ color: 'var(--dg-blue)', borderBottom: '2px solid var(--dg-blue)' }}>通用配置</button> <button className="pb-2 text-xs font-medium" style={{ color: 'var(--dg-blue)', borderBottom: '2px solid var(--dg-blue)' }}>{t('monitor.statChart.generalConfig')}</button>
</div> </div>
{/* 图表类型 */} {/* 图表类型 */}
<div className="mb-3"> <div className="mb-3">
<p className="text-[10px] mb-1.5" style={{ color: 'var(--dg-text-muted)' }}>图表类型</p> <p className="text-[10px] mb-1.5" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.statChart.chartTypeLabel')}</p>
<div className="grid grid-cols-5 gap-1"> <div className="grid grid-cols-5 gap-1">
{(Object.keys(CHART_TYPE_LABELS) as ChartType[]).map(t => ( {(Object.keys(chartTypeLabels) as ChartType[]).map(t2 => (
<button <button
key={t} key={t2}
onClick={() => setChartType(t)} onClick={() => setChartType(t2)}
className="py-1 rounded text-[9px] text-center transition-colors" className="py-1 rounded text-[9px] text-center transition-colors"
style={{ style={{
background: chartType === t ? 'var(--dg-bg-tertiary)' : 'transparent', background: chartType === t2 ? 'var(--dg-bg-tertiary)' : 'transparent',
color: chartType === t ? 'var(--dg-text-primary)' : 'var(--dg-text-muted)', color: chartType === t2 ? 'var(--dg-text-primary)' : 'var(--dg-text-muted)',
border: chartType === t ? '1px solid var(--dg-border)' : '1px solid transparent', border: chartType === t2 ? '1px solid var(--dg-border)' : '1px solid transparent',
}} }}
> >
{CHART_TYPE_LABELS[t].slice(0, 2)} {chartTypeLabels[t2].slice(0, 2)}
</button> </button>
))} ))}
</div> </div>
@@ -249,7 +251,7 @@ export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSave
{/* 字段配置 */} {/* 字段配置 */}
<div className="mb-3"> <div className="mb-3">
<p className="text-[10px] mb-1.5" style={{ color: 'var(--dg-text-muted)' }}>X 轴字段</p> <p className="text-[10px] mb-1.5" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.statChart.xAxisField')}</p>
<select <select
value={defaultX} value={defaultX}
onChange={e => setXAxisField(e.target.value)} onChange={e => setXAxisField(e.target.value)}
@@ -259,7 +261,7 @@ export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSave
{stringCols.map(c => <option key={c} value={c}>{c}</option>)} {stringCols.map(c => <option key={c} value={c}>{c}</option>)}
</select> </select>
<p className="text-[10px] mb-1.5" style={{ color: 'var(--dg-text-muted)' }}>Y 轴字段</p> <p className="text-[10px] mb-1.5" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.statChart.yAxisField')}</p>
<select <select
value={defaultY} value={defaultY}
onChange={e => setYAxisField(e.target.value)} onChange={e => setYAxisField(e.target.value)}
@@ -272,9 +274,9 @@ export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSave
{/* 当前查询 */} {/* 当前查询 */}
<div> <div>
<p className="text-[10px] mb-1.5" style={{ color: 'var(--dg-text-muted)' }}>当前查询</p> <p className="text-[10px] mb-1.5" style={{ color: 'var(--dg-text-muted)' }}>{t('monitor.statChart.currentQuery')}</p>
<div className="p-2 rounded text-[10px] font-mono break-all" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)' }}> <div className="p-2 rounded text-[10px] font-mono break-all" style={{ background: 'var(--dg-bg-tertiary)', color: 'var(--dg-text-secondary)' }}>
{query || '暂无查询'} {query || t('monitor.statChart.noQuery')}
</div> </div>
</div> </div>
</div> </div>
@@ -1,4 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -63,6 +64,7 @@ export function LogRoleCreateDialog({
onDescriptionChange, onDescriptionChange,
onSubmit, onSubmit,
}: LogRoleCreateDialogProps) { }: LogRoleCreateDialogProps) {
const { t } = useTranslation();
const preview = useMemo( const preview = useMemo(
() => matchSampleScopes(pattern, matchField, engine, SAMPLE_SCOPES), () => matchSampleScopes(pattern, matchField, engine, SAMPLE_SCOPES),
[pattern, matchField, engine], [pattern, matchField, engine],
@@ -70,16 +72,20 @@ export function LogRoleCreateDialog({
const patternHint = useMemo(() => { const patternHint = useMemo(() => {
if (!pattern.trim()) { if (!pattern.trim()) {
return matchField === 'index' ? '输入正则,匹配 ES 索引名' : `输入正则,匹配 Loki ${matchField} Label`; return matchField === 'index'
? t('rbac.logRole.patternHint.indexRegex')
: t('rbac.logRole.patternHint.labelRegex', { field: matchField });
} }
try { try {
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new RegExp(pattern); new RegExp(pattern);
return preview.length > 0 ? `命中 ${preview.length} 个示例范围` : '无匹配示例'; return preview.length > 0
? t('rbac.logRole.patternHint.matched', { count: preview.length })
: t('rbac.logRole.patternHint.noMatch');
} catch { } catch {
return '正则语法错误'; return t('rbac.logRole.patternHint.syntaxError');
} }
}, [pattern, matchField, preview.length]); }, [pattern, matchField, preview.length, t]);
const filteredDS = datasources.filter((ds) => { const filteredDS = datasources.filter((ds) => {
if (engine === '*') return true; if (engine === '*') return true;
@@ -90,40 +96,40 @@ export function LogRoleCreateDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"> <DialogContent className="max-w-md bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
<DialogHeader> <DialogHeader>
<DialogTitle>新建日志权限</DialogTitle> <DialogTitle>{t('rbac.logRole.newTitle')}</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]"> <DialogDescription className="text-[var(--dg-text-muted)]">
按正则匹配 ES 索引或 Loki Label,限制用户可查询的日志范围 {t('rbac.logRole.desc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">名称</Label> <Label className="text-xs">{t('rbac.logRole.label.name')}</Label>
<Input value={name} onChange={(e) => onNameChange(e.target.value)} placeholder="后端日志" className="h-8 text-sm dg-input" /> <Input value={name} onChange={(e) => onNameChange(e.target.value)} placeholder={t('rbac.logRole.placeholder.name')} className="h-8 text-sm dg-input" />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">标识</Label> <Label className="text-xs">{t('rbac.logRole.label.code')}</Label>
<Input value={code} onChange={(e) => onCodeChange(e.target.value)} placeholder="backend-logs" className="h-8 text-sm font-mono dg-input" /> <Input value={code} onChange={(e) => onCodeChange(e.target.value)} placeholder={t('rbac.logRole.placeholder.code')} className="h-8 text-sm font-mono dg-input" />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">引擎</Label> <Label className="text-xs">{t('rbac.logRole.label.engine')}</Label>
<Select value={engine} onValueChange={onEngineChange}> <Select value={engine} onValueChange={onEngineChange}>
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 text-sm dg-input"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="*">全部引擎</SelectItem> <SelectItem value="*">{t('rbac.logRole.engine.all')}</SelectItem>
<SelectItem value="loki">Loki</SelectItem> <SelectItem value="loki">Loki</SelectItem>
<SelectItem value="elasticsearch">Elasticsearch</SelectItem> <SelectItem value="elasticsearch">Elasticsearch</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">匹配维度</Label> <Label className="text-xs">{t('rbac.logRole.label.matchField')}</Label>
<Select value={matchField} onValueChange={onMatchFieldChange}> <Select value={matchField} onValueChange={onMatchFieldChange}>
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 text-sm dg-input"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="index">ES 索引</SelectItem> <SelectItem value="index">{t('rbac.logRole.matchField.index')}</SelectItem>
<SelectItem value="app">Loki app</SelectItem> <SelectItem value="app">Loki app</SelectItem>
<SelectItem value="namespace">Loki namespace</SelectItem> <SelectItem value="namespace">Loki namespace</SelectItem>
<SelectItem value="job">Loki job</SelectItem> <SelectItem value="job">Loki job</SelectItem>
@@ -132,7 +138,7 @@ export function LogRoleCreateDialog({
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">匹配正则</Label> <Label className="text-xs">{t('rbac.logRole.label.pattern')}</Label>
<Input <Input
value={pattern} value={pattern}
onChange={(e) => onPatternChange(e.target.value)} onChange={(e) => onPatternChange(e.target.value)}
@@ -143,11 +149,11 @@ export function LogRoleCreateDialog({
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">数据源(可选)</Label> <Label className="text-xs">{t('rbac.logRole.label.datasource')}</Label>
<Select value={datasourceId || '__all'} onValueChange={(v) => onDatasourceIdChange(v === '__all' ? '' : v)}> <Select value={datasourceId || '__all'} onValueChange={(v) => onDatasourceIdChange(v === '__all' ? '' : v)}>
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder="全部数据源" /></SelectTrigger> <SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder={t('rbac.logRole.datasource.all')} /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__all">全部数据源</SelectItem> <SelectItem value="__all">{t('rbac.logRole.datasource.all')}</SelectItem>
{filteredDS.map((ds) => ( {filteredDS.map((ds) => (
<SelectItem key={ds.id} value={String(ds.id)}>{ds.name} ({ds.type})</SelectItem> <SelectItem key={ds.id} value={String(ds.id)}>{ds.name} ({ds.type})</SelectItem>
))} ))}
@@ -155,19 +161,19 @@ export function LogRoleCreateDialog({
</Select> </Select>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">权限级别</Label> <Label className="text-xs">{t('rbac.logRole.label.roleLevel')}</Label>
<Select value={role} onValueChange={onRoleChange}> <Select value={role} onValueChange={onRoleChange}>
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 text-sm dg-input"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="viewer">查看者 — 只读查询</SelectItem> <SelectItem value="viewer">{t('rbac.logRole.role.viewer')}</SelectItem>
<SelectItem value="analyst">分析者 — 查询 + 告警</SelectItem> <SelectItem value="analyst">{t('rbac.logRole.role.analyst')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs">说明</Label> <Label className="text-xs">{t('rbac.logRole.label.description')}</Label>
<Input value={description} onChange={(e) => onDescriptionChange(e.target.value)} placeholder="可选" className="h-8 text-sm dg-input" /> <Input value={description} onChange={(e) => onDescriptionChange(e.target.value)} placeholder={t('rbac.logRole.placeholder.description')} className="h-8 text-sm dg-input" />
</div> </div>
{preview.length > 0 && ( {preview.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
@@ -180,10 +186,10 @@ export function LogRoleCreateDialog({
)} )}
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button> <Button variant="outline" onClick={() => onOpenChange(false)}>{t('rbac.logRole.cancel')}</Button>
<Button onClick={onSubmit} disabled={saving}> <Button onClick={onSubmit} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Save className="h-4 w-4 mr-1" />} {saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Save className="h-4 w-4 mr-1" />}
创建 {t('rbac.logRole.create')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,6 +1,7 @@
// 定时任务可视化统计面板 — 含健康度与整体成功率条 // Logan.Li // 定时任务可视化统计面板 — 含健康度与整体成功率条 // Logan.Li
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Clock, CheckCircle, XCircle, Activity } from 'lucide-react'; import { Clock, CheckCircle, XCircle, Activity } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { computeSuccessRate } from './cronJobHelpers'; import { computeSuccessRate } from './cronJobHelpers';
import type { CronJobRecord } from '@/types'; import type { CronJobRecord } from '@/types';
@@ -65,6 +66,7 @@ function StatCard({ label, value, sub, icon, borderClass, bgClass, valueClass, r
} }
export function CronJobStatsPanel({ jobs }: Props) { export function CronJobStatsPanel({ jobs }: Props) {
const { t } = useTranslation();
const total = jobs.length; const total = jobs.length;
const enabled = jobs.filter((j) => j.enabled).length; const enabled = jobs.filter((j) => j.enabled).length;
const success = jobs.reduce((sum, j) => sum + (j.success_count || 0), 0); const success = jobs.reduce((sum, j) => sum + (j.success_count || 0), 0);
@@ -84,18 +86,18 @@ export function CronJobStatsPanel({ jobs }: Props) {
<div className="space-y-2"> <div className="space-y-2">
<div className="grid grid-cols-2 lg:grid-cols-5 gap-2"> <div className="grid grid-cols-2 lg:grid-cols-5 gap-2">
<StatCard <StatCard
label="用户任务" label={t('task.cronJobStats.userJobs')}
value={total} value={total}
sub={`${enabled} 个启用中`} sub={t('task.cronJobStats.enabledCount', { count: enabled })}
icon={<Clock className="h-6 w-6 dg-text-blue" />} icon={<Clock className="h-6 w-6 dg-text-blue" />}
borderClass="border-blue-500/20" borderClass="border-blue-500/20"
bgClass="bg-blue-500/5" bgClass="bg-blue-500/5"
valueClass="dg-text-blue" valueClass="dg-text-blue"
/> />
<StatCard <StatCard
label="已启用" label={t('task.cronJobStats.enabled')}
value={enabled} value={enabled}
sub={`占比 ${enabledRate}%`} sub={`${t('task.cronJobStats.ratio')} ${enabledRate}%`}
borderClass="border-emerald-500/20" borderClass="border-emerald-500/20"
bgClass="bg-emerald-500/5" bgClass="bg-emerald-500/5"
valueClass="dg-text-green" valueClass="dg-text-green"
@@ -103,9 +105,9 @@ export function CronJobStatsPanel({ jobs }: Props) {
icon={<Activity className="h-6 w-6 dg-text-green" />} icon={<Activity className="h-6 w-6 dg-text-green" />}
/> />
<StatCard <StatCard
label="任务健康" label={t('task.cronJobStats.health')}
value={`${healthRate}%`} value={`${healthRate}%`}
sub={recentFailed > 0 ? `${recentFailed} 个最近失败` : '全部正常'} sub={recentFailed > 0 ? t('task.cronJobStats.recentFailedCount', { count: recentFailed }) : t('task.cronJobStats.allNormal')}
borderClass={recentFailed > 0 ? 'border-amber-500/20' : 'border-emerald-500/20'} borderClass={recentFailed > 0 ? 'border-amber-500/20' : 'border-emerald-500/20'}
bgClass={recentFailed > 0 ? 'bg-amber-500/5' : 'bg-emerald-500/5'} bgClass={recentFailed > 0 ? 'bg-amber-500/5' : 'bg-emerald-500/5'}
valueClass={recentFailed > 0 ? 'dg-text-yellow' : 'dg-text-green'} valueClass={recentFailed > 0 ? 'dg-text-yellow' : 'dg-text-green'}
@@ -113,18 +115,18 @@ export function CronJobStatsPanel({ jobs }: Props) {
icon={<Activity className="h-6 w-6" />} icon={<Activity className="h-6 w-6" />}
/> />
<StatCard <StatCard
label="累计成功" label={t('task.cronJobStats.totalSuccess')}
value={success} value={success}
sub={totalRuns > 0 ? `成功率 ${successRate}%` : '暂无执行'} sub={totalRuns > 0 ? t('task.cronJobStats.successRate', { rate: successRate }) : t('task.cronJobStats.noExec')}
icon={<CheckCircle className="h-6 w-6 text-emerald-500" />} icon={<CheckCircle className="h-6 w-6 text-emerald-500" />}
borderClass="border-emerald-500/20" borderClass="border-emerald-500/20"
bgClass="bg-emerald-500/5" bgClass="bg-emerald-500/5"
valueClass="text-emerald-500" valueClass="text-emerald-500"
/> />
<StatCard <StatCard
label="累计失败" label={t('task.cronJobStats.totalFailed')}
value={failed} value={failed}
sub={totalRuns > 0 ? `共 ${totalRuns} 次执行` : undefined} sub={totalRuns > 0 ? t('task.cronJobStats.totalRuns', { count: totalRuns }) : undefined}
icon={<XCircle className="h-6 w-6 text-red-500" />} icon={<XCircle className="h-6 w-6 text-red-500" />}
borderClass="border-red-500/20" borderClass="border-red-500/20"
bgClass="bg-red-500/5" bgClass="bg-red-500/5"
@@ -133,7 +135,7 @@ export function CronJobStatsPanel({ jobs }: Props) {
</div> </div>
{totalRuns > 0 && ( {totalRuns > 0 && (
<div className="flex items-center gap-3 px-1"> <div className="flex items-center gap-3 px-1">
<span className="text-[11px] dg-text-muted shrink-0">整体成功率</span> <span className="text-[11px] dg-text-muted shrink-0">{t('task.cronJobStats.overallRate')}</span>
<div className="flex-1 h-1.5 rounded-full bg-[var(--dg-border)] overflow-hidden"> <div className="flex-1 h-1.5 rounded-full bg-[var(--dg-border)] overflow-hidden">
<div <div
className="h-full rounded-full transition-all duration-500" className="h-full rounded-full transition-all duration-500"
@@ -11,6 +11,7 @@ import {
getStatusBadge, getEnabledBadge, getJobTypeBadge, getStatusBadge, getEnabledBadge, getJobTypeBadge,
parseCron, formatRelativeTime, SuccessRateBar, parseCron, formatRelativeTime, SuccessRateBar,
} from './cronJobHelpers'; } from './cronJobHelpers';
import { useTranslation } from 'react-i18next';
import type { CronJobRecord } from '@/types'; import type { CronJobRecord } from '@/types';
interface Props { interface Props {
@@ -34,6 +35,7 @@ export function CronJobTable({
onToggleSelect, onToggleSelectAll, allSelected, onToggleSelect, onToggleSelectAll, allSelected,
onView, onHistory, onExecute, onToggle, onEdit, onDelete, onView, onHistory, onExecute, onToggle, onEdit, onDelete,
}: Props) { }: Props) {
const { t } = useTranslation();
return ( return (
<div className="rounded-lg border border-[var(--dg-border)] overflow-hidden"> <div className="rounded-lg border border-[var(--dg-border)] overflow-hidden">
<Table> <Table>
@@ -100,15 +102,15 @@ export function CronJobTable({
</TableCell> </TableCell>
<TableCell className="py-2 hidden lg:table-cell"> <TableCell className="py-2 hidden lg:table-cell">
<span className="text-xs dg-text-secondary" title={cronExpr}> <span className="text-xs dg-text-secondary" title={cronExpr}>
{parseCron(cronExpr)} {parseCron(cronExpr, t)}
</span> </span>
</TableCell> </TableCell>
<TableCell className="py-2">{getJobTypeBadge(job.type)}</TableCell> <TableCell className="py-2">{getJobTypeBadge(job.type, t)}</TableCell>
<TableCell className="py-2 hidden sm:table-cell">{getEnabledBadge(job.enabled)}</TableCell> <TableCell className="py-2 hidden sm:table-cell">{getEnabledBadge(job.enabled, t)}</TableCell>
<TableCell className="py-2">{getStatusBadge(job.last_status)}</TableCell> <TableCell className="py-2">{getStatusBadge(job.last_status, t)}</TableCell>
<TableCell className="py-2 hidden md:table-cell"> <TableCell className="py-2 hidden md:table-cell">
<span className="text-xs dg-text-muted" title={job.last_run_at ? new Date(job.last_run_at).toLocaleString() : undefined}> <span className="text-xs dg-text-muted" title={job.last_run_at ? new Date(job.last_run_at).toLocaleString() : undefined}>
{formatRelativeTime(job.last_run_at)} {formatRelativeTime(job.last_run_at, t)}
</span> </span>
</TableCell> </TableCell>
<TableCell className="py-2 hidden md:table-cell"> <TableCell className="py-2 hidden md:table-cell">
@@ -11,6 +11,7 @@ import {
getJobTypeBadge, getEnabledBadge, getStatusBadge, getJobTypeBadge, getEnabledBadge, getStatusBadge,
parseCron, formatRelativeTime, SuccessRateBar, computeSuccessRate, parseCron, formatRelativeTime, SuccessRateBar, computeSuccessRate,
} from './cronJobHelpers'; } from './cronJobHelpers';
import { useTranslation } from 'react-i18next';
import type { CronJobRecord } from '@/types'; import type { CronJobRecord } from '@/types';
interface Props { interface Props {
@@ -24,6 +25,7 @@ function asText(v: unknown): string {
} }
export function CronJobViewDialog({ open, onOpenChange, job }: Props) { export function CronJobViewDialog({ open, onOpenChange, job }: Props) {
const { t } = useTranslation();
if (!job) return null; if (!job) return null;
const isScriptType = job.type === 'shell' || job.type === 'python' || job.type === 'powershell'; const isScriptType = job.type === 'shell' || job.type === 'python' || job.type === 'powershell';
@@ -40,15 +42,15 @@ export function CronJobViewDialog({ open, onOpenChange, job }: Props) {
<DialogHeader> <DialogHeader>
<DialogTitle className="truncate pr-6">{job.name}</DialogTitle> <DialogTitle className="truncate pr-6">{job.name}</DialogTitle>
<DialogDescription className="text-[var(--dg-text-secondary)]"> <DialogDescription className="text-[var(--dg-text-secondary)]">
只读预览 · {parseCron(cronExpr)} 只读预览 · {parseCron(cronExpr, t)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{getJobTypeBadge(job.type)} {getJobTypeBadge(job.type, t)}
{getEnabledBadge(job.enabled)} {getEnabledBadge(job.enabled, t)}
{getStatusBadge(job.last_status)} {getStatusBadge(job.last_status, t)}
<span className="text-xs dg-text-muted font-mono">{cronExpr}</span> <span className="text-xs dg-text-muted font-mono">{cronExpr}</span>
</div> </div>
@@ -59,7 +61,7 @@ export function CronJobViewDialog({ open, onOpenChange, job }: Props) {
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2.5 text-xs"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2.5 text-xs">
<div> <div>
<div className="dg-text-muted mb-0.5">上次执行</div> <div className="dg-text-muted mb-0.5">上次执行</div>
<div className="text-[var(--dg-text-primary)]">{formatRelativeTime(job.last_run_at)}</div> <div className="text-[var(--dg-text-primary)]">{formatRelativeTime(job.last_run_at, t)}</div>
</div> </div>
<div> <div>
<div className="dg-text-muted mb-0.5">累计执行</div> <div className="dg-text-muted mb-0.5">累计执行</div>
@@ -17,6 +17,7 @@ import {
} from '@/components/ui/table'; } from '@/components/ui/table';
import { History, Loader2, CheckCircle, XCircle, BarChart3, ChevronDown, ChevronRight } from 'lucide-react'; import { History, Loader2, CheckCircle, XCircle, BarChart3, ChevronDown, ChevronRight } from 'lucide-react';
import { taskExecutionApi } from '@/api'; import { taskExecutionApi } from '@/api';
import { useTranslation } from 'react-i18next';
import { getStatusBadge, formatDuration } from './cronJobHelpers'; import { getStatusBadge, formatDuration } from './cronJobHelpers';
import { TerminalOutput } from './TerminalOutput'; import { TerminalOutput } from './TerminalOutput';
import type { JsonData } from '@/types/common'; import type { JsonData } from '@/types/common';
@@ -71,6 +72,7 @@ export function ExecutionHistoryDialog({
taskId = null, taskId = null,
taskName, taskName,
}: Props) { }: Props) {
const { t } = useTranslation();
const [executions, setExecutions] = useState<JsonData[]>([]); const [executions, setExecutions] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [viewingExecId, setViewingExecId] = useState<number | string | null>(null); const [viewingExecId, setViewingExecId] = useState<number | string | null>(null);
@@ -225,7 +227,7 @@ export function ExecutionHistoryDialog({
{!taskId && ( {!taskId && (
<TableCell className="py-2 text-sm truncate max-w-[200px]">{exec.task_name}</TableCell> <TableCell className="py-2 text-sm truncate max-w-[200px]">{exec.task_name}</TableCell>
)} )}
<TableCell className="py-2">{getStatusBadge(exec.status)}</TableCell> <TableCell className="py-2">{getStatusBadge(exec.status, t)}</TableCell>
<TableCell className="py-2 text-xs dg-text-secondary tabular-nums"> <TableCell className="py-2 text-xs dg-text-secondary tabular-nums">
{formatDuration(exec.duration)} {formatDuration(exec.duration)}
</TableCell> </TableCell>
@@ -1,5 +1,6 @@
// Script 新建/编辑弹窗 // Logan.Li // Script 新建/编辑弹窗 // Logan.Li
import { CheckCircle } from 'lucide-react'; import { CheckCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { import {
@@ -36,33 +37,34 @@ interface Props {
const ScriptFormDialog: React.FC<Props> = ({ const ScriptFormDialog: React.FC<Props> = ({
open, onOpenChange, editingId, editingCode, formData, setFormData, onTypeChange, onSave, runtime, open, onOpenChange, editingId, editingCode, formData, setFormData, onTypeChange, onSave, runtime,
}) => { }) => {
const { t } = useTranslation();
const types = availableScriptTypes(runtime ?? null); const types = availableScriptTypes(runtime ?? null);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col"> <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader> <DialogHeader>
<DialogTitle>{editingId !== null ? '编辑脚本' : '新建脚本'}</DialogTitle> <DialogTitle>{editingId !== null ? t('task.script.editTitle') : t('task.script.newTitle')}</DialogTitle>
<DialogDescription>支持 Shell / Python / PowerShell,编辑器带语法高亮</DialogDescription> <DialogDescription>{t('task.script.desc')}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 overflow-y-auto flex-1 min-h-0 pr-1"> <div className="space-y-4 overflow-y-auto flex-1 min-h-0 pr-1">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label>名称</Label> <Label>{t('task.script.label.name')}</Label>
<Input <Input
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="如:系统巡检脚本" placeholder={t('task.script.placeholder.name')}
/> />
</div> </div>
<div> <div>
<Label>类型</Label> <Label>{t('task.script.label.type')}</Label>
<Select value={formData.type} onValueChange={onTypeChange}> <Select value={formData.type} onValueChange={onTypeChange}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
{types.map(t => ( {types.map((tp) => (
<SelectItem key={t.value} value={t.value} disabled={t.disabled}> <SelectItem key={tp.value} value={tp.value} disabled={tp.disabled}>
{t.label}{t.disabled ? '(环境不可用)' : ''} {tp.label}{tp.disabled ? t('task.script.envUnavailable') : ''}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -70,16 +72,16 @@ const ScriptFormDialog: React.FC<Props> = ({
</div> </div>
</div> </div>
<div> <div>
<Label>描述</Label> <Label>{t('task.script.label.description')}</Label>
<Input <Input
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="脚本用途说明" placeholder={t('task.script.placeholder.description')}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label>超时(秒)</Label> <Label>{t('task.script.label.timeout')}</Label>
<Input <Input
type="number" type="number"
value={formData.timeout} value={formData.timeout}
@@ -89,13 +91,13 @@ const ScriptFormDialog: React.FC<Props> = ({
</div> </div>
{editingId !== null && ( {editingId !== null && (
<div> <div>
<Label>编码</Label> <Label>{t('task.script.label.code')}</Label>
<Input value={editingCode} disabled className="font-mono text-xs" /> <Input value={editingCode} disabled className="font-mono text-xs" />
</div> </div>
)} )}
</div> </div>
<div> <div>
<Label className="mb-2 block">脚本内容</Label> <Label className="mb-2 block">{t('task.script.label.content')}</Label>
<ScriptCodeEditor <ScriptCodeEditor
value={formData.content} value={formData.content}
onChange={(v) => setFormData({ ...formData, content: v })} onChange={(v) => setFormData({ ...formData, content: v })}
@@ -105,10 +107,10 @@ const ScriptFormDialog: React.FC<Props> = ({
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button> <Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
<Button onClick={onSave}> <Button onClick={onSave}>
<CheckCircle className="w-4 h-4 mr-2" /> <CheckCircle className="w-4 h-4 mr-2" />
{editingId !== null ? '保存' : '创建'} {editingId !== null ? t('task.script.save') : t('task.script.create')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -1,6 +1,7 @@
// 脚本运行时环境预检横幅 — 展示 Shell/Python/PowerShell 可用性 // Logan.Li // 脚本运行时环境预检横幅 — 展示 Shell/Python/PowerShell 可用性 // Logan.Li
import { Terminal, AlertTriangle } from 'lucide-react'; import { Terminal, AlertTriangle } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useTranslation } from 'react-i18next';
import type { ScriptRuntimeInfo } from '@/api/task/script'; import type { ScriptRuntimeInfo } from '@/api/task/script';
interface Props { interface Props {
@@ -9,10 +10,11 @@ interface Props {
} }
export function ScriptRuntimeBanner({ runtime, loading }: Props) { export function ScriptRuntimeBanner({ runtime, loading }: Props) {
const { t } = useTranslation();
if (loading || !runtime) { if (loading || !runtime) {
return ( return (
<div className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-4 py-3 text-sm dg-text-muted"> <div className="rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-4 py-3 text-sm dg-text-muted">
正在检测本机脚本运行环境… {t('task.scriptRuntime.detecting')}
</div> </div>
); );
} }
@@ -32,7 +34,7 @@ export function ScriptRuntimeBanner({ runtime, loading }: Props) {
}`}> }`}>
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-primary)]"> <div className="flex items-center gap-2 text-sm text-[var(--dg-text-primary)]">
<Terminal className="h-4 w-4 dg-text-blue" /> <Terminal className="h-4 w-4 dg-text-blue" />
<span className="font-medium">执行环境</span> <span className="font-medium">{t('task.scriptRuntime.envLabel')}</span>
<span className="text-xs dg-text-muted">({runtime.os}/{runtime.arch}{runtime.embed_mode ? ' · embed' : ''})</span> <span className="text-xs dg-text-muted">({runtime.os}/{runtime.arch}{runtime.embed_mode ? ' · embed' : ''})</span>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -43,7 +45,7 @@ export function ScriptRuntimeBanner({ runtime, loading }: Props) {
className={item.ok className={item.ok
? 'border-emerald-500/40 dg-text-green bg-emerald-500/10' ? 'border-emerald-500/40 dg-text-green bg-emerald-500/10'
: 'border-[var(--dg-border)] dg-text-muted bg-[var(--dg-bg-tertiary)]'} : 'border-[var(--dg-border)] dg-text-muted bg-[var(--dg-bg-tertiary)]'}
title={item.path || (item.ok ? '可用' : '未安装')} title={item.path || (item.ok ? t('task.scriptRuntime.available') : t('task.scriptRuntime.notInstalled'))}
> >
{item.label}{item.ok ? ' ✓' : ' ✗'} {item.label}{item.ok ? ' ✓' : ' ✗'}
</Badge> </Badge>
@@ -52,14 +54,14 @@ export function ScriptRuntimeBanner({ runtime, loading }: Props) {
{!coreOk && ( {!coreOk && (
<p className="text-xs dg-text-red flex items-center gap-1 w-full"> <p className="text-xs dg-text-red flex items-center gap-1 w-full">
<AlertTriangle className="h-3.5 w-3.5" /> <AlertTriangle className="h-3.5 w-3.5" />
未检测到 Shell 解释器,脚本无法在本机执行。Docker 部署请确认镜像已安装 bash。 {t('task.scriptRuntime.noShell')}
</p> </p>
)} )}
{coreOk && !runtime.python && ( {coreOk && !runtime.python && (
<p className="text-xs dg-text-muted w-full">Python 未安装,Python 类型脚本将无法执行。</p> <p className="text-xs dg-text-muted w-full">{t('task.scriptRuntime.noPython')}</p>
)} )}
{!allOk && coreOk && ( {!allOk && coreOk && (
<p className="text-xs dg-text-muted w-full">部分解释器不可用,对应类型的脚本创建后无法执行。</p> <p className="text-xs dg-text-muted w-full">{t('task.scriptRuntime.partialUnavailable')}</p>
)} )}
</div> </div>
); );
@@ -8,6 +8,7 @@ import { systemTaskApi, type SystemTaskItem } from '@/api';
import { usePermission } from '@/hooks/usePermission'; import { usePermission } from '@/hooks/usePermission';
import { useConfirm } from '@/components/ui/confirm-dialog'; import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { formatRelativeTime } from './cronJobHelpers'; import { formatRelativeTime } from './cronJobHelpers';
import { import {
ServerCog, Play, Loader2, Shield, Trash2, Clock, CheckCircle, XCircle, AlertCircle, ServerCog, Play, Loader2, Shield, Trash2, Clock, CheckCircle, XCircle, AlertCircle,
@@ -52,6 +53,7 @@ interface Props {
} }
export function SystemTasksPanel({ refreshing }: Props) { export function SystemTasksPanel({ refreshing }: Props) {
const { t } = useTranslation();
const _confirm = useConfirm(); const _confirm = useConfirm();
const { hasAnyPermission, isAdmin } = usePermission(); const { hasAnyPermission, isAdmin } = usePermission();
const canRun = isAdmin || hasAnyPermission(['auto:script', 'auto:cron', 'auto:ansible']); const canRun = isAdmin || hasAnyPermission(['auto:script', 'auto:cron', 'auto:ansible']);
@@ -177,7 +179,7 @@ export function SystemTasksPanel({ refreshing }: Props) {
</div> </div>
<div className="min-w-0 text-[11px] dg-text-muted pl-5 md:pl-0"> <div className="min-w-0 text-[11px] dg-text-muted pl-5 md:pl-0">
<div>{formatRelativeTime(task.last_run_at ?? undefined)}</div> <div>{formatRelativeTime(task.last_run_at ?? undefined, t)}</div>
<div className="flex items-center gap-2 mt-0.5"> <div className="flex items-center gap-2 mt-0.5">
{task.last_duration_ms != null && task.last_duration_ms > 0 && ( {task.last_duration_ms != null && task.last_duration_ms > 0 && (
<span>{task.last_duration_ms}ms</span> <span>{task.last_duration_ms}ms</span>
+27 -25
View File
@@ -1,41 +1,42 @@
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { CheckCircle, XCircle, Loader2 } from 'lucide-react'; import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
import type { TFunction } from 'i18next';
// 获取执行状态徽章(主列表+执行历史共用) // Logan.Li // 获取执行状态徽章(主列表+执行历史共用) // Logan.Li
export function getStatusBadge(status?: string) { export function getStatusBadge(status: string | undefined, t: TFunction) {
switch (status) { switch (status) {
case 'success': case 'success':
return ( return (
<Badge variant="outline" className="border-emerald-500/30 dg-text-green bg-emerald-500/10 h-5 text-[11px] px-1.5 gap-1"> <Badge variant="outline" className="border-emerald-500/30 dg-text-green bg-emerald-500/10 h-5 text-[11px] px-1.5 gap-1">
<CheckCircle className="h-2.5 w-2.5" /> <CheckCircle className="h-2.5 w-2.5" />
成功 {t('task.cronJob.status.success')}
</Badge> </Badge>
); );
case 'failed': case 'failed':
return ( return (
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10 h-5 text-[11px] px-1.5 gap-1"> <Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10 h-5 text-[11px] px-1.5 gap-1">
<XCircle className="h-2.5 w-2.5" /> <XCircle className="h-2.5 w-2.5" />
失败 {t('task.cronJob.status.failed')}
</Badge> </Badge>
); );
case 'running': case 'running':
return ( return (
<Badge variant="outline" className="border-blue-500/30 dg-text-blue bg-blue-500/10 h-5 text-[11px] px-1.5 gap-1 animate-pulse"> <Badge variant="outline" className="border-blue-500/30 dg-text-blue bg-blue-500/10 h-5 text-[11px] px-1.5 gap-1 animate-pulse">
<Loader2 className="h-2.5 w-2.5 animate-spin" /> <Loader2 className="h-2.5 w-2.5 animate-spin" />
执行中 {t('task.cronJob.status.running')}
</Badge> </Badge>
); );
default: default:
return ( return (
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)] h-5 text-[11px] px-1.5"> <Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-muted)] h-5 text-[11px] px-1.5">
未执行 {t('task.cronJob.status.notRun')}
</Badge> </Badge>
); );
} }
} }
// 启用状态徽章 // Logan.Li // 启用状态徽章 // Logan.Li
export function getEnabledBadge(enabled: boolean) { export function getEnabledBadge(enabled: boolean, t: TFunction) {
return ( return (
<Badge <Badge
variant="outline" variant="outline"
@@ -45,14 +46,14 @@ export function getEnabledBadge(enabled: boolean) {
: 'border-[var(--dg-border)] text-[var(--dg-text-muted)] h-5 text-[11px] px-1.5' : 'border-[var(--dg-border)] text-[var(--dg-text-muted)] h-5 text-[11px] px-1.5'
} }
> >
{enabled ? '启用' : '禁用'} {enabled ? t('task.cronJob.enabled') : t('task.cronJob.disabled')}
</Badge> </Badge>
); );
} }
// 定时任务类型徽章 // Logan.Li // 定时任务类型徽章 // Logan.Li
export function getJobTypeBadge(type?: string) { export function getJobTypeBadge(type: string | undefined, t: TFunction) {
const label = getJobTypeLabel(type); const label = getJobTypeLabel(type, t);
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
shell: 'border-blue-500/30 dg-text-blue bg-blue-500/10', shell: 'border-blue-500/30 dg-text-blue bg-blue-500/10',
python: 'border-amber-500/30 dg-text-yellow bg-amber-500/10', python: 'border-amber-500/30 dg-text-yellow bg-amber-500/10',
@@ -70,27 +71,28 @@ export function getJobTypeBadge(type?: string) {
} }
// 定时任务类型中文标签 // Logan.Li // 定时任务类型中文标签 // Logan.Li
export function getJobTypeLabel(type?: string) { export function getJobTypeLabel(type: string | undefined, t: TFunction) {
switch (type) { switch (type) {
case 'shell': return 'Shell'; case 'shell': return 'Shell';
case 'python': return 'Python'; case 'python': return 'Python';
case 'powershell': return 'PowerShell'; case 'powershell': return 'PowerShell';
case 'command': return '命令'; case 'command': return t('task.cronJob.jobType.command');
default: return type || '未知'; default: return type || t('task.cronJob.jobType.unknown');
} }
} }
// Cron 表达式转中文可读描述 // Logan.Li // Cron 表达式转中文可读描述 // Logan.Li
export function parseCron(schedule?: string) { export function parseCron(schedule: string | undefined, t: TFunction) {
if (!schedule) return '—'; if (!schedule) return '—';
const patterns: Record<string, string> = { const patternKey: Record<string, string> = {
'0 2 * * *': '每天 02:00', '0 2 * * *': 'task.cronJob.cron.daily2',
'0 3 * * 0': '每周日 03:00', '0 3 * * 0': 'task.cronJob.cron.weeklySun3',
'*/5 * * * *': '每 5 分钟', '*/5 * * * *': 'task.cronJob.cron.every5min',
'0 * * * *': '每小时', '0 * * * *': 'task.cronJob.cron.everyHour',
'0 9 * * 1': '每周一 09:00', '0 9 * * 1': 'task.cronJob.cron.weeklyMon9',
}; };
return patterns[schedule] || schedule; const key = patternKey[schedule];
return key ? t(key) : schedule;
} }
// 计算成功率(0-100),无数据返回 null // Logan.Li // 计算成功率(0-100),无数据返回 null // Logan.Li
@@ -101,18 +103,18 @@ export function computeSuccessRate(success = 0, fail = 0): number | null {
} }
// 相对时间展示(列表上次执行列) // Logan.Li // 相对时间展示(列表上次执行列) // Logan.Li
export function formatRelativeTime(dateStr?: string): string { export function formatRelativeTime(dateStr: string | undefined, t: TFunction): string {
if (!dateStr) return '—'; if (!dateStr) return '—';
const d = new Date(dateStr); const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return '—'; if (Number.isNaN(d.getTime())) return '—';
const diff = Date.now() - d.getTime(); const diff = Date.now() - d.getTime();
const mins = Math.floor(diff / 60_000); const mins = Math.floor(diff / 60_000);
if (mins < 1) return '刚刚'; if (mins < 1) return t('task.cronJob.time.justNow');
if (mins < 60) return `${mins} 分钟前`; if (mins < 60) return t('task.cronJob.time.minutesAgo', { count: mins });
const hours = Math.floor(mins / 60); const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours} 小时前`; if (hours < 24) return t('task.cronJob.time.hoursAgo', { count: hours });
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
if (days < 7) return `${days} 天前`; if (days < 7) return t('task.cronJob.time.daysAgo', { count: days });
return d.toLocaleString('zh-CN', { return d.toLocaleString('zh-CN', {
hour12: false, hour12: false,
month: '2-digit', month: '2-digit',
@@ -1,5 +1,6 @@
// code-snippet-block.tsx — 代码片段展示 + 外侧复制按钮 // Logan.Li // code-snippet-block.tsx — 代码片段展示 + 外侧复制按钮 // Logan.Li
import { CopyButton } from '@/components/ui/copy-button'; import { CopyButton } from '@/components/ui/copy-button';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface CodeSnippetBlockProps { export interface CodeSnippetBlockProps {
@@ -13,8 +14,10 @@ export interface CodeSnippetBlockProps {
} }
export function CodeSnippetBlock({ export function CodeSnippetBlock({
label, code, copyLabel = '已复制', className, copyDisabled, copyDisabledTitle, label, code, copyLabel, className, copyDisabled, copyDisabledTitle,
}: CodeSnippetBlockProps) { }: CodeSnippetBlockProps) {
const { t } = useTranslation();
const resolvedCopyLabel = copyLabel ?? t('common.copied');
return ( return (
<div className={cn('space-y-1.5', className)}> <div className={cn('space-y-1.5', className)}>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@@ -25,14 +28,14 @@ export function CodeSnippetBlock({
)} )}
<CopyButton <CopyButton
text={code} text={code}
label={copyLabel} label={resolvedCopyLabel}
variant="outline" variant="outline"
size="sm" size="sm"
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] h-7 px-2" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] h-7 px-2"
disabled={copyDisabled} disabled={copyDisabled}
title={copyDisabled ? (copyDisabledTitle || '不可复制') : '复制'} title={copyDisabled ? (copyDisabledTitle || t('ui.codeSnippet.copyDisabled')) : t('common.copy')}
> >
复制 {t('common.copy')}
</CopyButton> </CopyButton>
</div> </div>
<pre className="text-[11px] font-mono text-[var(--dg-text-secondary)] bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-md p-3 overflow-x-auto whitespace-pre-wrap leading-relaxed"> <pre className="text-[11px] font-mono text-[var(--dg-text-secondary)] bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-md p-3 overflow-x-auto whitespace-pre-wrap leading-relaxed">
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,5 @@
// 监控图表工具栏 — 固定卡片/弹框右上角 // Logan.Li // 监控图表工具栏 — 固定卡片/弹框右上角 // Logan.Li
import { useTranslation } from 'react-i18next';
import { Maximize2, ZoomOut } from 'lucide-react'; import { Maximize2, ZoomOut } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -14,6 +15,7 @@ interface Props {
export function ChartToolbar({ export function ChartToolbar({
zoomed, onExpand, onResetZoom, size = 'sm', className, zoomed, onExpand, onResetZoom, size = 'sm', className,
}: Props) { }: Props) {
const { t } = useTranslation();
const btn = size === 'md' ? 'h-8 w-8' : 'h-7 w-7'; const btn = size === 'md' ? 'h-8 w-8' : 'h-7 w-7';
const icon = size === 'md' ? 'h-4 w-4' : 'h-3.5 w-3.5'; const icon = size === 'md' ? 'h-4 w-4' : 'h-3.5 w-3.5';
@@ -25,7 +27,7 @@ export function ChartToolbar({
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn(btn, 'dg-text-secondary hover:dg-text-primary hover:bg-[var(--dg-bg-hover)]')} className={cn(btn, 'dg-text-secondary hover:dg-text-primary hover:bg-[var(--dg-bg-hover)]')}
title="重置缩放" title={t('monitor.chartToolbar.resetZoom')}
onClick={onResetZoom} onClick={onResetZoom}
> >
<ZoomOut className={icon} /> <ZoomOut className={icon} />
@@ -36,7 +38,7 @@ export function ChartToolbar({
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn(btn, 'dg-text-secondary hover:dg-text-blue hover:bg-[var(--dg-bg-hover)]')} className={cn(btn, 'dg-text-secondary hover:dg-text-blue hover:bg-[var(--dg-bg-hover)]')}
title="放大查看" title={t('monitor.chartToolbar.expand')}
onClick={onExpand} onClick={onExpand}
> >
<Maximize2 className={icon} /> <Maximize2 className={icon} />
@@ -1,4 +1,5 @@
// 主机核心指标条 — CPU/内存/磁盘/网络/磁盘IO 一览 // Logan.Li // 主机核心指标条 — CPU/内存/磁盘/网络/磁盘IO 一览 // Logan.Li
import { useTranslation } from 'react-i18next';
import { Cpu, HardDrive, Network, Disc, Server } from 'lucide-react'; import { Cpu, HardDrive, Network, Disc, Server } from 'lucide-react';
import type { JsonData } from '@/types/common'; import type { JsonData } from '@/types/common';
import { formatBPS } from './utils'; import { formatBPS } from './utils';
@@ -17,6 +18,7 @@ function tone(val: number, warn: number, crit: number): string {
} }
export function HostCoreMetricsStrip({ latest, netInfo, diskIOInfo, onJump }: Props) { export function HostCoreMetricsStrip({ latest, netInfo, diskIOInfo, onJump }: Props) {
const { t } = useTranslation();
const cpu = Number(latest?.cpu_usage ?? latest?.cpu ?? 0); const cpu = Number(latest?.cpu_usage ?? latest?.cpu ?? 0);
const mem = Number(latest?.memory_usage ?? latest?.mem_usage ?? latest?.mem ?? 0); const mem = Number(latest?.memory_usage ?? latest?.mem_usage ?? latest?.mem ?? 0);
const disk = Number(latest?.disk_usage ?? latest?.disk ?? 0); const disk = Number(latest?.disk_usage ?? latest?.disk ?? 0);
@@ -27,10 +29,10 @@ export function HostCoreMetricsStrip({ latest, netInfo, diskIOInfo, onJump }: Pr
const items = [ const items = [
{ tab: 'basic', icon: Cpu, label: 'CPU', value: `${cpu.toFixed(1)}%`, cls: tone(cpu, 80, 90) }, { tab: 'basic', icon: Cpu, label: 'CPU', value: `${cpu.toFixed(1)}%`, cls: tone(cpu, 80, 90) },
{ tab: 'basic', icon: Server, label: '内存', value: `${mem.toFixed(1)}%`, cls: tone(mem, 85, 90) }, { tab: 'basic', icon: Server, label: t('monitor.hostMetric.memory'), value: `${mem.toFixed(1)}%`, cls: tone(mem, 85, 90) },
{ tab: 'disk', icon: HardDrive, label: '磁盘', value: `${disk.toFixed(1)}%`, cls: tone(disk, 80, 90) }, { tab: 'disk', icon: HardDrive, label: t('monitor.hostMetric.disk'), value: `${disk.toFixed(1)}%`, cls: tone(disk, 80, 90) },
{ tab: 'network', icon: Network, label: '网络', value: `↓${rx} ↑${tx}`, cls: 'text-[var(--dg-text-primary)]' }, { tab: 'network', icon: Network, label: t('monitor.hostMetric.network'), value: `↓${rx} ↑${tx}`, cls: 'text-[var(--dg-text-primary)]' },
{ tab: 'disk', icon: Disc, label: '磁盘IO', value: `↓${ioR} ↑${ioW}`, cls: 'text-[var(--dg-text-primary)]' }, { tab: 'disk', icon: Disc, label: t('monitor.hostMetric.diskIO'), value: `↓${ioR} ↑${ioW}`, cls: 'text-[var(--dg-text-primary)]' },
]; ];
return ( return (
@@ -1,5 +1,6 @@
// 指标图表卡片 — 工具栏固定标题栏右上角 // Logan.Li // 指标图表卡片 — 工具栏固定标题栏右上角 // Logan.Li
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { ZoomableAreaChart, type ZoomableChartHandle, type ChartReferenceLine } from './ZoomableAreaChart'; import { ZoomableAreaChart, type ZoomableChartHandle, type ChartReferenceLine } from './ZoomableAreaChart';
@@ -27,6 +28,7 @@ function safeChartId(title: string) {
export function MetricChartCard({ export function MetricChartCard({
title, value, icon, iconTone = 'blue', data, color, unit, domain, aggType, referenceLines, title, value, icon, iconTone = 'blue', data, color, unit, domain, aggType, referenceLines,
}: MetricChartCardProps) { }: MetricChartCardProps) {
const { t } = useTranslation();
const chartRef = useRef<ZoomableChartHandle>(null); const chartRef = useRef<ZoomableChartHandle>(null);
const [zoomed, setZoomed] = useState(false); const [zoomed, setZoomed] = useState(false);
const chartData = data.map((d) => ({ ...d, value: d[aggType] })); const chartData = data.map((d) => ({ ...d, value: d[aggType] }));
@@ -60,7 +62,7 @@ export function MetricChartCard({
unit={unit} unit={unit}
chartId={safeChartId(title)} chartId={safeChartId(title)}
title={title} title={title}
subtitle={`当前 ${value}${unit ? ` ${unit}` : ''}`} subtitle={`${t('monitor.metricChart.currentPrefix')} ${value}${unit ? ` ${unit}` : ''}`}
height={188} height={188}
toolbarPlacement="header" toolbarPlacement="header"
onZoomedChange={setZoomed} onZoomedChange={setZoomed}
@@ -1,4 +1,5 @@
// 系统信息 Tab 内容 // Logan.Li // 系统信息 Tab 内容 // Logan.Li
import { useTranslation } from 'react-i18next';
import { Activity, HardDrive } from 'lucide-react'; import { Activity, HardDrive } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { TabsContent } from '@/components/ui/tabs'; import { TabsContent } from '@/components/ui/tabs';
@@ -16,6 +17,7 @@ interface SystemInfoSectionProps {
export function SystemInfoSection({ export function SystemInfoSection({
selectedHost, serverInfo, latest, heartbeatInfo, portInfoCount, selectedHost, serverInfo, latest, heartbeatInfo, portInfoCount,
}: SystemInfoSectionProps) { }: SystemInfoSectionProps) {
const { t } = useTranslation();
const disk = latest?.disk_info ? parseJSON(latest.disk_info) : null; const disk = latest?.disk_info ? parseJSON(latest.disk_info) : null;
const parts = disk ? (Array.isArray(disk) ? disk : (disk.partitions || [])) : []; const parts = disk ? (Array.isArray(disk) ? disk : (disk.partitions || [])) : [];
@@ -25,18 +27,18 @@ export function SystemInfoSection({
<CardContent className="p-4"> <CardContent className="p-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-xs"> <div className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-xs">
{[ {[
['主机名', serverInfo?.hostname || selectedHost], [t('monitor.systemInfo.hostname'), serverInfo?.hostname || selectedHost],
['IP 地址', serverInfo?.ip || '—'], [t('monitor.systemInfo.ipAddress'), serverInfo?.ip || '—'],
['操作系统', serverInfo?.os || '—'], [t('monitor.systemInfo.os'), serverInfo?.os || '—'],
['内核版本', serverInfo?.kernel || '—'], [t('monitor.systemInfo.kernel'), serverInfo?.kernel || '—'],
['CPU 核心数', serverInfo?.cpu_cores || '—'], [t('monitor.systemInfo.cpuCores'), serverInfo?.cpu_cores || '—'],
['总内存', serverInfo?.mem_total ? `${serverInfo.mem_total.toFixed(0)} GB` : '—'], [t('monitor.systemInfo.memTotal'), serverInfo?.mem_total ? `${serverInfo.mem_total.toFixed(0)} GB` : '—'],
['已用内存', serverInfo?.mem_used ? `${serverInfo.mem_used.toFixed(1)} GB` : '—'], [t('monitor.systemInfo.memUsed'), serverInfo?.mem_used ? `${serverInfo.mem_used.toFixed(1)} GB` : '—'],
['运行时长', serverInfo?.uptime ? `${Math.floor(serverInfo.uptime / 86400)}天 ${Math.floor((serverInfo.uptime % 86400) / 3600)}时` : '—'], [t('monitor.systemInfo.uptime'), serverInfo?.uptime ? `${Math.floor(serverInfo.uptime / 86400)}${t('monitor.systemInfo.dayUnit')} ${Math.floor((serverInfo.uptime % 86400) / 3600)}${t('monitor.systemInfo.hourUnit')}` : '—'],
['Agent 版本', heartbeatInfo?.version || serverInfo?.agent_version || '—'], [t('monitor.systemInfo.agentVersion'), heartbeatInfo?.version || serverInfo?.agent_version || '—'],
['Agent 序列号', heartbeatInfo?.report_seq != null ? String(heartbeatInfo.report_seq) : '—'], [t('monitor.systemInfo.agentSeq'), heartbeatInfo?.report_seq != null ? String(heartbeatInfo.report_seq) : '—'],
['监听端口数', portInfoCount > 0 ? `${portInfoCount}` : '—'], [t('monitor.systemInfo.portCount'), portInfoCount > 0 ? `${portInfoCount}` : '—'],
['Agent RSS', heartbeatInfo?.self_rss_mb ? `${heartbeatInfo.self_rss_mb.toFixed(1)} MB` : '—'], [t('monitor.systemInfo.agentRss'), heartbeatInfo?.self_rss_mb ? `${heartbeatInfo.self_rss_mb.toFixed(1)} MB` : '—'],
].map(([label, val]) => ( ].map(([label, val]) => (
<div key={label as string}> <div key={label as string}>
<span className="text-[var(--dg-text-muted)]">{label}:</span> <span className="text-[var(--dg-text-muted)]">{label}:</span>
@@ -52,7 +54,7 @@ export function SystemInfoSection({
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" /> <Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
<h3 className="text-xs font-medium text-[var(--dg-text-primary)]">系统负载 (Load Average)</h3> <h3 className="text-xs font-medium text-[var(--dg-text-primary)]">{t('monitor.systemInfo.loadAvgTitle')}</h3>
</div> </div>
<div className="flex items-center gap-6 text-xs"> <div className="flex items-center gap-6 text-xs">
<div><span className="text-[var(--dg-text-muted)]">1min </span><span className="text-sm font-bold text-[var(--dg-text-primary)]">{latest.load1 ?? '--'}</span></div> <div><span className="text-[var(--dg-text-muted)]">1min </span><span className="text-sm font-bold text-[var(--dg-text-primary)]">{latest.load1 ?? '--'}</span></div>
@@ -66,7 +68,7 @@ export function SystemInfoSection({
{parts.length > 0 && ( {parts.length > 0 && (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"> <Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4"> <CardContent className="p-4">
<h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-2 flex items-center gap-2"><HardDrive className="h-4 w-4" />磁盘分区</h3> <h3 className="text-xs font-medium text-[var(--dg-text-primary)] mb-2 flex items-center gap-2"><HardDrive className="h-4 w-4" />{t('monitor.systemInfo.diskPartition')}</h3>
<div className="space-y-1.5"> <div className="space-y-1.5">
{parts.map((p: JsonData, i: number) => ( {parts.map((p: JsonData, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs"> <div key={i} className="flex items-center gap-2 text-xs">
@@ -1,5 +1,6 @@
// 中间件实例卡片 — 紧凑列表项,详情见右侧可观测面板 // Logan.Li // 中间件实例卡片 — 紧凑列表项,详情见右侧可观测面板 // Logan.Li
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, Loader2, Pencil, Trash2, Zap } from 'lucide-react'; import { AlertTriangle, Loader2, Pencil, Trash2, Zap } from 'lucide-react';
import type { MwTarget } from './helpers'; import type { MwTarget } from './helpers';
import { import {
@@ -18,6 +19,7 @@ interface Props {
} }
export function InstanceCard({ target, selected, onSelect, onEdit, onDelete, onTest, testing }: Props) { export function InstanceCard({ target, selected, onSelect, onEdit, onDelete, onTest, testing }: Props) {
const { t } = useTranslation();
const m = target.latest_metrics || {}; const m = target.latest_metrics || {};
const isRedis = target.description === 'redis'; const isRedis = target.description === 'redis';
const issues = evalMwHealth(target); const issues = evalMwHealth(target);
@@ -55,20 +57,20 @@ export function InstanceCard({ target, selected, onSelect, onEdit, onDelete, onT
) : ( ) : (
<> <>
<MetricPill label="QPS" value={fmtVal(m.mysql_qps, 1)} /> <MetricPill label="QPS" value={fmtVal(m.mysql_qps, 1)} />
<MetricPill label="线程" value={fmtVal(m.mysql_threads_running)} /> <MetricPill label={t('monitor.mwInstance.threads')} value={fmtVal(m.mysql_threads_running)} />
</> </>
)} )}
</div> </div>
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}> <div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={onTest} disabled={testing}> <Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={onTest} disabled={testing}>
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Zap className="h-3 w-3" />}测试 {testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Zap className="h-3 w-3" />}{t('monitor.mwInstance.test')}
</Button> </Button>
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={onEdit}> <Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={onEdit}>
<Pencil className="h-3 w-3" />编辑 <Pencil className="h-3 w-3" />{t('common.edit')}
</Button> </Button>
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px] dg-text-red" onClick={onDelete}> <Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px] dg-text-red" onClick={onDelete}>
<Trash2 className="h-3 w-3" />删除 <Trash2 className="h-3 w-3" />{t('common.delete')}
</Button> </Button>
</div> </div>
</button> </button>
@@ -1,4 +1,5 @@
// 中间件健康告警条 // Logan.Li // 中间件健康告警条 // Logan.Li
import { useTranslation } from 'react-i18next';
import { AlertTriangle, CheckCircle2 } from 'lucide-react'; import { AlertTriangle, CheckCircle2 } from 'lucide-react';
import type { MwHealthIssue } from './helpers'; import type { MwHealthIssue } from './helpers';
import { worstHealthLevel, healthLevelColor } from './helpers'; import { worstHealthLevel, healthLevelColor } from './helpers';
@@ -8,6 +9,7 @@ interface Props {
} }
export function MwHealthStrip({ issues }: Props) { export function MwHealthStrip({ issues }: Props) {
const { t } = useTranslation();
const level = worstHealthLevel(issues); const level = worstHealthLevel(issues);
const color = healthLevelColor(level); const color = healthLevelColor(level);
@@ -15,7 +17,7 @@ export function MwHealthStrip({ issues }: Props) {
return ( return (
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-xs"> <div className="flex items-center gap-2 rounded-lg border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-xs">
<CheckCircle2 className="h-3.5 w-3.5 dg-text-green shrink-0" /> <CheckCircle2 className="h-3.5 w-3.5 dg-text-green shrink-0" />
<span className="text-[var(--dg-text-muted)]">指标正常,无告警</span> <span className="text-[var(--dg-text-muted)]">{t('monitor.mwHealth.allNormal')}</span>
</div> </div>
); );
} }
@@ -1,5 +1,6 @@
// 监控图表放大壳 — 复用主机监控 ChartToolbar + 宽屏弹框 // Logan.Li // 监控图表放大壳 — 复用主机监控 ChartToolbar + 宽屏弹框 // Logan.Li
import { useState, type ReactNode } from 'react'; import { useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
@@ -28,6 +29,7 @@ export function ChartExpandShell({
children, children,
}: Props) { }: Props) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const { t } = useTranslation();
return ( return (
<> <>
@@ -40,7 +42,7 @@ export function ChartExpandShell({
{!empty && <ChartToolbar onExpand={() => setExpanded(true)} />} {!empty && <ChartToolbar onExpand={() => setExpanded(true)} />}
</div> </div>
{empty ? ( {empty ? (
<p className="text-xs text-center py-10 text-[var(--dg-text-muted)]">暂无数据</p> <p className="text-xs text-center py-10 text-[var(--dg-text-muted)]">{t('common.noData')}</p>
) : ( ) : (
children(compactHeight) children(compactHeight)
)} )}
@@ -50,7 +52,7 @@ export function ChartExpandShell({
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"> <DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-base">{title}</DialogTitle> <DialogTitle className="text-base">{title}</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]">放大查看监控趋势与指标细节</DialogDescription> <DialogDescription className="text-[var(--dg-text-muted)]">{t('monitor.chartExpand.description')}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="pt-2">{children(expandedHeight)}</div> <div className="pt-2">{children(expandedHeight)}</div>
</DialogContent> </DialogContent>
@@ -1,4 +1,5 @@
// 站点拨测图表容器 — 详情页全宽展示,无放大弹框 // Logan.Li // 站点拨测图表容器 — 详情页全宽展示,无放大弹框 // Logan.Li
import { useTranslation } from 'react-i18next';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
interface Props { interface Props {
@@ -16,6 +17,7 @@ export function SiteChartCard({
empty = false, empty = false,
children, children,
}: Props) { }: Props) {
const { t } = useTranslation();
return ( return (
<div className="rounded-lg bg-[var(--dg-bg-tertiary)] p-3"> <div className="rounded-lg bg-[var(--dg-bg-tertiary)] p-3">
<div className="flex items-center gap-2 mb-2 min-w-0"> <div className="flex items-center gap-2 mb-2 min-w-0">
@@ -23,7 +25,7 @@ export function SiteChartCard({
<span className="text-xs text-[var(--dg-text-muted)] truncate">{title}</span> <span className="text-xs text-[var(--dg-text-muted)] truncate">{title}</span>
</div> </div>
{empty ? ( {empty ? (
<p className="text-xs text-center py-12 text-[var(--dg-text-muted)]">暂无数据</p> <p className="text-xs text-center py-12 text-[var(--dg-text-muted)]">{t('common.noData')}</p>
) : ( ) : (
children(height) children(height)
)} )}
@@ -1,4 +1,5 @@
// 拨测任务详情页 — 可观测指标与历史记录(非弹框)// Logan.Li // 拨测任务详情页 — 可观测指标与历史记录(非弹框)// Logan.Li
import { useTranslation } from 'react-i18next';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -13,19 +14,20 @@ interface Props {
} }
export function SiteTaskDetailPage({ task, onBack, onProbed }: Props) { export function SiteTaskDetailPage({ task, onBack, onProbed }: Props) {
const { t } = useTranslation();
const probeType = normalizeProbeType(task.type); const probeType = normalizeProbeType(task.type);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-start gap-3 flex-wrap"> <div className="flex items-start gap-3 flex-wrap">
<Button variant="outline" size="sm" className="h-8 shrink-0" onClick={onBack}> <Button variant="outline" size="sm" className="h-8 shrink-0" onClick={onBack}>
<ArrowLeft className="h-3.5 w-3.5 mr-1" />返回列表 <ArrowLeft className="h-3.5 w-3.5 mr-1" />{t('monitor.siteTask.backToList')}
</Button> </Button>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)] truncate">{task.name}</h2> <h2 className="text-lg font-semibold text-[var(--dg-text-primary)] truncate">{task.name}</h2>
<Badge variant="outline" className="text-[10px] shrink-0">{probeType}</Badge> <Badge variant="outline" className="text-[10px] shrink-0">{probeType}</Badge>
<span className="text-[10px] text-[var(--dg-text-muted)] shrink-0">频率 {fmtFrequency(task.frequency_sec)}</span> <span className="text-[10px] text-[var(--dg-text-muted)] shrink-0">{t('monitor.siteTask.frequency')} {fmtFrequency(task.frequency_sec)}</span>
</div> </div>
<p className="text-xs font-mono text-[var(--dg-text-muted)] truncate mt-0.5">{task.url}</p> <p className="text-xs font-mono text-[var(--dg-text-muted)] truncate mt-0.5">{task.url}</p>
</div> </div>
@@ -1,4 +1,5 @@
// 拨测异常事件时间线 // Logan.Li // 拨测异常事件时间线 // Logan.Li
import { useTranslation } from 'react-i18next';
import { AlertTriangle, Info, XCircle } from 'lucide-react'; import { AlertTriangle, Info, XCircle } from 'lucide-react';
import type { ProbeEvent } from './analytics'; import type { ProbeEvent } from './analytics';
@@ -20,12 +21,14 @@ const toneMap = {
error: 'dg-text-red', error: 'dg-text-red',
}; };
export function SiteEventTimeline({ title, events, emptyText = '近期无异常事件' }: Props) { export function SiteEventTimeline({ title, events, emptyText }: Props) {
const { t } = useTranslation();
const resolvedEmpty = emptyText ?? t('monitor.siteEvent.defaultEmpty');
return ( return (
<div className="rounded-lg bg-[var(--dg-bg-tertiary)] p-3"> <div className="rounded-lg bg-[var(--dg-bg-tertiary)] p-3">
<p className="text-xs text-[var(--dg-text-muted)] mb-2">{title}</p> <p className="text-xs text-[var(--dg-text-muted)] mb-2">{title}</p>
{events.length === 0 ? ( {events.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] py-4 text-center">{emptyText}</p> <p className="text-xs text-[var(--dg-text-muted)] py-4 text-center">{resolvedEmpty}</p>
) : ( ) : (
<ul className="space-y-1.5 max-h-36 overflow-y-auto"> <ul className="space-y-1.5 max-h-36 overflow-y-auto">
{events.map((e) => { {events.map((e) => {
@@ -3,6 +3,7 @@ import {
Area, Bar, BarChart, CartesianGrid, Cell, ComposedChart, Line, Area, Bar, BarChart, CartesianGrid, Cell, ComposedChart, Line,
ReferenceArea, ReferenceLine, Tooltip, XAxis, YAxis, ReferenceArea, ReferenceLine, Tooltip, XAxis, YAxis,
} from 'recharts'; } from 'recharts';
import { useTranslation } from 'react-i18next';
import { Globe, Network, Radio, Shield, Wifi } from 'lucide-react'; import { Globe, Network, Radio, Shield, Wifi } from 'lucide-react';
import type { SiteResult, SiteTask } from '../helpers'; import type { SiteResult, SiteTask } from '../helpers';
import { fmtMs, fmtSslDays, normalizeProbeType } from '../helpers'; import { fmtMs, fmtSslDays, normalizeProbeType } from '../helpers';
@@ -35,16 +36,18 @@ function SelectionArea({ left, right }: { left: string; right: string }) {
); );
} }
function HttpObs({ task, results, rangeLabel = '近 6 小时' }: Props) { function HttpObs({ task, results, rangeLabel }: Props) {
const { t } = useTranslation();
const rl = rangeLabel ?? t('monitor.siteProtocol.rangeDefault');
const s = analyzeHttpResults(results, task); const s = analyzeHttpResults(results, task);
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-2"> <div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{[ {[
{ label: `期望 ${s.expectedCode}`, value: s.okCount, tone: 'dg-text-green' }, { label: `${t('monitor.siteProtocol.http.expectedPrefix')} ${s.expectedCode}`, value: s.okCount, tone: 'dg-text-green' },
{ label: '4xx', value: s.err4xx, tone: 'dg-text-yellow' }, { label: '4xx', value: s.err4xx, tone: 'dg-text-yellow' },
{ label: '5xx', value: s.err5xx, tone: 'dg-text-red' }, { label: '5xx', value: s.err5xx, tone: 'dg-text-red' },
{ label: '超时/其他', value: s.otherErr, tone: 'dg-text-red' }, { label: t('monitor.siteProtocol.http.timeoutOther'), value: s.otherErr, tone: 'dg-text-red' },
].map((item) => ( ].map((item) => (
<div key={item.label} className="bg-[var(--dg-bg-secondary)] rounded-lg px-3 py-2 text-center"> <div key={item.label} className="bg-[var(--dg-bg-secondary)] rounded-lg px-3 py-2 text-center">
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p> <p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
@@ -54,7 +57,7 @@ function HttpObs({ task, results, rangeLabel = '近 6 小时' }: Props) {
</div> </div>
<SiteChartCard <SiteChartCard
icon={<Globe className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />} icon={<Globe className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />}
title={`HTTP 状态码时序(${rangeLabel})`} title={`${t('monitor.siteProtocol.http.statusCodeTitle')}(${rl})`}
empty={s.chart.length === 0} empty={s.chart.length === 0}
> >
{(h) => ( {(h) => (
@@ -64,8 +67,8 @@ function HttpObs({ task, results, rangeLabel = '近 6 小时' }: Props) {
<CartesianGrid strokeDasharray="3 3" stroke={MONITOR_CHART_GRID} vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke={MONITOR_CHART_GRID} vertical={false} />
<XAxis dataKey="time" tick={MONITOR_CHART_TICK} tickLine={false} axisLine={{ stroke: MONITOR_CHART_GRID }} interval="preserveStartEnd" /> <XAxis dataKey="time" tick={MONITOR_CHART_TICK} tickLine={false} axisLine={{ stroke: MONITOR_CHART_GRID }} interval="preserveStartEnd" />
<YAxis tick={MONITOR_CHART_TICK} tickLine={false} axisLine={false} width={36} domain={[0, 'auto']} /> <YAxis tick={MONITOR_CHART_TICK} tickLine={false} axisLine={false} width={36} domain={[0, 'auto']} />
<Tooltip content={<MonitorChartTooltip valueFormatter={(v) => `HTTP ${v}`} nameFormatter={() => '状态码'} />} /> <Tooltip content={<MonitorChartTooltip valueFormatter={(v) => `HTTP ${v}`} nameFormatter={() => t('monitor.siteProtocol.http.statusCodeName')} />} />
<ReferenceLine y={s.expectedCode} stroke="var(--dg-green)" strokeDasharray="4 4" label={{ value: `期望${s.expectedCode}`, fontSize: 10, fill: 'var(--dg-text-muted)' }} /> <ReferenceLine y={s.expectedCode} stroke="var(--dg-green)" strokeDasharray="4 4" label={{ value: `${t('monitor.siteProtocol.http.expectedPrefix')}${s.expectedCode}`, fontSize: 10, fill: 'var(--dg-text-muted)' }} />
<Bar dataKey="code" radius={[2, 2, 0, 0]} maxBarSize={14} isAnimationActive={false}> <Bar dataKey="code" radius={[2, 2, 0, 0]} maxBarSize={14} isAnimationActive={false}>
{displayData.map((d, i) => ( {displayData.map((d, i) => (
<Cell key={`${d.time}-${i}`} fill={d.ok ? 'var(--dg-green)' : d.code >= 500 ? 'var(--dg-red)' : d.code >= 400 ? '#eab308' : 'var(--dg-red)'} /> <Cell key={`${d.time}-${i}`} fill={d.ok ? 'var(--dg-green)' : d.code >= 500 ? 'var(--dg-red)' : d.code >= 400 ? '#eab308' : 'var(--dg-red)'} />
@@ -77,22 +80,23 @@ function HttpObs({ task, results, rangeLabel = '近 6 小时' }: Props) {
</MonitorRangeChartFrame> </MonitorRangeChartFrame>
)} )}
</SiteChartCard> </SiteChartCard>
<SiteEventTimeline title="4xx / 5xx / 超时事件" events={s.events} /> <SiteEventTimeline title={t('monitor.siteProtocol.http.timelineTitle')} events={s.events} />
</div> </div>
); );
} }
function SslObs({ task, results }: Props) { function SslObs({ task, results }: Props) {
const { t } = useTranslation();
const s = analyzeSslResults(results, task); const s = analyzeSslResults(results, task);
const sslInfo = fmtSslDays(s.days); const sslInfo = fmtSslDays(s.days);
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-2"> <div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{[ {[
{ label: '证书到期', value: s.expiry || '--', tone: 'text-[var(--dg-text-primary)]' }, { label: t('monitor.siteProtocol.ssl.expiry'), value: s.expiry || '--', tone: 'text-[var(--dg-text-primary)]' },
{ label: '剩余天数', value: sslInfo.text, tone: sslInfo.tone }, { label: t('monitor.siteProtocol.ssl.remainingDays'), value: sslInfo.text, tone: sslInfo.tone },
{ label: '签发者', value: s.issuer ? (s.issuer.length > 16 ? `${s.issuer.slice(0, 16)}…` : s.issuer) : '--', tone: 'text-[var(--dg-text-primary)]' }, { label: t('monitor.siteProtocol.ssl.issuer'), value: s.issuer ? (s.issuer.length > 16 ? `${s.issuer.slice(0, 16)}…` : s.issuer) : '--', tone: 'text-[var(--dg-text-primary)]' },
{ label: '握手延迟', value: fmtMs(s.handshakeMs), tone: 'text-[var(--dg-text-primary)]' }, { label: t('monitor.siteProtocol.ssl.handshakeLatency'), value: fmtMs(s.handshakeMs), tone: 'text-[var(--dg-text-primary)]' },
].map((item) => ( ].map((item) => (
<div key={item.label} className="bg-[var(--dg-bg-secondary)] rounded-lg px-3 py-2 text-center"> <div key={item.label} className="bg-[var(--dg-bg-secondary)] rounded-lg px-3 py-2 text-center">
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p> <p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
@@ -102,7 +106,7 @@ function SslObs({ task, results }: Props) {
</div> </div>
<SiteChartCard <SiteChartCard
icon={<Shield className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />} icon={<Shield className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />}
title="证书剩余天数趋势" title={t('monitor.siteProtocol.ssl.daysTrendTitle')}
height={220} height={220}
empty={s.chart.length === 0} empty={s.chart.length === 0}
> >
@@ -113,9 +117,9 @@ function SslObs({ task, results }: Props) {
<CartesianGrid strokeDasharray="3 3" stroke={MONITOR_CHART_GRID} vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke={MONITOR_CHART_GRID} vertical={false} />
<XAxis dataKey="time" tick={MONITOR_CHART_TICK} tickLine={false} axisLine={{ stroke: MONITOR_CHART_GRID }} /> <XAxis dataKey="time" tick={MONITOR_CHART_TICK} tickLine={false} axisLine={{ stroke: MONITOR_CHART_GRID }} />
<YAxis tick={MONITOR_CHART_TICK} tickLine={false} axisLine={false} width={32} /> <YAxis tick={MONITOR_CHART_TICK} tickLine={false} axisLine={false} width={32} />
<Tooltip content={<MonitorChartTooltip valueFormatter={(v) => `${v} 天`} nameFormatter={() => '剩余'} />} /> <Tooltip content={<MonitorChartTooltip valueFormatter={(v) => `${v} ${t('monitor.siteProtocol.ssl.daysUnit')}`} nameFormatter={() => t('monitor.siteProtocol.ssl.daysName')} />} />
<ReferenceLine y={30} stroke="#eab308" strokeDasharray="4 4" label={{ value: '30天', fontSize: 10, fill: 'var(--dg-text-muted)' }} /> <ReferenceLine y={30} stroke="#eab308" strokeDasharray="4 4" label={{ value: t('monitor.siteProtocol.ssl.ref30days'), fontSize: 10, fill: 'var(--dg-text-muted)' }} />
<ReferenceLine y={7} stroke="var(--dg-red)" strokeDasharray="4 4" label={{ value: '7天', fontSize: 10, fill: 'var(--dg-text-muted)' }} /> <ReferenceLine y={7} stroke="var(--dg-red)" strokeDasharray="4 4" label={{ value: t('monitor.siteProtocol.ssl.ref7days'), fontSize: 10, fill: 'var(--dg-text-muted)' }} />
<Line type="monotone" dataKey="days" stroke="var(--dg-blue)" strokeWidth={2} dot={false} isAnimationActive={false} /> <Line type="monotone" dataKey="days" stroke="var(--dg-blue)" strokeWidth={2} dot={false} isAnimationActive={false} />
<SelectionArea left={refLeft} right={refRight} /> <SelectionArea left={refLeft} right={refRight} />
</ComposedChart> </ComposedChart>
@@ -123,24 +127,26 @@ function SslObs({ task, results }: Props) {
</MonitorRangeChartFrame> </MonitorRangeChartFrame>
)} )}
</SiteChartCard> </SiteChartCard>
<SiteEventTimeline title="证书 / 握手异常" events={s.events} /> <SiteEventTimeline title={t('monitor.siteProtocol.ssl.timelineTitle')} events={s.events} />
</div> </div>
); );
} }
function LatencyObs({ results, mode, rangeLabel = '近 6 小时' }: Props & { mode: 'PING' | 'DNS' }) { function LatencyObs({ results, mode, rangeLabel }: Props & { mode: 'PING' | 'DNS' }) {
const { t } = useTranslation();
const rl = rangeLabel ?? t('monitor.siteProtocol.rangeDefault');
const s = mode === 'PING' ? analyzePingResults(results) : analyzeDnsResults(results); const s = mode === 'PING' ? analyzePingResults(results) : analyzeDnsResults(results);
const label = mode === 'PING' ? 'PING 延迟波动' : 'DNS 解析延迟'; const label = mode === 'PING' ? t('monitor.siteProtocol.latency.pingLabel') : t('monitor.siteProtocol.latency.dnsLabel');
const Icon = mode === 'PING' ? Wifi : Radio; const Icon = mode === 'PING' ? Wifi : Radio;
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 md:grid-cols-5 gap-2"> <div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{[ {[
{ label: '平均', value: fmtMs(s.avg) }, { label: t('monitor.siteProtocol.latency.avg'), value: fmtMs(s.avg) },
{ label: '最小', value: fmtMs(s.min) }, { label: t('monitor.siteProtocol.latency.min'), value: fmtMs(s.min) },
{ label: '最大', value: fmtMs(s.max) }, { label: t('monitor.siteProtocol.latency.max'), value: fmtMs(s.max) },
{ label: 'P95', value: fmtMs(s.p95) }, { label: 'P95', value: fmtMs(s.p95) },
{ label: '波动次数', value: String(s.spikeCount), tone: s.spikeCount > 0 ? 'dg-text-yellow' : undefined }, { label: t('monitor.siteProtocol.latency.spikeCount'), value: String(s.spikeCount), tone: s.spikeCount > 0 ? 'dg-text-yellow' : undefined },
].map((item) => ( ].map((item) => (
<div key={item.label} className="bg-[var(--dg-bg-secondary)] rounded-lg px-3 py-2 text-center"> <div key={item.label} className="bg-[var(--dg-bg-secondary)] rounded-lg px-3 py-2 text-center">
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p> <p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
@@ -150,7 +156,7 @@ function LatencyObs({ results, mode, rangeLabel = '近 6 小时' }: Props & { mo
</div> </div>
<SiteChartCard <SiteChartCard
icon={<Icon className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />} icon={<Icon className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />}
title={`${label}(${rangeLabel})`} title={`${label}(${rl})`}
empty={s.chart.length === 0} empty={s.chart.length === 0}
> >
{(h) => ( {(h) => (
@@ -160,8 +166,8 @@ function LatencyObs({ results, mode, rangeLabel = '近 6 小时' }: Props & { mo
<CartesianGrid strokeDasharray="3 3" stroke={MONITOR_CHART_GRID} vertical={false} /> <CartesianGrid strokeDasharray="3 3" stroke={MONITOR_CHART_GRID} vertical={false} />
<XAxis dataKey="time" tick={MONITOR_CHART_TICK} tickLine={false} axisLine={{ stroke: MONITOR_CHART_GRID }} interval="preserveStartEnd" /> <XAxis dataKey="time" tick={MONITOR_CHART_TICK} tickLine={false} axisLine={{ stroke: MONITOR_CHART_GRID }} interval="preserveStartEnd" />
<YAxis tick={MONITOR_CHART_TICK} tickLine={false} axisLine={false} width={42} tickFormatter={(v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}s` : `${v}ms`)} /> <YAxis tick={MONITOR_CHART_TICK} tickLine={false} axisLine={false} width={42} tickFormatter={(v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}s` : `${v}ms`)} />
<Tooltip content={<MonitorChartTooltip valueFormatter={(v, name) => (name === 'latency' ? fmtMs(v) : String(v))} nameFormatter={(name) => (name === 'latency' ? '延迟' : name)} />} /> <Tooltip content={<MonitorChartTooltip valueFormatter={(v, name) => (name === 'latency' ? fmtMs(v) : String(v))} nameFormatter={(name) => (name === 'latency' ? t('monitor.siteProtocol.latency.latencyName') : name)} />} />
<ReferenceLine y={s.avg} stroke="var(--dg-blue)" strokeDasharray="4 4" label={{ value: '均值', fontSize: 10, fill: 'var(--dg-text-muted)' }} /> <ReferenceLine y={s.avg} stroke="var(--dg-blue)" strokeDasharray="4 4" label={{ value: t('monitor.siteProtocol.latency.avgLabel'), fontSize: 10, fill: 'var(--dg-text-muted)' }} />
<Line type="monotone" dataKey="latency" stroke="var(--dg-blue)" strokeWidth={2} dot={false} isAnimationActive={false} /> <Line type="monotone" dataKey="latency" stroke="var(--dg-blue)" strokeWidth={2} dot={false} isAnimationActive={false} />
<SelectionArea left={refLeft} right={refRight} /> <SelectionArea left={refLeft} right={refRight} />
</ComposedChart> </ComposedChart>
@@ -169,20 +175,22 @@ function LatencyObs({ results, mode, rangeLabel = '近 6 小时' }: Props & { mo
</MonitorRangeChartFrame> </MonitorRangeChartFrame>
)} )}
</SiteChartCard> </SiteChartCard>
<SiteEventTimeline title="延迟尖峰 / 失败事件" events={s.events} /> <SiteEventTimeline title={t('monitor.siteProtocol.latency.timelineTitle')} events={s.events} />
</div> </div>
); );
} }
function TcpObs({ results, rangeLabel = '近 6 小时' }: Props) { function TcpObs({ results, rangeLabel }: Props) {
const { t } = useTranslation();
const rl = rangeLabel ?? t('monitor.siteProtocol.rangeDefault');
const s = analyzeTcpResults(results); const s = analyzeTcpResults(results);
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 md:grid-cols-3 gap-2"> <div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{[ {[
{ label: `连通率(${rangeLabel})`, value: s.upRate, tone: 'dg-text-green' }, { label: `${t('monitor.siteProtocol.tcp.upRate')}(${rl})`, value: s.upRate, tone: 'dg-text-green' },
{ label: '不通次数', value: String(s.failCount), tone: s.failCount > 0 ? 'dg-text-red' : 'text-[var(--dg-text-primary)]' }, { label: t('monitor.siteProtocol.tcp.failCount'), value: String(s.failCount), tone: s.failCount > 0 ? 'dg-text-red' : 'text-[var(--dg-text-primary)]' },
{ label: '最近失败', value: s.lastFailAt || '--', tone: 'text-[var(--dg-text-primary)]' }, { label: t('monitor.siteProtocol.tcp.lastFail'), value: s.lastFailAt || '--', tone: 'text-[var(--dg-text-primary)]' },
].map((item) => ( ].map((item) => (
<div key={item.label} className="bg-[var(--dg-bg-secondary)] rounded-lg px-3 py-2 text-center"> <div key={item.label} className="bg-[var(--dg-bg-secondary)] rounded-lg px-3 py-2 text-center">
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p> <p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
@@ -192,7 +200,7 @@ function TcpObs({ results, rangeLabel = '近 6 小时' }: Props) {
</div> </div>
<SiteChartCard <SiteChartCard
icon={<Network className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />} icon={<Network className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />}
title="TCP 连通状态时序" title={t('monitor.siteProtocol.tcp.statusTitle')}
height={200} height={200}
empty={s.chart.length === 0} empty={s.chart.length === 0}
> >
@@ -202,7 +210,7 @@ function TcpObs({ results, rangeLabel = '近 6 小时' }: Props) {
<ComposedChart data={displayData} margin={{ top: 4, right: 8, left: 0, bottom: 4 }} {...chartHandlers}> <ComposedChart data={displayData} margin={{ top: 4, right: 8, left: 0, bottom: 4 }} {...chartHandlers}>
<XAxis dataKey="time" tick={MONITOR_CHART_TICK} tickLine={false} axisLine={{ stroke: MONITOR_CHART_GRID }} interval="preserveStartEnd" /> <XAxis dataKey="time" tick={MONITOR_CHART_TICK} tickLine={false} axisLine={{ stroke: MONITOR_CHART_GRID }} interval="preserveStartEnd" />
<YAxis hide domain={[0, 100]} /> <YAxis hide domain={[0, 100]} />
<Tooltip content={<MonitorChartTooltip valueFormatter={(v) => (v >= 100 ? '连通' : '不通')} nameFormatter={() => '状态'} />} /> <Tooltip content={<MonitorChartTooltip valueFormatter={(v) => (v >= 100 ? t('monitor.siteProtocol.tcp.connected') : t('monitor.siteProtocol.tcp.disconnected'))} nameFormatter={() => t('monitor.siteProtocol.tcp.statusName')} />} />
<Area type="stepAfter" dataKey="up" stroke="var(--dg-green)" fill="var(--dg-green)" fillOpacity={0.2} isAnimationActive={false} /> <Area type="stepAfter" dataKey="up" stroke="var(--dg-green)" fill="var(--dg-green)" fillOpacity={0.2} isAnimationActive={false} />
<SelectionArea left={refLeft} right={refRight} /> <SelectionArea left={refLeft} right={refRight} />
</ComposedChart> </ComposedChart>
@@ -210,7 +218,7 @@ function TcpObs({ results, rangeLabel = '近 6 小时' }: Props) {
</MonitorRangeChartFrame> </MonitorRangeChartFrame>
)} )}
</SiteChartCard> </SiteChartCard>
<SiteEventTimeline title="TCP 不通时间点" events={s.events} emptyText="近期 TCP 均连通" /> <SiteEventTimeline title={t('monitor.siteProtocol.tcp.timelineTitle')} events={s.events} emptyText={t('monitor.siteProtocol.tcp.emptyText')} />
</div> </div>
); );
} }
@@ -1,5 +1,6 @@
// SNMP 内置 OID 模板 — 默认折叠,不占用主监控区域 // Logan.Li // SNMP 内置 OID 模板 — 默认折叠,不占用主监控区域 // Logan.Li
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChevronDown, Radio } from 'lucide-react'; import { ChevronDown, Radio } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
@@ -25,6 +26,7 @@ interface Props {
export function SnmpTemplatesCollapsible({ templates }: Props) { export function SnmpTemplatesCollapsible({ templates }: Props) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { t } = useTranslation();
return ( return (
<Collapsible open={open} onOpenChange={setOpen}> <Collapsible open={open} onOpenChange={setOpen}>
@@ -35,9 +37,9 @@ export function SnmpTemplatesCollapsible({ templates }: Props) {
> >
<span className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)]"> <span className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)]">
<Radio className="h-3.5 w-3.5" /> <Radio className="h-3.5 w-3.5" />
<span className="text-[var(--dg-text-primary)] font-medium">内置 OID 模板</span> <span className="text-[var(--dg-text-primary)] font-medium">{t('monitor.snmp.builtinTemplates')}</span>
<Badge variant="outline" className="text-[10px] h-5">{templates.length} 个</Badge> <Badge variant="outline" className="text-[10px] h-5">{t('monitor.snmp.templateCount', { count: templates.length })}</Badge>
<span className="hidden sm:inline">H3C / 华为 / Cisco / 锐捷 / Dell / 浪潮</span> <span className="hidden sm:inline">{t('monitor.snmp.vendors')}</span>
</span> </span>
<ChevronDown className={cn('h-4 w-4 shrink-0 text-[var(--dg-text-muted)] transition-transform', open && 'rotate-180')} /> <ChevronDown className={cn('h-4 w-4 shrink-0 text-[var(--dg-text-muted)] transition-transform', open && 'rotate-180')} />
</button> </button>
@@ -65,7 +67,7 @@ export function SnmpTemplatesCollapsible({ templates }: Props) {
</div> </div>
))} ))}
{templates.length === 0 && ( {templates.length === 0 && (
<p className="text-xs text-[var(--dg-text-muted)] col-span-2 py-4 text-center">模板尚未初始化,重启服务后自动种子</p> <p className="text-xs text-[var(--dg-text-muted)] col-span-2 py-4 text-center">{t('monitor.snmp.templatesNotInit')}</p>
)} )}
</div> </div>
</div> </div>