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:
1 parent
eddf01dd79
commit
63e4c220d4
59 files changed
+6700
-628
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -39,6 +40,7 @@ import { ContentPanel } from '@/components/layout/ContentPanel';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
|
||||
export function AppReleaseManager() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [envFilter, setEnvFilter] = useState<string>('all');
|
||||
@@ -188,8 +190,8 @@ export function AppReleaseManager() {
|
||||
{release.version}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{getEnvBadge(release.environment)}</TableCell>
|
||||
<TableCell>{getStatusBadge(release.status)}</TableCell>
|
||||
<TableCell>{getEnvBadge(release.environment, t)}</TableCell>
|
||||
<TableCell>{getStatusBadge(release.status, t)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-xs dg-text-secondary">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GitBranch, Settings2, Shield, Users, Server } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ path: '/pipelines', label: '流水线', icon: GitBranch },
|
||||
{ path: '/deploy/build-nodes', label: '构建节点', icon: Server },
|
||||
{ path: '/deploy/configureTools', label: '发布配置', icon: Settings2 },
|
||||
{ path: '/deploy/rbac', label: 'Job 权限', icon: Shield },
|
||||
{ path: '/deploy/groups', label: '用户组', icon: Users },
|
||||
{ path: '/pipelines', labelKey: 'deploy.pipeline.title', icon: GitBranch },
|
||||
{ path: '/deploy/build-nodes', labelKey: 'deploy.deployNav.buildNodes', icon: Server },
|
||||
{ path: '/deploy/configureTools', labelKey: 'deploy.label.releaseConfig', icon: Settings2 },
|
||||
{ path: '/deploy/rbac', labelKey: 'deploy.deployNav.jobPermission', icon: Shield },
|
||||
{ path: '/deploy/groups', labelKey: 'deploy.label.userGroups', icon: Users },
|
||||
];
|
||||
|
||||
// 应用发布模块二级导航 // Logan.Li
|
||||
export function DeployNav() {
|
||||
const { t } = useTranslation();
|
||||
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_ITEMS.map((item) => {
|
||||
@@ -28,7 +30,7 @@ export function DeployNav() {
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{item.label}
|
||||
{t(item.labelKey)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -36,6 +37,7 @@ export function PipelineDetailDialog({
|
||||
activeStage: string | null;
|
||||
setActiveStage: (id: string | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<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">
|
||||
<TabsList className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
|
||||
<TabsTrigger value="stages" className="data-[state=active]:bg-[var(--dg-bg-tertiary)]">
|
||||
执行阶段
|
||||
{t('deploy.run.execStage')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="config" className="data-[state=active]:bg-[var(--dg-bg-tertiary)]">
|
||||
配置
|
||||
{t('deploy.detailDialog.config')}
|
||||
</TabsTrigger>
|
||||
<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>
|
||||
</TabsList>
|
||||
<TabsContent value="stages" className="mt-4">
|
||||
<div className="space-y-2">
|
||||
{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) => (
|
||||
<div
|
||||
@@ -103,21 +105,21 @@ export function PipelineDetailDialog({
|
||||
<Table>
|
||||
<TableBody>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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)]">
|
||||
{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>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
@@ -129,7 +131,7 @@ export function PipelineDetailDialog({
|
||||
<Loader2 className="h-5 w-5 text-[var(--dg-text-secondary)] animate-spin" />
|
||||
</div>
|
||||
) : 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">
|
||||
{pipelineRuns.map((run: JsonData) => (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
|
||||
Plus,
|
||||
@@ -60,7 +61,13 @@ interface LogEntry {
|
||||
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 };
|
||||
|
||||
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 ----
|
||||
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 (
|
||||
<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="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>
|
||||
<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 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>
|
||||
<SelectContent>{Object.entries(taskIcons).map(([k]) => (<SelectItem key={k} value={k}>{k}</SelectItem>))}</SelectContent></Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-xs">脚本类型</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="space-y-1"><Label className="text-xs">{t('automation.scriptType')}</Label>
|
||||
<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 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'" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,20 +131,21 @@ function TaskEditor({ task, onUpdate }: { task: Task | null; onUpdate: (t: Task)
|
||||
|
||||
// ====== Log Dialog ======
|
||||
function LogDialog({ open, onClose, run, logs }: { open: boolean; onClose: () => void; run?: JsonData; logs: LogEntry[] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<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="flex flex-wrap gap-3 text-sm text-[var(--dg-text-secondary)]">
|
||||
<span>触发方式: {run?.triggered_by || 'manual'}</span>
|
||||
<span>分支: {run?.branch || '-'}</span>
|
||||
<span>耗时: {fmtDuration(run?.duration)}</span>
|
||||
<span>{t('pipeline.triggerMode')}: {run?.triggered_by || 'manual'}</span>
|
||||
<span>{t('pipeline.branch')}: {run?.branch || '-'}</span>
|
||||
<span>{t('pipeline.duration')}: {fmtDuration(run?.duration)}</span>
|
||||
<span>{run?.created_at ? new Date(run.created_at).toLocaleString('zh-CN') : '-'}</span>
|
||||
</div>
|
||||
<div className="bg-[var(--dg-bg-primary)] rounded-md p-3 max-h-[50vh] overflow-y-auto font-mono text-xs">
|
||||
{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) => (
|
||||
<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 ======
|
||||
export default function PipelineEditor() {
|
||||
const { t } = useTranslation();
|
||||
const { code } = useParams<{ code: string }>();
|
||||
const pipelineCode = code || '';
|
||||
const [pipeline, setPipeline] = useState<JsonData>(null);
|
||||
@@ -172,7 +182,7 @@ export default function PipelineEditor() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [runs, setRuns] = useState<JsonData[]>([]);
|
||||
const [environments, setEnvironments] = useState<JsonData[]>([]);
|
||||
const [members, setMembers] = useState<JsonData[]>([]);
|
||||
const [members, setMembers] = useState<JsonData>([]);
|
||||
const [loadingMembers, setLoadingMembers] = useState(false);
|
||||
|
||||
// trigger config
|
||||
@@ -246,7 +256,7 @@ export default function PipelineEditor() {
|
||||
stageList.forEach((stg: Stage) => { expanded[stg.id] = true; });
|
||||
setExpandedStages(expanded);
|
||||
} catch { setStages([]); }
|
||||
} catch { toast.error('加载失败'); }
|
||||
} catch { toast.error(t('common.loadFailed')); }
|
||||
finally { setLoading(false); }
|
||||
})();
|
||||
loadEnvironments();
|
||||
@@ -256,16 +266,16 @@ export default function PipelineEditor() {
|
||||
const data = res as { list?: BuildNode[] };
|
||||
setBuildNodes(Array.isArray(data?.list) ? data.list.filter((n) => n.enabled) : []);
|
||||
}).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 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 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));
|
||||
};
|
||||
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 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()))),
|
||||
build_node_id: buildNodeId ? Number(buildNodeId) : null,
|
||||
});
|
||||
toast.success('已保存');
|
||||
} catch { toast.error('保存失败'); }
|
||||
toast.success(t('common.savedSuccessfully'));
|
||||
} catch { toast.error(t('common.saveFailed')); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleTrigger = async () => {
|
||||
try {
|
||||
await pipelineApi.triggerPipeline(pipelineCode);
|
||||
toast.success('已触发');
|
||||
toast.success(t('deploy.pipeline.triggered'));
|
||||
loadRuns();
|
||||
} catch { toast.error('触发失败'); }
|
||||
} catch { toast.error(t('deploy.pipeline.triggerFailed')); }
|
||||
};
|
||||
|
||||
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;
|
||||
try {
|
||||
await pipelineApi.stopRun(pipelineCode, runId);
|
||||
toast.success('已停止');
|
||||
toast.success(t('pipeline.stopped'));
|
||||
loadRuns();
|
||||
} catch { toast.error('停止失败'); }
|
||||
} catch { toast.error(t('pipeline.stopFailed')); }
|
||||
};
|
||||
|
||||
const handleViewLog = async (run: JsonData) => {
|
||||
@@ -315,36 +325,36 @@ export default function PipelineEditor() {
|
||||
};
|
||||
|
||||
const handleAddMember = async () => {
|
||||
if (!newMemberUser.trim()) { toast.error('请输入用户名'); return; }
|
||||
if (!newMemberUser.trim()) { toast.error(t('pipeline.usernameRequired')); return; }
|
||||
try {
|
||||
await pipelineApi.addMember(pipelineCode, { username: newMemberUser.trim(), role: newMemberRole });
|
||||
toast.success('已添加成员');
|
||||
toast.success(t('pipeline.memberAdded'));
|
||||
setNewMemberUser('');
|
||||
loadMembers();
|
||||
} catch (e) { toast.error(e?.message || '添加失败'); }
|
||||
} catch (e) { toast.error(e?.message || t('common.operationFailed')); }
|
||||
};
|
||||
|
||||
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;
|
||||
try { await pipelineApi.removeMember(pipelineCode, memberId); toast.success('已移除'); loadMembers(); }
|
||||
catch { toast.error('移除失败'); }
|
||||
try { await pipelineApi.removeMember(pipelineCode, memberId); toast.success(t('pipeline.memberRemoved')); loadMembers(); }
|
||||
catch { toast.error(t('common.operationFailed')); }
|
||||
};
|
||||
|
||||
const envBadge = environments.find(e => String(e.id) === String(environmentId));
|
||||
|
||||
const editorTabs = [
|
||||
{ id: 'flow', label: '流程配置' },
|
||||
{ id: 'triggers', label: '触发设置' },
|
||||
{ id: 'variables', label: '变量' },
|
||||
{ id: 'runs', label: '发布记录', badge: runs.length || undefined },
|
||||
{ id: 'members', label: '成员管理', badge: members.length || undefined },
|
||||
{ id: 'flow', label: t('pipeline.flowConfig') },
|
||||
{ id: 'triggers', label: t('pipeline.triggerSettings') },
|
||||
{ id: 'variables', label: t('pipeline.variables') },
|
||||
{ id: 'runs', label: t('pipeline.releaseRecords'), badge: runs.length || undefined },
|
||||
{ id: 'members', label: t('pipeline.memberManage'), badge: members.length || undefined },
|
||||
];
|
||||
|
||||
if (!loading && !pipeline) {
|
||||
return (
|
||||
<PageShell variant="workspace" title="流水线编辑" icon={<GitBranch className="h-5 w-5" />}>
|
||||
<PageEmpty title="流水线不存在" description="请检查链接或返回列表重试" />
|
||||
<PageShell variant="workspace" title={t('pipeline.pipelineEditor')} icon={<GitBranch className="h-5 w-5" />}>
|
||||
<PageEmpty title={t('pipeline.pipelineNotExist')} description={t('pipeline.checkLinkOrRetry')} />
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -352,7 +362,7 @@ export default function PipelineEditor() {
|
||||
return (
|
||||
<PageShell
|
||||
variant="workspace"
|
||||
title={pipeline?.name || '流水线编辑'}
|
||||
title={pipeline?.name || t('pipeline.pipelineEditor')}
|
||||
subtitle={pipeline ? `code: ${pipeline.code} · ${pipeline.description || ''}` : undefined}
|
||||
icon={<GitBranch className="h-5 w-5" />}
|
||||
loading={loading}
|
||||
@@ -367,10 +377,10 @@ export default function PipelineEditor() {
|
||||
</Badge>
|
||||
)}
|
||||
<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 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>
|
||||
</>
|
||||
)}
|
||||
@@ -379,11 +389,11 @@ export default function PipelineEditor() {
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3 flex items-center gap-3">
|
||||
<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)}>
|
||||
<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>
|
||||
<SelectItem value="__none">未指定</SelectItem>
|
||||
<SelectItem value="__none">{t('pipeline.unspecified')}</SelectItem>
|
||||
{environments.map(env => (
|
||||
<SelectItem key={env.id} value={String(env.id)}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
@@ -395,11 +405,11 @@ export default function PipelineEditor() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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)}>
|
||||
<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>
|
||||
<SelectItem value="__auto">自动调度(Master 优先)</SelectItem>
|
||||
<SelectItem value="__auto">{t('pipeline.autoScheduleMaster')}</SelectItem>
|
||||
{buildNodes.map(n => (
|
||||
<SelectItem key={n.id} value={String(n.id)}>
|
||||
{n.name} ({n.running_count || 0}/{n.max_executors})
|
||||
@@ -407,7 +417,7 @@ export default function PipelineEditor() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
@@ -417,11 +427,11 @@ export default function PipelineEditor() {
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">流水线阶段 ({stages.length})</span>
|
||||
<Button size="sm" onClick={handleAddStage}><Plus className="w-3.5 h-3.5 mr-1" />添加阶段</Button>
|
||||
<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" />{t('pipeline.addStage')}</Button>
|
||||
</div>
|
||||
{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) => {
|
||||
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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">{stage.tasks.length} 任务</span>
|
||||
<Badge variant="secondary" className="text-xs">{stageTypeLabels[stage.type] || stage.type}</Badge>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">{stage.tasks.length} {t('pipeline.tasks')}</span>
|
||||
<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 === 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>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<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)} />))}
|
||||
<Button variant="outline" size="sm" className="w-full h-8 text-xs" onClick={() => handleAddTask(idx)}><Plus className="w-3 h-3 mr-1" />添加任务</Button>
|
||||
{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" />{t('pipeline.addTask')}</Button>
|
||||
</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 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)} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -469,18 +479,18 @@ export default function PipelineEditor() {
|
||||
<TabsContent value="triggers" className="mt-3">
|
||||
<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">
|
||||
<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>
|
||||
{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>}
|
||||
<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>{t('pipeline.listenBranch')}</Label><Input value={watchBranches} onChange={e => setWatchBranches(e.target.value)} placeholder="master, release/*" className="h-9" /></div>}
|
||||
</CardContent></Card>
|
||||
<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>
|
||||
{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>}
|
||||
<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>{t('pipeline.cronExpr')}</Label><Input value={cronExpr} onChange={e => setCronExpr(e.target.value)} placeholder="0 2 * * *" className="h-9 font-mono" /></div>}
|
||||
</CardContent></Card>
|
||||
<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>
|
||||
{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>}
|
||||
<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={t('pipeline.inputSignKey')} className="h-9" /></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">手动触发</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>
|
||||
</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>
|
||||
</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>
|
||||
</TabsContent>
|
||||
|
||||
{/* ====== Runs Tab ====== */}
|
||||
<TabsContent value="runs" className="mt-3">
|
||||
{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">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<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)]">触发人</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)]">耗时</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">时间</th>
|
||||
<th className="px-3 py-2 text-right 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)]">{t('pipeline.triggerMode')}</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)]">{t('pipeline.buildNode')}</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)]">{t('pipeline.duration')}</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)]">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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">
|
||||
<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">
|
||||
{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 === '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 === '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 === '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 === '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" />{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" />{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" />{t('pipeline.stopped')}</Badge> :
|
||||
<Badge variant="secondary" className="text-[var(--dg-text-secondary)]">{r.status}</Badge>}
|
||||
</td>
|
||||
<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 === 'cron' && <span className="flex items-center gap-1"><Clock 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" />{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 === '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 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>
|
||||
@@ -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-right">
|
||||
<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') && (
|
||||
<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>
|
||||
</td>
|
||||
@@ -566,25 +576,25 @@ export default function PipelineEditor() {
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<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 className="flex items-end gap-3 mt-3">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label className="text-xs">用户名</Label>
|
||||
<Input value={newMemberUser} onChange={e => setNewMemberUser(e.target.value)} placeholder="输入用户名" className="h-9" />
|
||||
<Label className="text-xs">{t('pipeline.username')}</Label>
|
||||
<Input value={newMemberUser} onChange={e => setNewMemberUser(e.target.value)} placeholder={t('pipeline.usernamePlaceholder')} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">角色</Label>
|
||||
<Label className="text-xs">{t('pipeline.role')}</Label>
|
||||
<Select value={newMemberRole} onValueChange={setNewMemberRole}>
|
||||
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="owner">拥有者</SelectItem>
|
||||
<SelectItem value="editor">编辑者</SelectItem>
|
||||
<SelectItem value="viewer">查看者</SelectItem>
|
||||
<SelectItem value="owner">{t('pipeline.owner')}</SelectItem>
|
||||
<SelectItem value="editor">{t('pipeline.editor')}</SelectItem>
|
||||
<SelectItem value="viewer">{t('pipeline.viewer')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -599,12 +609,12 @@ export default function PipelineEditor() {
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<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)]">
|
||||
👤 直接用户授权 ({directUsers.length})
|
||||
👤 {t('pipeline.directUserAuth')} ({directUsers.length})
|
||||
</div>
|
||||
{loadingMembers ? (
|
||||
<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 ? (
|
||||
<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)]">
|
||||
{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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -628,11 +638,11 @@ export default function PipelineEditor() {
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<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)]">
|
||||
👥 用户组授权 ({groupAssignments.length})
|
||||
👥 {t('pipeline.groupAuth')} ({groupAssignments.length})
|
||||
</div>
|
||||
{groupAssignments.length === 0 ? (
|
||||
<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 className="divide-y divide-[var(--dg-border)]">
|
||||
@@ -641,11 +651,11 @@ export default function PipelineEditor() {
|
||||
<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>
|
||||
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{m.group?.name || `组 #${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-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">{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>
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -39,36 +40,37 @@ export function PipelineFormDialog({
|
||||
setFormData: (data: PipelineFormData) => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editMode ? '编辑流水线' : '新建流水线'}</DialogTitle>
|
||||
<DialogTitle>{editMode ? t('deploy.pipeline.editPipeline') : t('deploy.pipeline.newPipeline')}</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">
|
||||
{editMode ? '修改流水线配置' : '配置新的 CI/CD 流水线'}
|
||||
{editMode ? t('deploy.pipeline.modifyConfig') : t('deploy.pipeline.configureNew')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label className="text-[var(--dg-text-secondary)]">流水线名称</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('deploy.pipeline.name')}</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[var(--dg-text-secondary)]">描述</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('common.description')}</Label>
|
||||
<Textarea
|
||||
value={formData.description}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[var(--dg-text-secondary)]">代码仓库</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('deploy.table.codeCol')}</Label>
|
||||
<Input
|
||||
value={formData.source}
|
||||
onChange={(e) => setFormData({ ...formData, source: e.target.value })}
|
||||
@@ -77,7 +79,7 @@ export function PipelineFormDialog({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[var(--dg-text-secondary)]">默认分支</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('deploy.table.defaultBranch')}</Label>
|
||||
<Input
|
||||
value={formData.branch}
|
||||
onChange={(e) => setFormData({ ...formData, branch: e.target.value })}
|
||||
@@ -87,7 +89,7 @@ export function PipelineFormDialog({
|
||||
</div>
|
||||
{!editMode && (
|
||||
<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">
|
||||
{pipelineTemplates.map((template) => (
|
||||
<Card
|
||||
@@ -123,10 +125,10 @@ export function PipelineFormDialog({
|
||||
</div>
|
||||
<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)]">
|
||||
取消
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={onSave}>
|
||||
{editMode ? '保存修改' : '创建流水线'}
|
||||
{editMode ? t('deploy.pipeline.editMode') : t('deploy.pipeline.createMode')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -25,24 +26,25 @@ export function PipelineRunDialog({
|
||||
selectedPipeline?: JsonData;
|
||||
onRun: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>执行流水线</DialogTitle>
|
||||
<DialogTitle>{t('deploy.runDialog.title')}</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">{selectedPipeline?.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[var(--dg-text-secondary)]">Commit ID (可选)</Label>
|
||||
<Input placeholder="留空则使用最新提交" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] mt-1" />
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('deploy.form.commitId')}</Label>
|
||||
<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>
|
||||
<Label className="text-[var(--dg-text-secondary)]">环境变量 (可选)</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('deploy.var.envVarOptional')}</Label>
|
||||
<Textarea
|
||||
placeholder="KEY=value KEY2=value2"
|
||||
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>
|
||||
<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)]">
|
||||
取消
|
||||
{t('deploy.form.cancel')}
|
||||
</Button>
|
||||
<Button className="bg-emerald-600 hover:bg-emerald-700" onClick={onRun}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
开始执行
|
||||
{t('deploy.runDialog.startExec')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 新建发布弹窗(应用/版本/环境/Commit/说明 + 提交申请) // Logan.Li
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -39,6 +40,7 @@ const EMPTY_FORM = {
|
||||
};
|
||||
|
||||
export function ReleaseCreateDialog({ open, onOpenChange, onSuccess }: ReleaseCreateDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState({ ...EMPTY_FORM });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -48,7 +50,7 @@ export function ReleaseCreateDialog({ open, onOpenChange, onSuccess }: ReleaseCr
|
||||
// 提交发布申请 // Logan.Li
|
||||
const handleSubmit = async () => {
|
||||
if (!form.appName.trim() || !form.version.trim()) {
|
||||
toast.error('请填写应用名称和版本号');
|
||||
toast.error(t('deploy.release.nameVerRequired'));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
@@ -61,12 +63,12 @@ export function ReleaseCreateDialog({ open, onOpenChange, onSuccess }: ReleaseCr
|
||||
commit_msg: form.commitMsg.trim(),
|
||||
description: form.description.trim(),
|
||||
});
|
||||
toast.success('发布申请已提交');
|
||||
toast.success(t('deploy.toast.releaseSubmitted'));
|
||||
onOpenChange(false);
|
||||
setForm({ ...EMPTY_FORM });
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
toast.error('创建失败');
|
||||
toast.error(t('deploy.toast.createFailed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -76,22 +78,22 @@ export function ReleaseCreateDialog({ open, onOpenChange, onSuccess }: ReleaseCr
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl dg-card">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">新建发布</DialogTitle>
|
||||
<DialogDescription className="dg-text-muted">创建新的应用版本发布申请</DialogDescription>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">{t('deploy.release.newRelease')}</DialogTitle>
|
||||
<DialogDescription className="dg-text-muted">{t('deploy.release.createNew')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label className="text-[var(--dg-text-secondary)]">应用名称</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('deploy.release.appName')}</Label>
|
||||
<Input
|
||||
value={form.appName}
|
||||
onChange={(e) => update('appName', e.target.value)}
|
||||
placeholder="如:用户中心服务"
|
||||
placeholder={t('deploy.releaseCreate.appPlaceholder')}
|
||||
className="mt-1 dg-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-[var(--dg-text-secondary)]">版本号</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('deploy.release.versionNo')}</Label>
|
||||
<Input
|
||||
value={form.version}
|
||||
onChange={(e) => update('version', e.target.value)}
|
||||
@@ -100,7 +102,7 @@ export function ReleaseCreateDialog({ open, onOpenChange, onSuccess }: ReleaseCr
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[var(--dg-text-secondary)]">目标环境</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('deploy.release.targetEnv')}</Label>
|
||||
<Select
|
||||
value={form.environment}
|
||||
onValueChange={(value) => update('environment', value)}
|
||||
@@ -109,10 +111,10 @@ export function ReleaseCreateDialog({ open, onOpenChange, onSuccess }: ReleaseCr
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="dg-input">
|
||||
<SelectItem value="dev">开发环境</SelectItem>
|
||||
<SelectItem value="test">测试环境</SelectItem>
|
||||
<SelectItem value="staging">预发布</SelectItem>
|
||||
<SelectItem value="prod">生产环境</SelectItem>
|
||||
<SelectItem value="dev">{t('deploy.env.devEnv')}</SelectItem>
|
||||
<SelectItem value="test">{t('deploy.env.testEnv')}</SelectItem>
|
||||
<SelectItem value="staging">{t('deploy.env.staging')}</SelectItem>
|
||||
<SelectItem value="prod">{t('deploy.env.prodEnv')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -128,32 +130,32 @@ export function ReleaseCreateDialog({ open, onOpenChange, onSuccess }: ReleaseCr
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[var(--dg-text-secondary)]">提交信息</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('deploy.releaseCreate.commitMsg')}</Label>
|
||||
<Input
|
||||
value={form.commitMsg}
|
||||
onChange={(e) => update('commitMsg', e.target.value)}
|
||||
placeholder="feat: 新增功能"
|
||||
placeholder={t('deploy.releaseCreate.commitMsgPlaceholder')}
|
||||
className="mt-1 dg-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[var(--dg-text-secondary)]">发布说明</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('deploy.release.releaseDesc')}</Label>
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(e) => update('description', e.target.value)}
|
||||
placeholder="描述本次发布的内容..."
|
||||
placeholder={t('deploy.releaseCreate.descPlaceholder')}
|
||||
className="mt-1 dg-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
{t('deploy.form.cancel')}
|
||||
</Button>
|
||||
<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" />}
|
||||
提交申请
|
||||
{t('deploy.release.submitApp')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 发布详情弹窗(流水线进度 + 构建日志 + 重新部署) // Logan.Li
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
import { Rocket, CheckCircle, Loader2, RotateCcw } from 'lucide-react';
|
||||
import { releaseApi } from '@/api';
|
||||
import { toast } from 'sonner';
|
||||
import { PIPELINE_STEPS } from './releaseHelpers';
|
||||
import { getPipelineSteps } from './releaseHelpers';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
interface ReleaseDetailDialogProps {
|
||||
@@ -25,6 +26,7 @@ interface ReleaseDetailDialogProps {
|
||||
}
|
||||
|
||||
export function ReleaseDetailDialog({ release, open, onOpenChange, onSuccess }: ReleaseDetailDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// 重新触发部署 // Logan.Li
|
||||
@@ -33,16 +35,18 @@ export function ReleaseDetailDialog({ release, open, onOpenChange, onSuccess }:
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await releaseApi.deployRelease(release.id);
|
||||
toast.success('已重新触发部署');
|
||||
toast.success(t('deploy.toast.redeployed'));
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
toast.error('重新部署失败');
|
||||
toast.error(t('deploy.toast.redeployFailed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pipelineSteps = getPipelineSteps(t);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl dg-card">
|
||||
@@ -55,16 +59,16 @@ export function ReleaseDetailDialog({ release, open, onOpenChange, onSuccess }:
|
||||
</Badge>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="dg-text-muted">
|
||||
环境: {release?.environment}
|
||||
{t('deploy.releaseDialog.envLabel')} {release?.environment}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Pipeline Steps */}
|
||||
<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">
|
||||
{PIPELINE_STEPS.map((step, index) => (
|
||||
{pipelineSteps.map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
@@ -99,7 +103,7 @@ export function ReleaseDetailDialog({ release, open, onOpenChange, onSuccess }:
|
||||
|
||||
{/* Build Logs */}
|
||||
<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="space-y-1">
|
||||
<p>[10:00:00] Starting pipeline for {release?.app_name}...</p>
|
||||
@@ -129,7 +133,7 @@ export function ReleaseDetailDialog({ release, open, onOpenChange, onSuccess }:
|
||||
disabled={submitting}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
重新部署
|
||||
{t('deploy.release.redeploy')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -42,6 +43,7 @@ import { PageEmpty } from '@/components/layout/PageEmpty';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
|
||||
export function ReleaseManager() {
|
||||
const { t } = useTranslation();
|
||||
const _confirm = useConfirm();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [envFilter, setEnvFilter] = useState<string>('all');
|
||||
@@ -188,8 +190,8 @@ export function ReleaseManager() {
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-medium text-[var(--dg-text-primary)]">{release.app_name}</h3>
|
||||
{getStatusBadge(release.status)}
|
||||
{getEnvironmentBadge(release.environment)}
|
||||
{getStatusBadge(release.status, t)}
|
||||
{getEnvironmentBadge(release.environment, t)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-[var(--dg-text-secondary)]">
|
||||
<span className="flex items-center gap-1">
|
||||
@@ -302,8 +304,8 @@ export function ReleaseManager() {
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusBadge(selectedRelease.status)}
|
||||
{getEnvironmentBadge(selectedRelease.environment)}
|
||||
{getStatusBadge(selectedRelease.status, t)}
|
||||
{getEnvironmentBadge(selectedRelease.environment, t)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCw, Loader2, Download, CheckCircle, XCircle, Clock, Settings2, ChevronDown, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -21,8 +22,8 @@ interface VersionOption {
|
||||
version: string; label: string;
|
||||
}
|
||||
|
||||
const categoryLabel: Record<string, string> = {
|
||||
build: '构建', deploy: '部署', automation: '自动化', other: '其他',
|
||||
const categoryLabelKey: Record<string, string> = {
|
||||
build: 'pipeline.build', deploy: 'pipeline.deploy', automation: 'pipeline.automation', other: 'pipeline.other',
|
||||
};
|
||||
const categoryColor: Record<string, string> = {
|
||||
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'];
|
||||
|
||||
export default function ToolConfigTab() {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [tools, setTools] = useState<ToolConfig[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -202,7 +204,7 @@ export default function ToolConfigTab() {
|
||||
</td>
|
||||
<td className="px-3 py-2 hidden sm:table-cell">
|
||||
<Badge className={`text-xs ${categoryColor[tool.category] || categoryColor.other}`}>
|
||||
{categoryLabel[tool.category] || tool.category}
|
||||
{categoryLabelKey[tool.category] || tool.category}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -50,40 +51,41 @@ export function JobRoleCreateDialog({
|
||||
onDescriptionChange,
|
||||
onSubmit,
|
||||
}: JobRoleCreateDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const preview = useMemo(() => matchPipelines(pattern, pipelines), [pattern, pipelines]);
|
||||
const patternHint = useMemo(() => {
|
||||
if (!pattern.trim()) return '输入正则,匹配流水线 code 或 Job 名称';
|
||||
if (!pattern.trim()) return t('rbac.jobRole.patternHint.empty');
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
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 {
|
||||
return '正则语法错误';
|
||||
return t('rbac.jobRole.patternHint.syntaxError');
|
||||
}
|
||||
}, [pattern, preview.length]);
|
||||
}, [pattern, preview.length, t]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建 Job 权限</DialogTitle>
|
||||
<DialogTitle>{t('rbac.jobRole.newTitle')}</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
按正则匹配流水线 code 或 Job 名称,分配 owner / editor / viewer 权限
|
||||
{t('rbac.jobRole.desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">名称</Label>
|
||||
<Input value={name} onChange={(e) => onNameChange(e.target.value)} placeholder="后端发布" className="h-8 text-sm dg-input" />
|
||||
<Label className="text-xs">{t('rbac.jobRole.label.name')}</Label>
|
||||
<Input value={name} onChange={(e) => onNameChange(e.target.value)} placeholder={t('rbac.jobRole.placeholder.name')} className="h-8 text-sm dg-input" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">标识</Label>
|
||||
<Input value={code} onChange={(e) => onCodeChange(e.target.value)} placeholder="backend-jobs" className="h-8 text-sm font-mono dg-input" />
|
||||
<Label className="text-xs">{t('rbac.jobRole.label.code')}</Label>
|
||||
<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 className="space-y-1">
|
||||
<Label className="text-xs">匹配正则</Label>
|
||||
<Label className="text-xs">{t('rbac.jobRole.label.pattern')}</Label>
|
||||
<Input
|
||||
value={pattern}
|
||||
onChange={(e) => onPatternChange(e.target.value)}
|
||||
@@ -93,26 +95,26 @@ export function JobRoleCreateDialog({
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{patternHint}</p>
|
||||
</div>
|
||||
<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}>
|
||||
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="owner">拥有者 — 配置 + 执行 + 授权</SelectItem>
|
||||
<SelectItem value="editor">编辑者 — 配置 + 执行</SelectItem>
|
||||
<SelectItem value="viewer">查看者 — 只读</SelectItem>
|
||||
<SelectItem value="owner">{t('rbac.jobRole.role.owner')}</SelectItem>
|
||||
<SelectItem value="editor">{t('rbac.jobRole.role.editor')}</SelectItem>
|
||||
<SelectItem value="viewer">{t('rbac.jobRole.role.viewer')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">说明</Label>
|
||||
<Input value={description} onChange={(e) => onDescriptionChange(e.target.value)} placeholder="可选" className="h-8 text-sm dg-input" />
|
||||
<Label className="text-xs">{t('rbac.jobRole.label.description')}</Label>
|
||||
<Input value={description} onChange={(e) => onDescriptionChange(e.target.value)} placeholder={t('rbac.jobRole.placeholder.description')} className="h-8 text-sm dg-input" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('rbac.jobRole.cancel')}</Button>
|
||||
<Button onClick={onSubmit} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Save className="h-4 w-4 mr-1" />}
|
||||
创建
|
||||
{t('rbac.jobRole.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -51,6 +52,7 @@ export function PipelineCard({
|
||||
onStopRun,
|
||||
}: PipelineCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const stages = parseStages(pipeline.stages);
|
||||
const repo = pipeline.repo 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 && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
{/* 最近运行摘要行:branch + commit → image:tag // Logan.Li */}
|
||||
{(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">
|
||||
<span className="flex items-center gap-1">
|
||||
<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 className="flex items-center gap-1">
|
||||
{deployTarget === 'k8s' ? (
|
||||
<>
|
||||
<Network className="h-3 w-3" />
|
||||
{k8sDep ? `${k8sNs}/${k8sDep}` : 'Kubernetes Deployment'}
|
||||
{k8sDep ? `${k8sNs}/${k8sDep}` : t('deploy.pipelineCard.k8sDeployment')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Server className="h-3 w-3" />
|
||||
{serverCount > 0 ? `${serverCount} 台主机` : 'SSH 主机'}
|
||||
{serverCount > 0 ? `${serverCount} ${t('deploy.pipelineCard.hostCountUnit')}` : t('deploy.banner.sshHost')}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
@@ -159,16 +161,16 @@ export function PipelineCard({
|
||||
<div className="flex items-center gap-1 ml-4 shrink-0">
|
||||
{pipeline.last_status === 'running' ? (
|
||||
<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 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 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`)}>
|
||||
<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 variant="ghost" size="sm" className="h-8" onClick={onToggleExpand}>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 发布相关共享工具函数与常量 // Logan.Li
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
CheckCircle,
|
||||
@@ -9,48 +10,48 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
// 发布状态徽章(列表 + 详情共用) // Logan.Li
|
||||
export function getStatusBadge( status: string) {
|
||||
export function getStatusBadge(status: string, t: TFunction) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return (
|
||||
<Badge variant="outline" className="border-emerald-500/30 dg-text-green bg-emerald-500/10">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
成功
|
||||
{t('deploy.status.success')}
|
||||
</Badge>
|
||||
);
|
||||
case 'failed':
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
失败
|
||||
{t('deploy.status.failed')}
|
||||
</Badge>
|
||||
);
|
||||
case 'deploying':
|
||||
return (
|
||||
<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" />
|
||||
发布中
|
||||
{t('deploy.status.deploying')}
|
||||
</Badge>
|
||||
);
|
||||
case 'building':
|
||||
return (
|
||||
<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" />
|
||||
构建中
|
||||
{t('deploy.status.building')}
|
||||
</Badge>
|
||||
);
|
||||
case 'rolled_back':
|
||||
return (
|
||||
<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" />
|
||||
已回滚
|
||||
{t('deploy.status.rolledBack')}
|
||||
</Badge>
|
||||
);
|
||||
case 'pending':
|
||||
return (
|
||||
<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" />
|
||||
待发布
|
||||
{t('deploy.status.pendingRelease')}
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
@@ -59,34 +60,36 @@ export function getStatusBadge( status: string) {
|
||||
}
|
||||
|
||||
// 环境徽章 // Logan.Li
|
||||
export function getEnvBadge( env: string) {
|
||||
export function getEnvBadge(env: string, t: TFunction) {
|
||||
const colors: Record<string, string> = {
|
||||
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',
|
||||
staging: 'border-amber-500/30 dg-text-yellow bg-amber-500/10',
|
||||
prod: 'border-emerald-500/30 dg-text-green bg-emerald-500/10',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
dev: '开发环境',
|
||||
test: '测试环境',
|
||||
staging: '预发布',
|
||||
prod: '生产环境',
|
||||
const labelKeys: Record<string, string> = {
|
||||
dev: 'deploy.env.devEnv',
|
||||
test: 'deploy.env.testEnv',
|
||||
staging: 'deploy.env.staging',
|
||||
prod: 'deploy.env.prodEnv',
|
||||
};
|
||||
return (
|
||||
<Badge variant="outline" className={colors[env] || colors.dev}>
|
||||
{labels[env] || env}
|
||||
{labelKeys[env] ? t(labelKeys[env]) : env}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// 模拟流水线步骤(详情弹窗使用) // Logan.Li
|
||||
export const PIPELINE_STEPS = [
|
||||
{ name: '代码检出', status: 'success', duration: '15s' },
|
||||
{ name: '依赖安装', status: 'success', duration: '45s' },
|
||||
{ name: '代码检查', status: 'success', duration: '30s' },
|
||||
{ name: '单元测试', status: 'success', duration: '2m 15s' },
|
||||
{ name: '构建镜像', status: 'success', duration: '3m 30s' },
|
||||
{ name: '推送镜像', status: 'success', duration: '1m 20s' },
|
||||
{ name: '部署到Kubernetes', status: 'running', duration: '--' },
|
||||
{ name: '健康检查', status: 'pending', duration: '--' },
|
||||
];
|
||||
export function getPipelineSteps(t: TFunction) {
|
||||
return [
|
||||
{ name: t('deploy.stage.checkout'), status: 'success', duration: '15s' },
|
||||
{ name: t('deploy.template.dependencyInstall'), status: 'success', duration: '45s' },
|
||||
{ name: t('deploy.releaseHelpers.pipelineStep.codeCheck'), status: 'success', duration: '30s' },
|
||||
{ name: t('deploy.template.unitTest'), status: 'success', duration: '2m 15s' },
|
||||
{ name: t('deploy.releaseHelpers.pipelineStep.buildImage'), status: 'success', duration: '3m 30s' },
|
||||
{ name: t('deploy.releaseHelpers.pipelineStep.pushImage'), status: 'success', duration: '1m 20s' },
|
||||
{ name: t('deploy.stage.deployDesc'), status: 'running', duration: '--' },
|
||||
{ name: t('deploy.template.healthCheck'), status: 'pending', duration: '--' },
|
||||
];
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// 发布管理(审批流)共享工具函数与常量 // Logan.Li
|
||||
// 注意:本文件专用于 ReleaseManager(含审批流,状态标签与 AppRelease 的 releaseHelpers 不同)
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
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' },
|
||||
};
|
||||
|
||||
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
|
||||
export function getStatusBadge( status: string) {
|
||||
const map: Record<string, { icon: any; label: string; cls: string }> = {
|
||||
pending: { icon: Clock, label: '待审批', 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' },
|
||||
rejected: { icon: XCircle, label: '已驳回', 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' },
|
||||
success: { icon: CheckCircle, label: '发布成功', 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' },
|
||||
rolled_back: { icon: RotateCcw, label: '已回滚', cls: 'border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10' },
|
||||
export function getStatusBadge(status: string, t: TFunction) {
|
||||
const map: Record<string, { icon: any; labelKey: string; cls: string }> = {
|
||||
pending: { icon: Clock, labelKey: 'deploy.status.pendingApproval', cls: 'border-amber-500/30 dg-text-yellow bg-amber-500/10' },
|
||||
approved: { icon: Check, labelKey: 'deploy.status.approved', cls: 'border-purple-500/30 dg-text-purple bg-purple-500/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, labelKey: 'deploy.status.deploying', cls: 'border-blue-500/30 dg-text-blue bg-blue-500/10 animate-pulse' },
|
||||
success: { icon: CheckCircle, labelKey: 'deploy.status.succeeded', cls: 'border-emerald-500/30 dg-text-green bg-emerald-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, labelKey: 'deploy.status.rolledBack', cls: 'border-[var(--dg-border)] dg-text-secondary bg-[var(--dg-bg-active)]/10' },
|
||||
};
|
||||
const cfg = map[status];
|
||||
if (!cfg) return null;
|
||||
@@ -35,17 +43,17 @@ export function getStatusBadge( status: string) {
|
||||
return (
|
||||
<Badge variant="outline" className={cfg.cls}>
|
||||
<Icon className="h-3 w-3 mr-1" />
|
||||
{cfg.label}
|
||||
{t(cfg.labelKey)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// 环境徽章 // Logan.Li
|
||||
export function getEnvironmentBadge( env: string) {
|
||||
export function getEnvironmentBadge(env: string, t: TFunction) {
|
||||
const cfg = ENVIRONMENTS[env];
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// YAML 查看/编辑统一工具栏:复制 · 下载 · 编辑 · 保存 // Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Copy, Check, Save, Loader2, Pencil, Download } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
@@ -23,29 +24,30 @@ export function YamlEditorToolbar({
|
||||
readOnly = false, showRawToggle = true,
|
||||
onCopy, onDownload, onEdit, onCancel, onSave, onToggleRaw,
|
||||
}: YamlEditorToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const sz = compact ? 'h-7 text-xs' : 'h-8';
|
||||
return (
|
||||
<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}>
|
||||
{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>
|
||||
{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>
|
||||
)}
|
||||
{!readOnly && (
|
||||
!editing ? (
|
||||
<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 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}>
|
||||
{saving ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Save className="h-3 w-3 mr-1" />}
|
||||
保存
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
@@ -56,7 +58,7 @@ export function YamlEditorToolbar({
|
||||
onClick={onToggleRaw}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// DeploymentHpaTab.tsx — Deployment 详情 HPA Tab // Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Gauge, Trash2 } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -26,12 +27,13 @@ export function DeploymentHpaTab({
|
||||
onCreated,
|
||||
onDeleteHpa,
|
||||
}: DeploymentHpaTabProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TabsContent value="hpa">
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-2">
|
||||
<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
|
||||
clusterCode={clusterCode}
|
||||
namespace={namespace}
|
||||
@@ -44,18 +46,18 @@ export function DeploymentHpaTab({
|
||||
{hpas.length === 0 ? (
|
||||
<div className="text-center py-12 dg-text-muted">
|
||||
<Gauge className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>未配置 HPA</p>
|
||||
<p className="text-xs mt-1">点击右上角「创建 HPA」设置自动扩缩容</p>
|
||||
<p>{t('k8s.deploymentDetail.noHpa')}</p>
|
||||
<p className="text-xs mt-1">{t('k8s.deploymentDetail.createHpaHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="dg-text-secondary">名称</TableHead>
|
||||
<TableHead className="dg-text-secondary">最小/最大</TableHead>
|
||||
<TableHead className="dg-text-secondary">指标</TableHead>
|
||||
<TableHead className="dg-text-secondary">当前/目标</TableHead>
|
||||
<TableHead className="dg-text-secondary">操作</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('k8s.column.name')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('k8s.hpaCronHpa.col.minMax')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('k8s.hpaCronHpa.col.metrics')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('k8s.deploymentDetail.currentTarget')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('common.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -74,7 +76,7 @@ export function DeploymentHpaTab({
|
||||
<TableCell className="text-[var(--dg-text-secondary)]">{hpa.current_replicas || 0}</TableCell>
|
||||
<TableCell>
|
||||
<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" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// DeploymentLogsTab.tsx — Deployment 详情日志 Tab // Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FileText, ExternalLink } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -31,19 +32,20 @@ export function DeploymentLogsTab({
|
||||
onLogsTabPodChange,
|
||||
onOpenLogsViewer,
|
||||
}: DeploymentLogsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TabsContent value="logs">
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-2">
|
||||
<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">
|
||||
<span className="text-xs dg-text-muted">最近</span>
|
||||
<span className="text-xs dg-text-muted">{t('k8s.deploymentDetail.recent')}</span>
|
||||
<Input
|
||||
type="number" value={logTail} onChange={(e) => onLogTailChange(Number(e.target.value))}
|
||||
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>
|
||||
</CardHeader>
|
||||
@@ -51,15 +53,15 @@ export function DeploymentLogsTab({
|
||||
{pods.length === 0 ? (
|
||||
<div className="text-center py-12 dg-text-muted">
|
||||
<FileText className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>暂无 Pod</p>
|
||||
<p>{t('k8s.deploymentDetail.noPod')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 py-10 justify-center flex-wrap">
|
||||
<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}>
|
||||
<SelectTrigger className="w-[280px] h-8 dg-input text-xs">
|
||||
<SelectValue placeholder="选择 Pod…" />
|
||||
<SelectValue placeholder={t('k8s.deploymentDetail.selectPodPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pods.map((p: JsonData) => (
|
||||
@@ -76,10 +78,10 @@ export function DeploymentLogsTab({
|
||||
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>
|
||||
<span className="text-xs dg-text-muted block w-full text-center mt-2">
|
||||
支持实时跟踪、多容器切换、previous 日志、关键字过滤、导出
|
||||
{t('k8s.deploymentDetail.logsFeatures')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// DeploymentPodsTab.tsx — Deployment 详情 Pod 列表 Tab // Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FileText, Terminal, FolderOpen, FileCode } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -20,6 +21,7 @@ export interface DeploymentPodsTabProps {
|
||||
}
|
||||
|
||||
export function DeploymentPodsTab({ pods, onOpenLogsViewer, onOpenExec, onOpenFileBrowser, onOpenYaml }: DeploymentPodsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TabsContent value="pods">
|
||||
<Card className="dg-card">
|
||||
@@ -27,19 +29,19 @@ export function DeploymentPodsTab({ pods, onOpenLogsViewer, onOpenExec, onOpenFi
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="dg-text-secondary">Pod 名称</TableHead>
|
||||
<TableHead className="dg-text-secondary">状态</TableHead>
|
||||
<TableHead className="dg-text-secondary">节点</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('k8s.deploymentDetail.podName')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('k8s.column.status')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('k8s.column.node')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">IP</TableHead>
|
||||
<TableHead className="dg-text-secondary">重启</TableHead>
|
||||
<TableHead className="dg-text-secondary">操作</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('k8s.deploymentDetail.restart')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('common.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pods.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center dg-text-muted py-8">
|
||||
暂无 Pod
|
||||
{t('k8s.deploymentDetail.noPod')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
@@ -61,22 +63,22 @@ export function DeploymentPodsTab({ pods, onOpenLogsViewer, onOpenExec, onOpenFi
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<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) })}>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<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) })}>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<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) })}>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<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)}>
|
||||
<FileCode className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// DnsHostSection.tsx — DNS 配置 + 主机别名 // Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Globe, MapPin, Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -36,6 +37,7 @@ export function DnsHostSection({
|
||||
hostAliases,
|
||||
onHostAliasesChange,
|
||||
}: DnsHostSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const addDnsOption = () => onDnsOptionsChange([...dnsOptions, { name: '', value: '' }]);
|
||||
const updateDnsOption = (index: number, patch: Partial<DnsOptionEntry>) =>
|
||||
onDnsOptionsChange(dnsOptions.map((o, i) => (i === index ? { ...o, ...patch } : o)));
|
||||
@@ -53,16 +55,16 @@ export function DnsHostSection({
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-3">
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-3">
|
||||
<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>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="dg-text-muted text-xs">nameservers(每行一个 IP)</Label>
|
||||
<Label className="dg-text-muted text-xs">{t('k8s.dnsHost.nameservers')}</Label>
|
||||
<textarea
|
||||
value={dnsNameservers}
|
||||
onChange={e => onDnsNameserversChange(e.target.value)}
|
||||
@@ -72,7 +74,7 @@ export function DnsHostSection({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="dg-text-muted text-xs">searches(搜索域,每行一个)</Label>
|
||||
<Label className="dg-text-muted text-xs">{t('k8s.dnsHost.searches')}</Label>
|
||||
<textarea
|
||||
value={dnsSearches}
|
||||
onChange={e => onDnsSearchesChange(e.target.value)}
|
||||
@@ -84,13 +86,13 @@ export function DnsHostSection({
|
||||
</div>
|
||||
<div>
|
||||
<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}>
|
||||
<Plus className="h-3 w-3 mr-1" />添加
|
||||
<Plus className="h-3 w-3 mr-1" />{t('common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
{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) => (
|
||||
<div key={i} className="flex items-center gap-2 mb-1.5">
|
||||
@@ -104,7 +106,7 @@ export function DnsHostSection({
|
||||
<Input
|
||||
value={o.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"
|
||||
/>
|
||||
<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">
|
||||
<CardHeader className="pb-3">
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-2">
|
||||
<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}>
|
||||
<Plus className="h-3 w-3 mr-1" />添加
|
||||
<Plus className="h-3 w-3 mr-1" />{t('common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
{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) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// SchedulingSection.tsx — 调度:节点名称 / 标签选择器 / 节点亲和性 // Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, Plus, Server, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -31,10 +32,10 @@ export interface SchedulingSectionProps {
|
||||
nodeLabels: Record<string, string[]>;
|
||||
}
|
||||
|
||||
const SCHEDULE_MODES: Array<{ key: ScheduleMode; label: string }> = [
|
||||
{ key: 'nodeName', label: '节点名称' },
|
||||
{ key: 'nodeSelector', label: '标签选择器' },
|
||||
{ key: 'nodeAffinity', label: '节点亲和性' },
|
||||
const SCHEDULE_MODES: Array<{ key: ScheduleMode }> = [
|
||||
{ key: 'nodeName' },
|
||||
{ key: 'nodeSelector' },
|
||||
{ key: 'nodeAffinity' },
|
||||
];
|
||||
|
||||
export function SchedulingSection({
|
||||
@@ -50,12 +51,13 @@ export function SchedulingSection({
|
||||
onUpdateLabelSelector,
|
||||
nodeLabels,
|
||||
}: SchedulingSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<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 => (
|
||||
@@ -68,24 +70,24 @@ export function SchedulingSection({
|
||||
: 'dg-text-secondary hover:text-[var(--dg-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{m.label}
|
||||
{t(`k8s.scheduling.mode.${m.key}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs dg-text-muted mt-1">
|
||||
{scheduleMode === 'nodeName' && 'Pod 将直接调度到指定节点'}
|
||||
{scheduleMode === 'nodeSelector' && 'Pod 将调度到匹配标签的节点'}
|
||||
{scheduleMode === 'nodeAffinity' && '通过亲和性规则控制 Pod 调度到指定节点'}
|
||||
{scheduleMode === 'nodeName' && t('k8s.scheduling.hint.nodeName')}
|
||||
{scheduleMode === 'nodeSelector' && t('k8s.scheduling.hint.nodeSelector')}
|
||||
{scheduleMode === 'nodeAffinity' && t('k8s.scheduling.hint.nodeAffinity')}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{scheduleMode === 'nodeName' && (
|
||||
<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 ? (
|
||||
<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>
|
||||
) : (
|
||||
<select
|
||||
@@ -93,7 +95,7 @@ export function SchedulingSection({
|
||||
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"
|
||||
>
|
||||
<option value="">(不指定)</option>
|
||||
<option value="">{t('k8s.scheduling.notSpecified')}</option>
|
||||
{availableNodes.map(n => (
|
||||
<option key={n.name} value={n.name}>
|
||||
{n.name} {n.ip ? `(${n.ip})` : ''}
|
||||
@@ -103,7 +105,7 @@ export function SchedulingSection({
|
||||
)}
|
||||
{nodeName && (
|
||||
<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>
|
||||
@@ -112,13 +114,13 @@ export function SchedulingSection({
|
||||
{scheduleMode === 'nodeSelector' && (
|
||||
<div className="space-y-2">
|
||||
<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}>
|
||||
<Plus className="h-3 w-3 mr-1" />添加
|
||||
<Plus className="h-3 w-3 mr-1" />{t('k8s.scheduling.add')}
|
||||
</Button>
|
||||
</div>
|
||||
{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) => (
|
||||
<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: '' })}
|
||||
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]) => (
|
||||
<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"
|
||||
disabled={!n.key}
|
||||
>
|
||||
<option value="">选择值</option>
|
||||
<option value="">{t('k8s.scheduling.selectValuePlaceholder')}</option>
|
||||
{(nodeLabels[n.key] || []).map(v => (
|
||||
<option key={v} value={v}>{v}</option>
|
||||
))}
|
||||
@@ -161,14 +163,14 @@ export function SchedulingSection({
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
{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) => (
|
||||
<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: '' })}
|
||||
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]) => (
|
||||
<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"
|
||||
disabled={!n.key}
|
||||
>
|
||||
<option value="">选择值</option>
|
||||
<option value="">{t('k8s.scheduling.selectValuePlaceholder')}</option>
|
||||
{(nodeLabels[n.key] || []).map(v => (
|
||||
<option key={v} value={v}>{v}</option>
|
||||
))}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// deployment-view/EventsTab.tsx — 事件 Tab // Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EventTable } from '../EventTable';
|
||||
import type { EventItem } from './types';
|
||||
|
||||
export function EventsTab({ events, loading }: { events: EventItem[]; loading: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<EventTable
|
||||
events={events}
|
||||
loading={loading}
|
||||
emptyText="暂无事件"
|
||||
emptyText={t('k8s.empty.events')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
// deployment-view/HPACronHPATab.tsx — 容器伸缩 Tab (HPA + CronHPA) // Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -9,6 +10,7 @@ import { Activity, Clock, Loader2 } from 'lucide-react';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
export function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: JsonData[]; cronHpas: JsonData[]; loading: string; replicas: number }) {
|
||||
const { t } = useTranslation();
|
||||
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>;
|
||||
}
|
||||
@@ -17,29 +19,29 @@ export function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: Jso
|
||||
<div className="space-y-4">
|
||||
{/* 当前副本数提示 */}
|
||||
<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-xs text-[var(--dg-text-muted)]">(可通过 Tab 栏右侧 +/- 手动调整)</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{t('k8s.hpaCronHpa.scaleHint')}</span>
|
||||
</div>
|
||||
|
||||
{/* HPA 策略 */}
|
||||
<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>
|
||||
{hpas.length === 0 ? (
|
||||
<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 className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">最小/最大</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">指标</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">当前负载</TableHead>
|
||||
<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)]">{t('k8s.hpaCronHpa.col.minMax')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">{t('k8s.hpaCronHpa.col.metrics')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">{t('k8s.hpaCronHpa.col.currentLoad')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">{t('k8s.hpaCronHpa.col.status')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<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>
|
||||
<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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -80,22 +82,22 @@ export function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: Jso
|
||||
|
||||
{/* CronHPA 策略 */}
|
||||
<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>
|
||||
{cronHpas.length === 0 ? (
|
||||
<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 className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">目标副本</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">调度规则</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">时区</TableHead>
|
||||
<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)]">{t('k8s.hpaCronHpa.cronCol.targetReplicas')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">{t('k8s.hpaCronHpa.cronCol.scheduleRule')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">{t('k8s.hpaCronHpa.cronCol.timezone')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">{t('k8s.hpaCronHpa.cronCol.status')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<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 text-[var(--dg-text-muted)]">{tz}</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>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// deployment-view/HistoryTab.tsx — 历史版本 Tab // Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@@ -9,8 +10,9 @@ import { formatK8sTime } from '../k8s-yaml-utils';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
export function HistoryTab({ revisions, rollbackLoading, onRollback }: { revisions: JsonData[]; rollbackLoading: number | null; onRollback: (rev: JsonData) => void }) {
|
||||
const { t } = useTranslation();
|
||||
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 (
|
||||
<div className="overflow-x-auto">
|
||||
@@ -18,10 +20,10 @@ export function HistoryTab({ revisions, rollbackLoading, onRollback }: { revisio
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<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)] w-24">状态</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-40">更新时间</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-24">操作</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">{t('k8s.column.image')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-24">{t('k8s.column.status')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-40">{t('k8s.history.updateTime')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)] w-24">{t('common.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<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>
|
||||
{isLatest
|
||||
? <Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)] border-[var(--dg-green)]/30">当前</Badge>
|
||||
: <Badge variant="outline" className="text-[var(--dg-text-muted)]">历史</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)]">{t('k8s.history.historical')}</Badge>}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-[var(--dg-text-muted)]">{formatK8sTime(rev.created_at || rev.time)}</TableCell>
|
||||
<TableCell>
|
||||
<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" />}
|
||||
回滚
|
||||
{t('k8s.history.rollback')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// deployment-view/PodsTab.tsx — 容器组 Tab // Logan.Li
|
||||
// v2: 虚拟滚动,支持 1000+ Pod 场景流畅渲染
|
||||
import { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -27,6 +28,7 @@ export function PodsTab({
|
||||
onDeletePod,
|
||||
expandedPod, setExpandedPod,
|
||||
}: DeploymentViewProps & { expandedPod: string | null; setExpandedPod: (v: string | null) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
@@ -37,7 +39,7 @@ export function PodsTab({
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -52,13 +54,13 @@ export function PodsTab({
|
||||
style={{ gridTemplateColumns: GRID_COLS }}
|
||||
>
|
||||
<span />
|
||||
<span>名称</span>
|
||||
<span>状态</span>
|
||||
<span>节点</span>
|
||||
<span>{t('k8s.pods.col.name')}</span>
|
||||
<span>{t('k8s.pods.col.status')}</span>
|
||||
<span>{t('k8s.pods.col.node')}</span>
|
||||
<span>IP</span>
|
||||
<span className="text-center">重启</span>
|
||||
<span className="text-center">{t('k8s.pods.col.restart')}</span>
|
||||
<span>Age</span>
|
||||
<span className="text-right">操作</span>
|
||||
<span className="text-right">{t('k8s.pods.col.actions')}</span>
|
||||
</div>
|
||||
|
||||
{/* 虚拟滚动列表 */}
|
||||
@@ -134,10 +136,10 @@ export function PodsTab({
|
||||
</span>
|
||||
|
||||
<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" />
|
||||
</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" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
@@ -146,21 +148,21 @@ export function PodsTab({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 shrink-0 text-[var(--dg-text-muted)] hover:text-[var(--dg-blue)]"
|
||||
title="更多"
|
||||
aria-label="更多"
|
||||
title={t('k8s.pods.action.more')}
|
||||
aria-label={t('k8s.pods.action.more')}
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<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 onClick={() => onShowPodYaml(pod)} className="text-[var(--dg-text-primary)]">
|
||||
<FileCode className="h-3.5 w-3.5 mr-2" /> YAML
|
||||
</DropdownMenuItem>
|
||||
<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>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -173,7 +175,7 @@ export function PodsTab({
|
||||
<PodConditionsRow pod={pod} />
|
||||
{containers && containers.length > 0 && (
|
||||
<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">
|
||||
{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">
|
||||
@@ -187,7 +189,7 @@ export function PodsTab({
|
||||
{c.resources && (
|
||||
<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>内存 {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>
|
||||
@@ -206,7 +208,7 @@ export function PodsTab({
|
||||
{/* 行数统计 */}
|
||||
{pods.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// deployment-view/ServicesTab.tsx — 访问方式 Tab // Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
@@ -8,6 +9,7 @@ import type { SvcItem, IngItem } from './types';
|
||||
import type { JsonData } from '@/types/common';
|
||||
|
||||
export function ServicesTab({ services, ingresses, loading }: { services: SvcItem[]; ingresses: IngItem[]; loading: string; namespace: string }) {
|
||||
const { t } = useTranslation();
|
||||
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>;
|
||||
}
|
||||
@@ -16,20 +18,20 @@ export function ServicesTab({ services, ingresses, loading }: { services: SvcIte
|
||||
{/* Services */}
|
||||
<div>
|
||||
<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>
|
||||
{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">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableHead className="text-[var(--dg-text-muted)]">名称</TableHead>
|
||||
<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)]">{t('k8s.column.type')}</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)]">Selector</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">{t('k8s.column.ports')}</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">{t('k8s.column.selector')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -55,19 +57,19 @@ export function ServicesTab({ services, ingresses, loading }: { services: SvcIte
|
||||
{/* Ingresses */}
|
||||
<div>
|
||||
<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>
|
||||
{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">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<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)]">Host</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">路径</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-muted)]">{t('k8s.column.path')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// ActionModals.tsx — 调整镜像 / 伸缩 / 删除 弹窗
|
||||
// 作者: Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { AlertTriangle, Loader2, Minus, Plus, Trash2 } from 'lucide-react';
|
||||
@@ -39,6 +40,7 @@ export function ActionModals({
|
||||
onScaleTo,
|
||||
onDelete,
|
||||
}: ActionModalsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{/* ============ 弹窗:调整镜像版本 ============ */}
|
||||
@@ -55,9 +57,9 @@ export function ActionModals({
|
||||
|
||||
{/* ============ 弹窗:伸缩 ============ */}
|
||||
{modal?.kind === 'scale' && (
|
||||
<ModalShell title="伸缩 (Scale)" onClose={onClose}>
|
||||
<ModalShell title={t('k8s.actionModal.scaleTitle')} onClose={onClose}>
|
||||
<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">
|
||||
<Button variant="outline" size="icon" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
|
||||
disabled={actionLoading === 'scale' || scaleValue <= 0}
|
||||
@@ -74,9 +76,9 @@ export function ActionModals({
|
||||
</Button>
|
||||
</div>
|
||||
<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}>
|
||||
{actionLoading === 'scale' ? <Loader2 className="h-4 w-4 animate-spin" /> : '确认'}
|
||||
{actionLoading === 'scale' ? <Loader2 className="h-4 w-4 animate-spin" /> : t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,21 +87,21 @@ export function ActionModals({
|
||||
|
||||
{/* ============ 弹窗:删除 ============ */}
|
||||
{modal?.kind === 'delete' && (
|
||||
<ModalShell title="删除 Deployment" onClose={onClose}>
|
||||
<ModalShell title={t('k8s.actionModal.deleteTitle')} onClose={onClose}>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 dg-text-red">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<span className="font-semibold">此操作不可撤销</span>
|
||||
<span className="font-semibold">{t('k8s.actionModal.irreversible')}</span>
|
||||
</div>
|
||||
<p className="text-sm dg-text-secondary">
|
||||
确认删除 Deployment <code className="text-[var(--dg-text-primary)] font-mono">{namespace}/{deploymentName}</code>?
|
||||
其管理的所有 Pod 将被一并回收。
|
||||
{t('k8s.actionModal.deleteConfirm', { namespace, name: deploymentName })}
|
||||
{t('k8s.actionModal.deleteConfirmDesc')}
|
||||
</p>
|
||||
<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}>
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 页面级空状态 // Logan.Li
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PageEmptyProps {
|
||||
@@ -12,15 +13,17 @@ interface PageEmptyProps {
|
||||
|
||||
export function PageEmpty({
|
||||
icon,
|
||||
title = '暂无数据',
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className,
|
||||
}: PageEmptyProps) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTitle = title ?? t('common.noData');
|
||||
return (
|
||||
<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>}
|
||||
<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>}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 页面级加载态 // Logan.Li
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PageLoadingProps {
|
||||
@@ -8,7 +9,9 @@ interface PageLoadingProps {
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -18,7 +21,7 @@ export function PageLoading({ label = '加载中...', className, compact }: Page
|
||||
)}
|
||||
>
|
||||
<Loader2 className="h-6 w-6 animate-spin mb-2" />
|
||||
<span className="text-sm">{label}</span>
|
||||
<span className="text-sm">{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -25,6 +26,7 @@ export function AlertEventsTab({
|
||||
loading: boolean;
|
||||
onReload: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const _confirm = useConfirm();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [severityFilter, setSeverityFilter] = useState<string>('all');
|
||||
@@ -45,21 +47,21 @@ export function AlertEventsTab({
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
严重
|
||||
{t('monitor.alertEvents.severity.critical')}
|
||||
</Badge>
|
||||
);
|
||||
case 'warning':
|
||||
return (
|
||||
<Badge variant="outline" className="border-amber-500/30 dg-text-yellow bg-amber-500/10">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
警告
|
||||
{t('monitor.alertEvents.severity.warning')}
|
||||
</Badge>
|
||||
);
|
||||
case 'info':
|
||||
return (
|
||||
<Badge variant="outline" className="border-blue-500/30 dg-text-blue bg-blue-500/10">
|
||||
<Bell className="h-3 w-3 mr-1" />
|
||||
提示
|
||||
{t('monitor.alertEvents.severity.info')}
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
@@ -72,13 +74,13 @@ export function AlertEventsTab({
|
||||
case 'firing':
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10 animate-pulse">
|
||||
触发中
|
||||
{t('monitor.alertEvents.status.firing')}
|
||||
</Badge>
|
||||
);
|
||||
case 'resolved':
|
||||
return (
|
||||
<Badge variant="outline" className="border-emerald-500/30 dg-text-green bg-emerald-500/10">
|
||||
已恢复
|
||||
{t('monitor.alertEvents.status.resolved')}
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
@@ -102,7 +104,7 @@ export function AlertEventsTab({
|
||||
<div className="relative w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
|
||||
<Input
|
||||
placeholder="搜索告警名称或目标..."
|
||||
placeholder={t('monitor.alertEvents.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9 dg-input"
|
||||
@@ -113,19 +115,19 @@ export function AlertEventsTab({
|
||||
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"
|
||||
>
|
||||
<option value="all">全部级别</option>
|
||||
<option value="critical">严重</option>
|
||||
<option value="warning">警告</option>
|
||||
<option value="info">提示</option>
|
||||
<option value="all">{t('monitor.alertEvents.filter.allSeverity')}</option>
|
||||
<option value="critical">{t('monitor.alertEvents.severity.critical')}</option>
|
||||
<option value="warning">{t('monitor.alertEvents.severity.warning')}</option>
|
||||
<option value="info">{t('monitor.alertEvents.severity.info')}</option>
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
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"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="firing">触发中</option>
|
||||
<option value="resolved">已恢复</option>
|
||||
<option value="all">{t('monitor.alertEvents.filter.allStatus')}</option>
|
||||
<option value="firing">{t('monitor.alertEvents.status.firing')}</option>
|
||||
<option value="resolved">{t('monitor.alertEvents.status.resolved')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -136,14 +138,14 @@ export function AlertEventsTab({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="dg-text-secondary">级别</TableHead>
|
||||
<TableHead className="dg-text-secondary">告警名称</TableHead>
|
||||
<TableHead className="dg-text-secondary">状态</TableHead>
|
||||
<TableHead className="dg-text-secondary">目标</TableHead>
|
||||
<TableHead className="dg-text-secondary">当前值</TableHead>
|
||||
<TableHead className="dg-text-secondary">阈值</TableHead>
|
||||
<TableHead className="dg-text-secondary">开始时间</TableHead>
|
||||
<TableHead className="dg-text-secondary text-right">操作</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('monitor.alertEvents.col.severity')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('monitor.alertEvents.col.alertName')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('monitor.alertEvents.col.status')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('monitor.alertEvents.col.target')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('monitor.alertEvents.col.currentValue')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('monitor.alertEvents.col.threshold')}</TableHead>
|
||||
<TableHead className="dg-text-secondary">{t('monitor.alertEvents.col.startTime')}</TableHead>
|
||||
<TableHead className="dg-text-secondary text-right">{t('monitor.alertEvents.col.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -151,7 +153,7 @@ export function AlertEventsTab({
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell colSpan={8} className="text-center dg-text-muted py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin inline mr-2" />
|
||||
加载中...
|
||||
{t('monitor.alertEvents.loading')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredEvents.map((event) => (
|
||||
@@ -187,7 +189,7 @@ export function AlertEventsTab({
|
||||
onClick={() => handleAcknowledge(event.id)}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
确认
|
||||
{t('monitor.alertEvents.acknowledge')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -18,6 +19,7 @@ export function AlertDetailDialog({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAcknowledge: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
@@ -26,44 +28,44 @@ export function AlertDetailDialog({
|
||||
{alert?.title}
|
||||
{alert && getSeverityBadge(alert.severity)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">查看告警详情</DialogDescription>
|
||||
<DialogDescription className="sr-only">{t('monitor.alertDetail.viewDetail')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{alert && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-muted-foreground">描述</Label>
|
||||
<Label className="text-muted-foreground">{t('monitor.alertDetail.description')}</Label>
|
||||
<p className="mt-1">{alert.description}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-muted-foreground">来源</Label>
|
||||
<Label className="text-muted-foreground">{t('monitor.alertDetail.source')}</Label>
|
||||
<p className="font-medium">{alert.source}</p>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
{alert.resolvedAt && (
|
||||
<div>
|
||||
<Label className="text-muted-foreground">解决时间</Label>
|
||||
<Label className="text-muted-foreground">{t('monitor.alertDetail.resolvedTime')}</Label>
|
||||
<p className="font-medium">{alert.resolvedAt}</p>
|
||||
</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">
|
||||
{Object.entries(alert.labels).map(([k, v]) => (
|
||||
<Badge key={k} variant="outline">{k}={v}</Badge>
|
||||
@@ -72,20 +74,20 @@ export function AlertDetailDialog({
|
||||
</div>
|
||||
{alert.runbookUrl && (
|
||||
<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">
|
||||
查看处理手册 →
|
||||
{t('monitor.alertDetail.viewRunbook')} →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>关闭</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.close')}</Button>
|
||||
{alert?.status === 'firing' && (
|
||||
<Button onClick={() => { onAcknowledge(alert.id); onOpenChange(false); }}>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
确认告警
|
||||
{t('monitor.acknowledge')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
@@ -105,12 +107,13 @@ export function AlertSilenceDialog({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSilence: (id: string, duration: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>静默告警</DialogTitle>
|
||||
<DialogDescription>选择静默持续时间</DialogDescription>
|
||||
<DialogTitle>{t('monitor.silenceAlert')}</DialogTitle>
|
||||
<DialogDescription>{t('monitor.alertDetail.selectSilenceDuration')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
{['1h', '4h', '8h', '24h', '7d'].map((duration) => (
|
||||
@@ -120,7 +123,7 @@ export function AlertSilenceDialog({
|
||||
className="w-full justify-between"
|
||||
onClick={() => alert && onSilence(alert.id, duration)}
|
||||
>
|
||||
<span>静默 {duration}</span>
|
||||
<span>{t('monitor.alertDetail.silenceDuration', { duration })}</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 数据源管理页面 — Prometheus + Zabbix 双 Tab // Logan.Li
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Database } from 'lucide-react';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import PrometheusDatasourceManager from '@/components/monitor/datasource/PrometheusDatasourceManager';
|
||||
@@ -7,6 +8,7 @@ import ZabbixDatasourceManager from '@/components/monitor/datasource/ZabbixDatas
|
||||
|
||||
export default function DatasourcePage() {
|
||||
const [activeTab, setActiveTab] = useState('prometheus');
|
||||
const { t } = useTranslation();
|
||||
|
||||
const pageTabs = [
|
||||
{ id: 'prometheus', label: 'Prometheus' },
|
||||
@@ -16,8 +18,8 @@ export default function DatasourcePage() {
|
||||
return (
|
||||
<PageShell
|
||||
variant="list"
|
||||
title="数据源管理"
|
||||
subtitle="管理 Prometheus 与 Zabbix 监控数据源"
|
||||
title={t('monitor.datasource.manageTitle')}
|
||||
subtitle={t('monitor.datasource.manageSubtitle')}
|
||||
icon={<Database className="h-5 w-5" />}
|
||||
tabs={pageTabs}
|
||||
activeTab={activeTab}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// LogAlertBell.tsx — 小铃铛告警关联组件 // Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Bell } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
@@ -14,13 +15,14 @@ const SIZES = {
|
||||
};
|
||||
|
||||
export function LogAlertBell({ hasAlertRule, onClick, size = 'md' }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const s = SIZES[size];
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
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)' }}
|
||||
title={hasAlertRule ? '已关联告警策略' : '关联告警策略'}
|
||||
title={hasAlertRule ? t('monitor.logAlertBell.linked') : t('monitor.logAlertBell.link')}
|
||||
>
|
||||
<Bell style={{ width: s.icon, height: s.icon }} />
|
||||
{hasAlertRule && (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// LogCenter — 日志中心 v4(侧边栏子菜单驱动:Loki查询 / ES查询 / 日志库 / 日志告警)// Logan.Li
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
import { ScrollText } from 'lucide-react';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
@@ -10,17 +11,18 @@ import { LogAlertsPanel } from './LogAlertsPanel';
|
||||
import LogRBAC from './LogRBAC';
|
||||
import type { LogEngine } from './types-v3';
|
||||
|
||||
const SECTION_META: Record<string, { title: string; description: string; engine?: LogEngine }> = {
|
||||
loki: { title: 'Loki 查询', description: '对接自建 Loki · LogQL 实时检索', engine: 'loki' },
|
||||
es: { title: 'ES 查询', description: '对接自建 Elasticsearch · 索引日志检索', engine: 'elasticsearch' },
|
||||
datasources: { title: '日志库', description: '管理日志数据源连接(Loki / Elasticsearch)' },
|
||||
alerts: { title: '日志告警', description: '基于查询条件的日志告警策略' },
|
||||
rbac: { title: '日志权限', description: '按 ES 索引 / Loki Label 正则限制查询范围' },
|
||||
const SECTION_KEYS: Record<string, { titleKey: string; descriptionKey: string; engine?: LogEngine }> = {
|
||||
loki: { titleKey: 'monitor.logCenter.loki', descriptionKey: 'monitor.logCenter.lokiDesc', engine: 'loki' },
|
||||
es: { titleKey: 'monitor.logCenter.es', descriptionKey: 'monitor.logCenter.esDesc', engine: 'elasticsearch' },
|
||||
datasources: { titleKey: 'monitor.logCenter.datasources', descriptionKey: 'monitor.logCenter.datasourcesDesc' },
|
||||
alerts: { titleKey: 'monitor.logCenter.alerts', descriptionKey: 'monitor.logCenter.alertsDesc' },
|
||||
rbac: { titleKey: 'monitor.logCenter.rbac', descriptionKey: 'monitor.logCenter.rbacDesc' },
|
||||
};
|
||||
|
||||
const VALID_TABS = ['loki', 'es', 'datasources', 'alerts', 'rbac'];
|
||||
|
||||
export default function LogCenter() {
|
||||
const { t } = useTranslation();
|
||||
const { section } = useParams<{ section: string }>();
|
||||
const tab = section && VALID_TABS.includes(section) ? section : null;
|
||||
const [alertReloadKey, setAlertReloadKey] = useState(0);
|
||||
@@ -29,14 +31,14 @@ export default function LogCenter() {
|
||||
|
||||
if (tab === 'rbac') return <LogRBAC />;
|
||||
|
||||
const meta = SECTION_META[tab];
|
||||
const meta = SECTION_KEYS[tab];
|
||||
|
||||
return (
|
||||
<LogQueryProvider>
|
||||
<PageShell
|
||||
variant="workspace"
|
||||
title={meta.title}
|
||||
subtitle={meta.description}
|
||||
title={t(meta.titleKey)}
|
||||
subtitle={t(meta.descriptionKey)}
|
||||
icon={<ScrollText className="h-5 w-5" />}
|
||||
contentClassName="flex-1 min-h-0 pb-20"
|
||||
>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
// LogCenterArchBanner — 架构说明:平台不落库海量日志 // Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
export function LogCenterArchBanner() {
|
||||
const { t } = useTranslation();
|
||||
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">
|
||||
<Info className="h-4 w-4 shrink-0 dg-text-cyan mt-0.5" />
|
||||
<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>
|
||||
AIOps 不存储 Loki / Elasticsearch 中的海量日志,仅保存<strong className="text-[var(--dg-text-primary)] font-normal">数据源连接</strong>与
|
||||
<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">{t('monitor.logCenterArch.alertPolicy')}</strong>{t('monitor.logCenterArch.bodySuffix')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// StatChartPanel.tsx — 统计图表面板(SLS风格)// Logan.Li
|
||||
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 { Save, Play, AlertCircle } from 'lucide-react';
|
||||
import type { StatTable, ChartType, LogEngine } from './types-v3';
|
||||
@@ -13,17 +14,10 @@ interface Props {
|
||||
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'];
|
||||
|
||||
export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSaveAsQuery }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [chartType, setChartType] = useState<ChartType>('table');
|
||||
const [xAxisField, setXAxisField] = useState('');
|
||||
const [yAxisField, setYAxisField] = useState('');
|
||||
@@ -31,6 +25,14 @@ export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSave
|
||||
|
||||
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
|
||||
const columns = statTable?.columns || [];
|
||||
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 gap-2">
|
||||
<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>
|
||||
<button
|
||||
onClick={onSaveAsQuery}
|
||||
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)' }}
|
||||
>
|
||||
<Save className="h-3 w-3" /> 另存为查询
|
||||
<Save className="h-3 w-3" /> {t('monitor.statChart.saveAsQuery')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 查询样例 */}
|
||||
<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">
|
||||
{querySamples.map((q, i) => (
|
||||
<button
|
||||
@@ -111,7 +113,7 @@ export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSave
|
||||
{sampleQuery && (
|
||||
<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 }}>
|
||||
<Play className="h-3.5 w-3.5" /> 执行分析
|
||||
<Play className="h-3.5 w-3.5" /> {t('monitor.statChart.runAnalysis')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -125,21 +127,21 @@ export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSave
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* 图表类型切换 */}
|
||||
<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
|
||||
key={t}
|
||||
onClick={() => setChartType(t)}
|
||||
key={t2}
|
||||
onClick={() => setChartType(t2)}
|
||||
className="px-2.5 py-1 rounded text-[11px] transition-colors"
|
||||
style={{
|
||||
background: chartType === t ? meta.color : 'var(--dg-bg-tertiary)',
|
||||
color: chartType === t ? '#fff' : 'var(--dg-text-muted)',
|
||||
background: chartType === t2 ? meta.color : 'var(--dg-bg-tertiary)',
|
||||
color: chartType === t2 ? '#fff' : 'var(--dg-text-muted)',
|
||||
}}
|
||||
>
|
||||
{CHART_TYPE_LABELS[t]}
|
||||
{chartTypeLabels[t2]}
|
||||
</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)' }}>
|
||||
<Save className="h-3 w-3" /> 另存为告警
|
||||
<Save className="h-3 w-3" /> {t('monitor.statChart.saveAsAlert')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -168,7 +170,7 @@ export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSave
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -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="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 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">
|
||||
{(Object.keys(CHART_TYPE_LABELS) as ChartType[]).map(t => (
|
||||
{(Object.keys(chartTypeLabels) as ChartType[]).map(t2 => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setChartType(t)}
|
||||
key={t2}
|
||||
onClick={() => setChartType(t2)}
|
||||
className="py-1 rounded text-[9px] text-center transition-colors"
|
||||
style={{
|
||||
background: chartType === t ? 'var(--dg-bg-tertiary)' : 'transparent',
|
||||
color: chartType === t ? 'var(--dg-text-primary)' : 'var(--dg-text-muted)',
|
||||
border: chartType === t ? '1px solid var(--dg-border)' : '1px solid transparent',
|
||||
background: chartType === t2 ? 'var(--dg-bg-tertiary)' : 'transparent',
|
||||
color: chartType === t2 ? 'var(--dg-text-primary)' : 'var(--dg-text-muted)',
|
||||
border: chartType === t2 ? '1px solid var(--dg-border)' : '1px solid transparent',
|
||||
}}
|
||||
>
|
||||
{CHART_TYPE_LABELS[t].slice(0, 2)}
|
||||
{chartTypeLabels[t2].slice(0, 2)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -249,7 +251,7 @@ export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSave
|
||||
|
||||
{/* 字段配置 */}
|
||||
<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
|
||||
value={defaultX}
|
||||
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>)}
|
||||
</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
|
||||
value={defaultY}
|
||||
onChange={e => setYAxisField(e.target.value)}
|
||||
@@ -272,9 +274,9 @@ export function StatChartPanel({ statTable, engine, query, onSaveAsAlert, onSave
|
||||
|
||||
{/* 当前查询 */}
|
||||
<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)' }}>
|
||||
{query || '暂无查询'}
|
||||
{query || t('monitor.statChart.noQuery')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -63,6 +64,7 @@ export function LogRoleCreateDialog({
|
||||
onDescriptionChange,
|
||||
onSubmit,
|
||||
}: LogRoleCreateDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const preview = useMemo(
|
||||
() => matchSampleScopes(pattern, matchField, engine, SAMPLE_SCOPES),
|
||||
[pattern, matchField, engine],
|
||||
@@ -70,16 +72,20 @@ export function LogRoleCreateDialog({
|
||||
|
||||
const patternHint = useMemo(() => {
|
||||
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 {
|
||||
// eslint-disable-next-line no-new
|
||||
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 {
|
||||
return '正则语法错误';
|
||||
return t('rbac.logRole.patternHint.syntaxError');
|
||||
}
|
||||
}, [pattern, matchField, preview.length]);
|
||||
}, [pattern, matchField, preview.length, t]);
|
||||
|
||||
const filteredDS = datasources.filter((ds) => {
|
||||
if (engine === '*') return true;
|
||||
@@ -90,40 +96,40 @@ export function LogRoleCreateDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建日志权限</DialogTitle>
|
||||
<DialogTitle>{t('rbac.logRole.newTitle')}</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
按正则匹配 ES 索引或 Loki Label,限制用户可查询的日志范围
|
||||
{t('rbac.logRole.desc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">名称</Label>
|
||||
<Input value={name} onChange={(e) => onNameChange(e.target.value)} placeholder="后端日志" className="h-8 text-sm dg-input" />
|
||||
<Label className="text-xs">{t('rbac.logRole.label.name')}</Label>
|
||||
<Input value={name} onChange={(e) => onNameChange(e.target.value)} placeholder={t('rbac.logRole.placeholder.name')} className="h-8 text-sm dg-input" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">标识</Label>
|
||||
<Input value={code} onChange={(e) => onCodeChange(e.target.value)} placeholder="backend-logs" className="h-8 text-sm font-mono dg-input" />
|
||||
<Label className="text-xs">{t('rbac.logRole.label.code')}</Label>
|
||||
<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 className="grid grid-cols-2 gap-2">
|
||||
<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}>
|
||||
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="*">全部引擎</SelectItem>
|
||||
<SelectItem value="*">{t('rbac.logRole.engine.all')}</SelectItem>
|
||||
<SelectItem value="loki">Loki</SelectItem>
|
||||
<SelectItem value="elasticsearch">Elasticsearch</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<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}>
|
||||
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="index">ES 索引</SelectItem>
|
||||
<SelectItem value="index">{t('rbac.logRole.matchField.index')}</SelectItem>
|
||||
<SelectItem value="app">Loki app</SelectItem>
|
||||
<SelectItem value="namespace">Loki namespace</SelectItem>
|
||||
<SelectItem value="job">Loki job</SelectItem>
|
||||
@@ -132,7 +138,7 @@ export function LogRoleCreateDialog({
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">匹配正则</Label>
|
||||
<Label className="text-xs">{t('rbac.logRole.label.pattern')}</Label>
|
||||
<Input
|
||||
value={pattern}
|
||||
onChange={(e) => onPatternChange(e.target.value)}
|
||||
@@ -143,11 +149,11 @@ export function LogRoleCreateDialog({
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<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)}>
|
||||
<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>
|
||||
<SelectItem value="__all">全部数据源</SelectItem>
|
||||
<SelectItem value="__all">{t('rbac.logRole.datasource.all')}</SelectItem>
|
||||
{filteredDS.map((ds) => (
|
||||
<SelectItem key={ds.id} value={String(ds.id)}>{ds.name} ({ds.type})</SelectItem>
|
||||
))}
|
||||
@@ -155,19 +161,19 @@ export function LogRoleCreateDialog({
|
||||
</Select>
|
||||
</div>
|
||||
<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}>
|
||||
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="viewer">查看者 — 只读查询</SelectItem>
|
||||
<SelectItem value="analyst">分析者 — 查询 + 告警</SelectItem>
|
||||
<SelectItem value="viewer">{t('rbac.logRole.role.viewer')}</SelectItem>
|
||||
<SelectItem value="analyst">{t('rbac.logRole.role.analyst')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">说明</Label>
|
||||
<Input value={description} onChange={(e) => onDescriptionChange(e.target.value)} placeholder="可选" className="h-8 text-sm dg-input" />
|
||||
<Label className="text-xs">{t('rbac.logRole.label.description')}</Label>
|
||||
<Input value={description} onChange={(e) => onDescriptionChange(e.target.value)} placeholder={t('rbac.logRole.placeholder.description')} className="h-8 text-sm dg-input" />
|
||||
</div>
|
||||
{preview.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -180,10 +186,10 @@ export function LogRoleCreateDialog({
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('rbac.logRole.cancel')}</Button>
|
||||
<Button onClick={onSubmit} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Save className="h-4 w-4 mr-1" />}
|
||||
创建
|
||||
{t('rbac.logRole.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 定时任务可视化统计面板 — 含健康度与整体成功率条 // Logan.Li
|
||||
import type { ReactNode } from 'react';
|
||||
import { Clock, CheckCircle, XCircle, Activity } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { computeSuccessRate } from './cronJobHelpers';
|
||||
import type { CronJobRecord } from '@/types';
|
||||
|
||||
@@ -65,6 +66,7 @@ function StatCard({ label, value, sub, icon, borderClass, bgClass, valueClass, r
|
||||
}
|
||||
|
||||
export function CronJobStatsPanel({ jobs }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const total = jobs.length;
|
||||
const enabled = jobs.filter((j) => j.enabled).length;
|
||||
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="grid grid-cols-2 lg:grid-cols-5 gap-2">
|
||||
<StatCard
|
||||
label="用户任务"
|
||||
label={t('task.cronJobStats.userJobs')}
|
||||
value={total}
|
||||
sub={`${enabled} 个启用中`}
|
||||
sub={t('task.cronJobStats.enabledCount', { count: enabled })}
|
||||
icon={<Clock className="h-6 w-6 dg-text-blue" />}
|
||||
borderClass="border-blue-500/20"
|
||||
bgClass="bg-blue-500/5"
|
||||
valueClass="dg-text-blue"
|
||||
/>
|
||||
<StatCard
|
||||
label="已启用"
|
||||
label={t('task.cronJobStats.enabled')}
|
||||
value={enabled}
|
||||
sub={`占比 ${enabledRate}%`}
|
||||
sub={`${t('task.cronJobStats.ratio')} ${enabledRate}%`}
|
||||
borderClass="border-emerald-500/20"
|
||||
bgClass="bg-emerald-500/5"
|
||||
valueClass="dg-text-green"
|
||||
@@ -103,9 +105,9 @@ export function CronJobStatsPanel({ jobs }: Props) {
|
||||
icon={<Activity className="h-6 w-6 dg-text-green" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="任务健康"
|
||||
label={t('task.cronJobStats.health')}
|
||||
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'}
|
||||
bgClass={recentFailed > 0 ? 'bg-amber-500/5' : 'bg-emerald-500/5'}
|
||||
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" />}
|
||||
/>
|
||||
<StatCard
|
||||
label="累计成功"
|
||||
label={t('task.cronJobStats.totalSuccess')}
|
||||
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" />}
|
||||
borderClass="border-emerald-500/20"
|
||||
bgClass="bg-emerald-500/5"
|
||||
valueClass="text-emerald-500"
|
||||
/>
|
||||
<StatCard
|
||||
label="累计失败"
|
||||
label={t('task.cronJobStats.totalFailed')}
|
||||
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" />}
|
||||
borderClass="border-red-500/20"
|
||||
bgClass="bg-red-500/5"
|
||||
@@ -133,7 +135,7 @@ export function CronJobStatsPanel({ jobs }: Props) {
|
||||
</div>
|
||||
{totalRuns > 0 && (
|
||||
<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="h-full rounded-full transition-all duration-500"
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getStatusBadge, getEnabledBadge, getJobTypeBadge,
|
||||
parseCron, formatRelativeTime, SuccessRateBar,
|
||||
} from './cronJobHelpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CronJobRecord } from '@/types';
|
||||
|
||||
interface Props {
|
||||
@@ -34,6 +35,7 @@ export function CronJobTable({
|
||||
onToggleSelect, onToggleSelectAll, allSelected,
|
||||
onView, onHistory, onExecute, onToggle, onEdit, onDelete,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--dg-border)] overflow-hidden">
|
||||
<Table>
|
||||
@@ -100,15 +102,15 @@ export function CronJobTable({
|
||||
</TableCell>
|
||||
<TableCell className="py-2 hidden lg:table-cell">
|
||||
<span className="text-xs dg-text-secondary" title={cronExpr}>
|
||||
{parseCron(cronExpr)}
|
||||
{parseCron(cronExpr, t)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-2">{getJobTypeBadge(job.type)}</TableCell>
|
||||
<TableCell className="py-2 hidden sm:table-cell">{getEnabledBadge(job.enabled)}</TableCell>
|
||||
<TableCell className="py-2">{getStatusBadge(job.last_status)}</TableCell>
|
||||
<TableCell className="py-2">{getJobTypeBadge(job.type, t)}</TableCell>
|
||||
<TableCell className="py-2 hidden sm:table-cell">{getEnabledBadge(job.enabled, t)}</TableCell>
|
||||
<TableCell className="py-2">{getStatusBadge(job.last_status, t)}</TableCell>
|
||||
<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}>
|
||||
{formatRelativeTime(job.last_run_at)}
|
||||
{formatRelativeTime(job.last_run_at, t)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-2 hidden md:table-cell">
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getJobTypeBadge, getEnabledBadge, getStatusBadge,
|
||||
parseCron, formatRelativeTime, SuccessRateBar, computeSuccessRate,
|
||||
} from './cronJobHelpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CronJobRecord } from '@/types';
|
||||
|
||||
interface Props {
|
||||
@@ -24,6 +25,7 @@ function asText(v: unknown): string {
|
||||
}
|
||||
|
||||
export function CronJobViewDialog({ open, onOpenChange, job }: Props) {
|
||||
const { t } = useTranslation();
|
||||
if (!job) return null;
|
||||
|
||||
const isScriptType = job.type === 'shell' || job.type === 'python' || job.type === 'powershell';
|
||||
@@ -40,15 +42,15 @@ export function CronJobViewDialog({ open, onOpenChange, job }: Props) {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="truncate pr-6">{job.name}</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">
|
||||
只读预览 · {parseCron(cronExpr)}
|
||||
只读预览 · {parseCron(cronExpr, t)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{getJobTypeBadge(job.type)}
|
||||
{getEnabledBadge(job.enabled)}
|
||||
{getStatusBadge(job.last_status)}
|
||||
{getJobTypeBadge(job.type, t)}
|
||||
{getEnabledBadge(job.enabled, t)}
|
||||
{getStatusBadge(job.last_status, t)}
|
||||
<span className="text-xs dg-text-muted font-mono">{cronExpr}</span>
|
||||
</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>
|
||||
<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 className="dg-text-muted mb-0.5">累计执行</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@/components/ui/table';
|
||||
import { History, Loader2, CheckCircle, XCircle, BarChart3, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { taskExecutionApi } from '@/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getStatusBadge, formatDuration } from './cronJobHelpers';
|
||||
import { TerminalOutput } from './TerminalOutput';
|
||||
import type { JsonData } from '@/types/common';
|
||||
@@ -71,6 +72,7 @@ export function ExecutionHistoryDialog({
|
||||
taskId = null,
|
||||
taskName,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [executions, setExecutions] = useState<JsonData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [viewingExecId, setViewingExecId] = useState<number | string | null>(null);
|
||||
@@ -225,7 +227,7 @@ export function ExecutionHistoryDialog({
|
||||
{!taskId && (
|
||||
<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">
|
||||
{formatDuration(exec.duration)}
|
||||
</TableCell>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Script 新建/编辑弹窗 // Logan.Li
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
@@ -36,33 +37,34 @@ interface Props {
|
||||
const ScriptFormDialog: React.FC<Props> = ({
|
||||
open, onOpenChange, editingId, editingCode, formData, setFormData, onTypeChange, onSave, runtime,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const types = availableScriptTypes(runtime ?? null);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId !== null ? '编辑脚本' : '新建脚本'}</DialogTitle>
|
||||
<DialogDescription>支持 Shell / Python / PowerShell,编辑器带语法高亮</DialogDescription>
|
||||
<DialogTitle>{editingId !== null ? t('task.script.editTitle') : t('task.script.newTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('task.script.desc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 overflow-y-auto flex-1 min-h-0 pr-1">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>名称</Label>
|
||||
<Label>{t('task.script.label.name')}</Label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="如:系统巡检脚本"
|
||||
placeholder={t('task.script.placeholder.name')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>类型</Label>
|
||||
<Label>{t('task.script.label.type')}</Label>
|
||||
<Select value={formData.type} onValueChange={onTypeChange}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{types.map(t => (
|
||||
<SelectItem key={t.value} value={t.value} disabled={t.disabled}>
|
||||
{t.label}{t.disabled ? '(环境不可用)' : ''}
|
||||
{types.map((tp) => (
|
||||
<SelectItem key={tp.value} value={tp.value} disabled={tp.disabled}>
|
||||
{tp.label}{tp.disabled ? t('task.script.envUnavailable') : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -70,16 +72,16 @@ const ScriptFormDialog: React.FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>描述</Label>
|
||||
<Label>{t('task.script.label.description')}</Label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="脚本用途说明"
|
||||
placeholder={t('task.script.placeholder.description')}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>超时(秒)</Label>
|
||||
<Label>{t('task.script.label.timeout')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.timeout}
|
||||
@@ -89,13 +91,13 @@ const ScriptFormDialog: React.FC<Props> = ({
|
||||
</div>
|
||||
{editingId !== null && (
|
||||
<div>
|
||||
<Label>编码</Label>
|
||||
<Label>{t('task.script.label.code')}</Label>
|
||||
<Input value={editingCode} disabled className="font-mono text-xs" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block">脚本内容</Label>
|
||||
<Label className="mb-2 block">{t('task.script.label.content')}</Label>
|
||||
<ScriptCodeEditor
|
||||
value={formData.content}
|
||||
onChange={(v) => setFormData({ ...formData, content: v })}
|
||||
@@ -105,10 +107,10 @@ const ScriptFormDialog: React.FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={onSave}>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
{editingId !== null ? '保存' : '创建'}
|
||||
{editingId !== null ? t('task.script.save') : t('task.script.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 脚本运行时环境预检横幅 — 展示 Shell/Python/PowerShell 可用性 // Logan.Li
|
||||
import { Terminal, AlertTriangle } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ScriptRuntimeInfo } from '@/api/task/script';
|
||||
|
||||
interface Props {
|
||||
@@ -9,10 +10,11 @@ interface Props {
|
||||
}
|
||||
|
||||
export function ScriptRuntimeBanner({ runtime, loading }: Props) {
|
||||
const { t } = useTranslation();
|
||||
if (loading || !runtime) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +34,7 @@ export function ScriptRuntimeBanner({ runtime, loading }: Props) {
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-primary)]">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -43,7 +45,7 @@ export function ScriptRuntimeBanner({ runtime, loading }: Props) {
|
||||
className={item.ok
|
||||
? 'border-emerald-500/40 dg-text-green bg-emerald-500/10'
|
||||
: '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 ? ' ✓' : ' ✗'}
|
||||
</Badge>
|
||||
@@ -52,14 +54,14 @@ export function ScriptRuntimeBanner({ runtime, loading }: Props) {
|
||||
{!coreOk && (
|
||||
<p className="text-xs dg-text-red flex items-center gap-1 w-full">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
未检测到 Shell 解释器,脚本无法在本机执行。Docker 部署请确认镜像已安装 bash。
|
||||
{t('task.scriptRuntime.noShell')}
|
||||
</p>
|
||||
)}
|
||||
{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 && (
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { systemTaskApi, type SystemTaskItem } from '@/api';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { formatRelativeTime } from './cronJobHelpers';
|
||||
import {
|
||||
ServerCog, Play, Loader2, Shield, Trash2, Clock, CheckCircle, XCircle, AlertCircle,
|
||||
@@ -52,6 +53,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function SystemTasksPanel({ refreshing }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const _confirm = useConfirm();
|
||||
const { hasAnyPermission, isAdmin } = usePermission();
|
||||
const canRun = isAdmin || hasAnyPermission(['auto:script', 'auto:cron', 'auto:ansible']);
|
||||
@@ -177,7 +179,7 @@ export function SystemTasksPanel({ refreshing }: Props) {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{task.last_duration_ms != null && task.last_duration_ms > 0 && (
|
||||
<span>{task.last_duration_ms}ms</span>
|
||||
|
||||
@@ -1,41 +1,42 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
// 获取执行状态徽章(主列表+执行历史共用) // Logan.Li
|
||||
export function getStatusBadge(status?: string) {
|
||||
export function getStatusBadge(status: string | undefined, t: TFunction) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
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">
|
||||
<CheckCircle className="h-2.5 w-2.5" />
|
||||
成功
|
||||
{t('task.cronJob.status.success')}
|
||||
</Badge>
|
||||
);
|
||||
case 'failed':
|
||||
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">
|
||||
<XCircle className="h-2.5 w-2.5" />
|
||||
失败
|
||||
{t('task.cronJob.status.failed')}
|
||||
</Badge>
|
||||
);
|
||||
case 'running':
|
||||
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">
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin" />
|
||||
执行中
|
||||
{t('task.cronJob.status.running')}
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 启用状态徽章 // Logan.Li
|
||||
export function getEnabledBadge(enabled: boolean) {
|
||||
export function getEnabledBadge(enabled: boolean, t: TFunction) {
|
||||
return (
|
||||
<Badge
|
||||
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'
|
||||
}
|
||||
>
|
||||
{enabled ? '启用' : '禁用'}
|
||||
{enabled ? t('task.cronJob.enabled') : t('task.cronJob.disabled')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// 定时任务类型徽章 // Logan.Li
|
||||
export function getJobTypeBadge(type?: string) {
|
||||
const label = getJobTypeLabel(type);
|
||||
export function getJobTypeBadge(type: string | undefined, t: TFunction) {
|
||||
const label = getJobTypeLabel(type, t);
|
||||
const colorMap: Record<string, string> = {
|
||||
shell: 'border-blue-500/30 dg-text-blue bg-blue-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
|
||||
export function getJobTypeLabel(type?: string) {
|
||||
export function getJobTypeLabel(type: string | undefined, t: TFunction) {
|
||||
switch (type) {
|
||||
case 'shell': return 'Shell';
|
||||
case 'python': return 'Python';
|
||||
case 'powershell': return 'PowerShell';
|
||||
case 'command': return '命令';
|
||||
default: return type || '未知';
|
||||
case 'command': return t('task.cronJob.jobType.command');
|
||||
default: return type || t('task.cronJob.jobType.unknown');
|
||||
}
|
||||
}
|
||||
|
||||
// Cron 表达式转中文可读描述 // Logan.Li
|
||||
export function parseCron(schedule?: string) {
|
||||
export function parseCron(schedule: string | undefined, t: TFunction) {
|
||||
if (!schedule) return '—';
|
||||
const patterns: Record<string, string> = {
|
||||
'0 2 * * *': '每天 02:00',
|
||||
'0 3 * * 0': '每周日 03:00',
|
||||
'*/5 * * * *': '每 5 分钟',
|
||||
'0 * * * *': '每小时',
|
||||
'0 9 * * 1': '每周一 09:00',
|
||||
const patternKey: Record<string, string> = {
|
||||
'0 2 * * *': 'task.cronJob.cron.daily2',
|
||||
'0 3 * * 0': 'task.cronJob.cron.weeklySun3',
|
||||
'*/5 * * * *': 'task.cronJob.cron.every5min',
|
||||
'0 * * * *': 'task.cronJob.cron.everyHour',
|
||||
'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
|
||||
@@ -101,18 +103,18 @@ export function computeSuccessRate(success = 0, fail = 0): number | null {
|
||||
}
|
||||
|
||||
// 相对时间展示(列表上次执行列) // Logan.Li
|
||||
export function formatRelativeTime(dateStr?: string): string {
|
||||
export function formatRelativeTime(dateStr: string | undefined, t: TFunction): string {
|
||||
if (!dateStr) return '—';
|
||||
const d = new Date(dateStr);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
const diff = Date.now() - d.getTime();
|
||||
const mins = Math.floor(diff / 60_000);
|
||||
if (mins < 1) return '刚刚';
|
||||
if (mins < 60) return `${mins} 分钟前`;
|
||||
if (mins < 1) return t('task.cronJob.time.justNow');
|
||||
if (mins < 60) return t('task.cronJob.time.minutesAgo', { count: mins });
|
||||
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);
|
||||
if (days < 7) return `${days} 天前`;
|
||||
if (days < 7) return t('task.cronJob.time.daysAgo', { count: days });
|
||||
return d.toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
month: '2-digit',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// code-snippet-block.tsx — 代码片段展示 + 外侧复制按钮 // Logan.Li
|
||||
import { CopyButton } from '@/components/ui/copy-button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface CodeSnippetBlockProps {
|
||||
@@ -13,8 +14,10 @@ export interface CodeSnippetBlockProps {
|
||||
}
|
||||
|
||||
export function CodeSnippetBlock({
|
||||
label, code, copyLabel = '已复制', className, copyDisabled, copyDisabledTitle,
|
||||
label, code, copyLabel, className, copyDisabled, copyDisabledTitle,
|
||||
}: CodeSnippetBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedCopyLabel = copyLabel ?? t('common.copied');
|
||||
return (
|
||||
<div className={cn('space-y-1.5', className)}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -25,14 +28,14 @@ export function CodeSnippetBlock({
|
||||
)}
|
||||
<CopyButton
|
||||
text={code}
|
||||
label={copyLabel}
|
||||
label={resolvedCopyLabel}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] h-7 px-2"
|
||||
disabled={copyDisabled}
|
||||
title={copyDisabled ? (copyDisabledTitle || '不可复制') : '复制'}
|
||||
title={copyDisabled ? (copyDisabledTitle || t('ui.codeSnippet.copyDisabled')) : t('common.copy')}
|
||||
>
|
||||
复制
|
||||
{t('common.copy')}
|
||||
</CopyButton>
|
||||
</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">
|
||||
|
||||
+2965
-2
File diff suppressed because it is too large.
Load diff
+2965
-2
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,5 @@
|
||||
// 监控图表工具栏 — 固定卡片/弹框右上角 // Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Maximize2, ZoomOut } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -14,6 +15,7 @@ interface Props {
|
||||
export function ChartToolbar({
|
||||
zoomed, onExpand, onResetZoom, size = 'sm', className,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
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';
|
||||
|
||||
@@ -25,7 +27,7 @@ export function ChartToolbar({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(btn, 'dg-text-secondary hover:dg-text-primary hover:bg-[var(--dg-bg-hover)]')}
|
||||
title="重置缩放"
|
||||
title={t('monitor.chartToolbar.resetZoom')}
|
||||
onClick={onResetZoom}
|
||||
>
|
||||
<ZoomOut className={icon} />
|
||||
@@ -36,7 +38,7 @@ export function ChartToolbar({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(btn, 'dg-text-secondary hover:dg-text-blue hover:bg-[var(--dg-bg-hover)]')}
|
||||
title="放大查看"
|
||||
title={t('monitor.chartToolbar.expand')}
|
||||
onClick={onExpand}
|
||||
>
|
||||
<Maximize2 className={icon} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 主机核心指标条 — CPU/内存/磁盘/网络/磁盘IO 一览 // Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Cpu, HardDrive, Network, Disc, Server } from 'lucide-react';
|
||||
import type { JsonData } from '@/types/common';
|
||||
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) {
|
||||
const { t } = useTranslation();
|
||||
const cpu = Number(latest?.cpu_usage ?? latest?.cpu ?? 0);
|
||||
const mem = Number(latest?.memory_usage ?? latest?.mem_usage ?? latest?.mem ?? 0);
|
||||
const disk = Number(latest?.disk_usage ?? latest?.disk ?? 0);
|
||||
@@ -27,10 +29,10 @@ export function HostCoreMetricsStrip({ latest, netInfo, diskIOInfo, onJump }: Pr
|
||||
|
||||
const items = [
|
||||
{ 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: 'disk', icon: HardDrive, label: '磁盘', value: `${disk.toFixed(1)}%`, cls: tone(disk, 80, 90) },
|
||||
{ tab: 'network', icon: Network, label: '网络', 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: 'basic', icon: Server, label: t('monitor.hostMetric.memory'), value: `${mem.toFixed(1)}%`, cls: tone(mem, 85, 90) },
|
||||
{ tab: 'disk', icon: HardDrive, label: t('monitor.hostMetric.disk'), value: `${disk.toFixed(1)}%`, cls: tone(disk, 80, 90) },
|
||||
{ tab: 'network', icon: Network, label: t('monitor.hostMetric.network'), value: `↓${rx} ↑${tx}`, 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 (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 指标图表卡片 — 工具栏固定标题栏右上角 // Logan.Li
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { ZoomableAreaChart, type ZoomableChartHandle, type ChartReferenceLine } from './ZoomableAreaChart';
|
||||
@@ -27,6 +28,7 @@ function safeChartId(title: string) {
|
||||
export function MetricChartCard({
|
||||
title, value, icon, iconTone = 'blue', data, color, unit, domain, aggType, referenceLines,
|
||||
}: MetricChartCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const chartRef = useRef<ZoomableChartHandle>(null);
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
const chartData = data.map((d) => ({ ...d, value: d[aggType] }));
|
||||
@@ -60,7 +62,7 @@ export function MetricChartCard({
|
||||
unit={unit}
|
||||
chartId={safeChartId(title)}
|
||||
title={title}
|
||||
subtitle={`当前 ${value}${unit ? ` ${unit}` : ''}`}
|
||||
subtitle={`${t('monitor.metricChart.currentPrefix')} ${value}${unit ? ` ${unit}` : ''}`}
|
||||
height={188}
|
||||
toolbarPlacement="header"
|
||||
onZoomedChange={setZoomed}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 系统信息 Tab 内容 // Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Activity, HardDrive } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { TabsContent } from '@/components/ui/tabs';
|
||||
@@ -16,6 +17,7 @@ interface SystemInfoSectionProps {
|
||||
export function SystemInfoSection({
|
||||
selectedHost, serverInfo, latest, heartbeatInfo, portInfoCount,
|
||||
}: SystemInfoSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const disk = latest?.disk_info ? parseJSON(latest.disk_info) : null;
|
||||
const parts = disk ? (Array.isArray(disk) ? disk : (disk.partitions || [])) : [];
|
||||
|
||||
@@ -25,18 +27,18 @@ export function SystemInfoSection({
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-xs">
|
||||
{[
|
||||
['主机名', serverInfo?.hostname || selectedHost],
|
||||
['IP 地址', serverInfo?.ip || '—'],
|
||||
['操作系统', serverInfo?.os || '—'],
|
||||
['内核版本', serverInfo?.kernel || '—'],
|
||||
['CPU 核心数', serverInfo?.cpu_cores || '—'],
|
||||
['总内存', serverInfo?.mem_total ? `${serverInfo.mem_total.toFixed(0)} GB` : '—'],
|
||||
['已用内存', serverInfo?.mem_used ? `${serverInfo.mem_used.toFixed(1)} GB` : '—'],
|
||||
['运行时长', serverInfo?.uptime ? `${Math.floor(serverInfo.uptime / 86400)}天 ${Math.floor((serverInfo.uptime % 86400) / 3600)}时` : '—'],
|
||||
['Agent 版本', heartbeatInfo?.version || serverInfo?.agent_version || '—'],
|
||||
['Agent 序列号', heartbeatInfo?.report_seq != null ? String(heartbeatInfo.report_seq) : '—'],
|
||||
['监听端口数', portInfoCount > 0 ? `${portInfoCount}` : '—'],
|
||||
['Agent RSS', heartbeatInfo?.self_rss_mb ? `${heartbeatInfo.self_rss_mb.toFixed(1)} MB` : '—'],
|
||||
[t('monitor.systemInfo.hostname'), serverInfo?.hostname || selectedHost],
|
||||
[t('monitor.systemInfo.ipAddress'), serverInfo?.ip || '—'],
|
||||
[t('monitor.systemInfo.os'), serverInfo?.os || '—'],
|
||||
[t('monitor.systemInfo.kernel'), serverInfo?.kernel || '—'],
|
||||
[t('monitor.systemInfo.cpuCores'), serverInfo?.cpu_cores || '—'],
|
||||
[t('monitor.systemInfo.memTotal'), serverInfo?.mem_total ? `${serverInfo.mem_total.toFixed(0)} GB` : '—'],
|
||||
[t('monitor.systemInfo.memUsed'), serverInfo?.mem_used ? `${serverInfo.mem_used.toFixed(1)} GB` : '—'],
|
||||
[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')}` : '—'],
|
||||
[t('monitor.systemInfo.agentVersion'), heartbeatInfo?.version || serverInfo?.agent_version || '—'],
|
||||
[t('monitor.systemInfo.agentSeq'), heartbeatInfo?.report_seq != null ? String(heartbeatInfo.report_seq) : '—'],
|
||||
[t('monitor.systemInfo.portCount'), portInfoCount > 0 ? `${portInfoCount}` : '—'],
|
||||
[t('monitor.systemInfo.agentRss'), heartbeatInfo?.self_rss_mb ? `${heartbeatInfo.self_rss_mb.toFixed(1)} MB` : '—'],
|
||||
].map(([label, val]) => (
|
||||
<div key={label as string}>
|
||||
<span className="text-[var(--dg-text-muted)]">{label}:</span>
|
||||
@@ -52,7 +54,7 @@ export function SystemInfoSection({
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<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 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>
|
||||
@@ -66,7 +68,7 @@ export function SystemInfoSection({
|
||||
{parts.length > 0 && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<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">
|
||||
{parts.map((p: JsonData, i: number) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 中间件实例卡片 — 紧凑列表项,详情见右侧可观测面板 // Logan.Li
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Loader2, Pencil, Trash2, Zap } from 'lucide-react';
|
||||
import type { MwTarget } from './helpers';
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function InstanceCard({ target, selected, onSelect, onEdit, onDelete, onTest, testing }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const m = target.latest_metrics || {};
|
||||
const isRedis = target.description === 'redis';
|
||||
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="线程" value={fmtVal(m.mysql_threads_running)} />
|
||||
<MetricPill label={t('monitor.mwInstance.threads')} value={fmtVal(m.mysql_threads_running)} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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}>
|
||||
{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 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 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>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 中间件健康告警条 // Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import type { MwHealthIssue } from './helpers';
|
||||
import { worstHealthLevel, healthLevelColor } from './helpers';
|
||||
@@ -8,6 +9,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function MwHealthStrip({ issues }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const level = worstHealthLevel(issues);
|
||||
const color = healthLevelColor(level);
|
||||
|
||||
@@ -15,7 +17,7 @@ export function MwHealthStrip({ issues }: Props) {
|
||||
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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 监控图表放大壳 — 复用主机监控 ChartToolbar + 宽屏弹框 // Logan.Li
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
@@ -28,6 +29,7 @@ export function ChartExpandShell({
|
||||
children,
|
||||
}: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -40,7 +42,7 @@ export function ChartExpandShell({
|
||||
{!empty && <ChartToolbar onExpand={() => setExpanded(true)} />}
|
||||
</div>
|
||||
{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)
|
||||
)}
|
||||
@@ -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)]">
|
||||
<DialogHeader>
|
||||
<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>
|
||||
<div className="pt-2">{children(expandedHeight)}</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 站点拨测图表容器 — 详情页全宽展示,无放大弹框 // Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
@@ -16,6 +17,7 @@ export function SiteChartCard({
|
||||
empty = false,
|
||||
children,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="rounded-lg bg-[var(--dg-bg-tertiary)] p-3">
|
||||
<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>
|
||||
</div>
|
||||
{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)
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 拨测任务详情页 — 可观测指标与历史记录(非弹框)// Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -13,19 +14,20 @@ interface Props {
|
||||
}
|
||||
|
||||
export function SiteTaskDetailPage({ task, onBack, onProbed }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const probeType = normalizeProbeType(task.type);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 flex-wrap">
|
||||
<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>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<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>
|
||||
<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>
|
||||
<p className="text-xs font-mono text-[var(--dg-text-muted)] truncate mt-0.5">{task.url}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 拨测异常事件时间线 // Logan.Li
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Info, XCircle } from 'lucide-react';
|
||||
import type { ProbeEvent } from './analytics';
|
||||
|
||||
@@ -20,12 +21,14 @@ const toneMap = {
|
||||
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 (
|
||||
<div className="rounded-lg bg-[var(--dg-bg-tertiary)] p-3">
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-2">{title}</p>
|
||||
{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">
|
||||
{events.map((e) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Area, Bar, BarChart, CartesianGrid, Cell, ComposedChart, Line,
|
||||
ReferenceArea, ReferenceLine, Tooltip, XAxis, YAxis,
|
||||
} from 'recharts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Globe, Network, Radio, Shield, Wifi } from 'lucide-react';
|
||||
import type { SiteResult, SiteTask } 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);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<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: '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) => (
|
||||
<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>
|
||||
@@ -54,7 +57,7 @@ function HttpObs({ task, results, rangeLabel = '近 6 小时' }: Props) {
|
||||
</div>
|
||||
<SiteChartCard
|
||||
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}
|
||||
>
|
||||
{(h) => (
|
||||
@@ -64,8 +67,8 @@ function HttpObs({ task, results, rangeLabel = '近 6 小时' }: Props) {
|
||||
<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" />
|
||||
<YAxis tick={MONITOR_CHART_TICK} tickLine={false} axisLine={false} width={36} domain={[0, 'auto']} />
|
||||
<Tooltip content={<MonitorChartTooltip valueFormatter={(v) => `HTTP ${v}`} nameFormatter={() => '状态码'} />} />
|
||||
<ReferenceLine y={s.expectedCode} stroke="var(--dg-green)" strokeDasharray="4 4" label={{ value: `期望${s.expectedCode}`, fontSize: 10, fill: 'var(--dg-text-muted)' }} />
|
||||
<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: `${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}>
|
||||
{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)'} />
|
||||
@@ -77,22 +80,23 @@ function HttpObs({ task, results, rangeLabel = '近 6 小时' }: Props) {
|
||||
</MonitorRangeChartFrame>
|
||||
)}
|
||||
</SiteChartCard>
|
||||
<SiteEventTimeline title="4xx / 5xx / 超时事件" events={s.events} />
|
||||
<SiteEventTimeline title={t('monitor.siteProtocol.http.timelineTitle')} events={s.events} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SslObs({ task, results }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const s = analyzeSslResults(results, task);
|
||||
const sslInfo = fmtSslDays(s.days);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{[
|
||||
{ label: '证书到期', value: s.expiry || '--', tone: 'text-[var(--dg-text-primary)]' },
|
||||
{ label: '剩余天数', 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: '握手延迟', value: fmtMs(s.handshakeMs), tone: 'text-[var(--dg-text-primary)]' },
|
||||
{ label: t('monitor.siteProtocol.ssl.expiry'), value: s.expiry || '--', tone: 'text-[var(--dg-text-primary)]' },
|
||||
{ label: t('monitor.siteProtocol.ssl.remainingDays'), value: sslInfo.text, tone: sslInfo.tone },
|
||||
{ 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: t('monitor.siteProtocol.ssl.handshakeLatency'), value: fmtMs(s.handshakeMs), tone: 'text-[var(--dg-text-primary)]' },
|
||||
].map((item) => (
|
||||
<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>
|
||||
@@ -102,7 +106,7 @@ function SslObs({ task, results }: Props) {
|
||||
</div>
|
||||
<SiteChartCard
|
||||
icon={<Shield className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />}
|
||||
title="证书剩余天数趋势"
|
||||
title={t('monitor.siteProtocol.ssl.daysTrendTitle')}
|
||||
height={220}
|
||||
empty={s.chart.length === 0}
|
||||
>
|
||||
@@ -113,9 +117,9 @@ function SslObs({ task, results }: Props) {
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={MONITOR_CHART_GRID} vertical={false} />
|
||||
<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} />
|
||||
<Tooltip content={<MonitorChartTooltip valueFormatter={(v) => `${v} 天`} nameFormatter={() => '剩余'} />} />
|
||||
<ReferenceLine y={30} stroke="#eab308" strokeDasharray="4 4" label={{ value: '30天', 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)' }} />
|
||||
<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: t('monitor.siteProtocol.ssl.ref30days'), 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} />
|
||||
<SelectionArea left={refLeft} right={refRight} />
|
||||
</ComposedChart>
|
||||
@@ -123,24 +127,26 @@ function SslObs({ task, results }: Props) {
|
||||
</MonitorRangeChartFrame>
|
||||
)}
|
||||
</SiteChartCard>
|
||||
<SiteEventTimeline title="证书 / 握手异常" events={s.events} />
|
||||
<SiteEventTimeline title={t('monitor.siteProtocol.ssl.timelineTitle')} events={s.events} />
|
||||
</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 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;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{[
|
||||
{ label: '平均', value: fmtMs(s.avg) },
|
||||
{ label: '最小', value: fmtMs(s.min) },
|
||||
{ label: '最大', value: fmtMs(s.max) },
|
||||
{ label: t('monitor.siteProtocol.latency.avg'), value: fmtMs(s.avg) },
|
||||
{ label: t('monitor.siteProtocol.latency.min'), value: fmtMs(s.min) },
|
||||
{ label: t('monitor.siteProtocol.latency.max'), value: fmtMs(s.max) },
|
||||
{ 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) => (
|
||||
<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>
|
||||
@@ -150,7 +156,7 @@ function LatencyObs({ results, mode, rangeLabel = '近 6 小时' }: Props & { mo
|
||||
</div>
|
||||
<SiteChartCard
|
||||
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}
|
||||
>
|
||||
{(h) => (
|
||||
@@ -160,8 +166,8 @@ function LatencyObs({ results, mode, rangeLabel = '近 6 小时' }: Props & { mo
|
||||
<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" />
|
||||
<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)} />} />
|
||||
<ReferenceLine y={s.avg} stroke="var(--dg-blue)" strokeDasharray="4 4" label={{ value: '均值', fontSize: 10, fill: 'var(--dg-text-muted)' }} />
|
||||
<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: 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} />
|
||||
<SelectionArea left={refLeft} right={refRight} />
|
||||
</ComposedChart>
|
||||
@@ -169,20 +175,22 @@ function LatencyObs({ results, mode, rangeLabel = '近 6 小时' }: Props & { mo
|
||||
</MonitorRangeChartFrame>
|
||||
)}
|
||||
</SiteChartCard>
|
||||
<SiteEventTimeline title="延迟尖峰 / 失败事件" events={s.events} />
|
||||
<SiteEventTimeline title={t('monitor.siteProtocol.latency.timelineTitle')} events={s.events} />
|
||||
</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);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{[
|
||||
{ label: `连通率(${rangeLabel})`, 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: '最近失败', value: s.lastFailAt || '--', tone: 'text-[var(--dg-text-primary)]' },
|
||||
{ label: `${t('monitor.siteProtocol.tcp.upRate')}(${rl})`, value: s.upRate, tone: 'dg-text-green' },
|
||||
{ label: t('monitor.siteProtocol.tcp.failCount'), value: String(s.failCount), tone: s.failCount > 0 ? 'dg-text-red' : 'text-[var(--dg-text-primary)]' },
|
||||
{ label: t('monitor.siteProtocol.tcp.lastFail'), value: s.lastFailAt || '--', tone: 'text-[var(--dg-text-primary)]' },
|
||||
].map((item) => (
|
||||
<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>
|
||||
@@ -192,7 +200,7 @@ function TcpObs({ results, rangeLabel = '近 6 小时' }: Props) {
|
||||
</div>
|
||||
<SiteChartCard
|
||||
icon={<Network className="h-3.5 w-3.5 text-[var(--dg-text-muted)]" />}
|
||||
title="TCP 连通状态时序"
|
||||
title={t('monitor.siteProtocol.tcp.statusTitle')}
|
||||
height={200}
|
||||
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}>
|
||||
<XAxis dataKey="time" tick={MONITOR_CHART_TICK} tickLine={false} axisLine={{ stroke: MONITOR_CHART_GRID }} interval="preserveStartEnd" />
|
||||
<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} />
|
||||
<SelectionArea left={refLeft} right={refRight} />
|
||||
</ComposedChart>
|
||||
@@ -210,7 +218,7 @@ function TcpObs({ results, rangeLabel = '近 6 小时' }: Props) {
|
||||
</MonitorRangeChartFrame>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SNMP 内置 OID 模板 — 默认折叠,不占用主监控区域 // Logan.Li
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronDown, Radio } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
@@ -25,6 +26,7 @@ interface Props {
|
||||
|
||||
export function SnmpTemplatesCollapsible({ templates }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<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)]">
|
||||
<Radio className="h-3.5 w-3.5" />
|
||||
<span className="text-[var(--dg-text-primary)] font-medium">内置 OID 模板</span>
|
||||
<Badge variant="outline" className="text-[10px] h-5">{templates.length} 个</Badge>
|
||||
<span className="hidden sm:inline">H3C / 华为 / Cisco / 锐捷 / Dell / 浪潮</span>
|
||||
<span className="text-[var(--dg-text-primary)] font-medium">{t('monitor.snmp.builtinTemplates')}</span>
|
||||
<Badge variant="outline" className="text-[10px] h-5">{t('monitor.snmp.templateCount', { count: templates.length })}</Badge>
|
||||
<span className="hidden sm:inline">{t('monitor.snmp.vendors')}</span>
|
||||
</span>
|
||||
<ChevronDown className={cn('h-4 w-4 shrink-0 text-[var(--dg-text-muted)] transition-transform', open && 'rotate-180')} />
|
||||
</button>
|
||||
@@ -65,7 +67,7 @@ export function SnmpTemplatesCollapsible({ templates }: Props) {
|
||||
</div>
|
||||
))}
|
||||
{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>
|
||||
|
||||
Reference in new issue
Block a user