feat(alert-center): 添加飞书Webhook通知渠道 + 告警引擎实时通知

- 后端: NotifyChannel 模型新增 feishu 类型, FeishuWebhookConfig
- 后端: TestNotifyChannel 真实发送飞书测试消息(文本格式)
- 后端: 告警引擎触发/恢复时自动发送飞书通知(sendFeishuAlert/sendFeishuRecovery)
- 后端: AlertEngine 新增 tcp_time_wait/tcp_close_wait/service_process 指标检测
- 后端: NetworkStatModel 新增 TCPCloseWait 字段
- 后端: QuickSetupRules 一键创建6条告警规则(CPU/内存/磁盘/TIME_WAIT/CLOSE_WAIT/进程)
- 后端: 注册 /alerts/quick-setup 路由
- 前端: AlertChannelDialog 新增飞书类型选项
- 前端: helpers 新增 getChannelIcon/getChannelTypeLabel 支持 feishu/dingtalk/wechat
This commit is contained in:
attacker committed 2026-06-24 08:34:43 +00:00
1 parent 7bc5169e8f
commit 5333f6f04a
30 files changed
+2406 -1001

No files matched your search

@@ -85,7 +85,7 @@ export function PipelineManager() {
const [newCachePaths, setNewCachePaths] = useState('');
// Executor concurrency
const [newMaxExecutors, setNewMaxExecutors] = useState(1);
const [newMaxExecutors, setNewMaxExecutors] = useState(2);
// Reference data
const [repos, setRepos] = useState<any[]>([]);
@@ -288,33 +288,46 @@ export function PipelineManager() {
))}
</div>
{/* ══════════ 全流程可视化横幅 // Logan.Li ══════════ */}
<div className="flex items-center gap-1.5 px-4 py-2.5 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
<div className="flex items-center gap-1">
<GitBranch className="h-3.5 w-3.5 text-indigo-400" />
<span className="text-[11px] font-semibold text-indigo-400">Git 代码</span>
{/* ══════════ 全流程可视化横幅 — 双路径 ══════════ */}
<div className="flex items-stretch gap-3 px-4 py-3 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
{/* 路径 1: SSH 传统主机部署 */}
<div className="flex-1 space-y-1.5">
<div className="flex items-center gap-1.5">
<GitBranch className="h-3 w-3 text-indigo-400" />
<span className="text-[10px] font-semibold text-indigo-400">构建</span>
<ArrowRight className="h-2.5 w-2.5 text-[var(--dg-text-secondary)] opacity-30" />
<Rocket className="h-3 w-3 text-red-400" />
<span className="text-[10px] font-semibold text-red-400">部署</span>
<ArrowRight className="h-2.5 w-2.5 text-[var(--dg-text-secondary)] opacity-30" />
<div className="flex items-center gap-1 px-2 py-0.5 rounded bg-blue-400/10 border border-blue-400/20">
<Server className="h-3 w-3 text-blue-400" />
<span className="text-[10px] font-medium text-blue-400">SSH 主机</span>
</div>
</div>
<p className="text-[9px] text-[var(--dg-text-secondary)] opacity-40 pl-1">传统主机 SCP 部署 · 无需镜像仓库</p>
</div>
<ArrowRight className="h-3 w-3 text-[var(--dg-text-secondary)] opacity-30" />
<div className="flex items-center gap-1">
<Box className="h-3.5 w-3.5 text-amber-400" />
<span className="text-[11px] font-semibold text-amber-400">构建</span>
{/* 分隔 */}
<div className="w-px bg-[var(--dg-border)]" />
{/* 路径 2: Kubernetes 容器部署 */}
<div className="flex-1 space-y-1.5">
<div className="flex items-center gap-1.5">
<GitBranch className="h-3 w-3 text-indigo-400" />
<span className="text-[10px] font-semibold text-indigo-400">构建</span>
<ArrowRight className="h-2.5 w-2.5 text-[var(--dg-text-secondary)] opacity-30" />
<Container className="h-3 w-3 text-cyan-400" />
<span className="text-[10px] font-semibold text-cyan-400">镜像仓库</span>
<ArrowRight className="h-2.5 w-2.5 text-[var(--dg-text-secondary)] opacity-30" />
<Rocket className="h-3 w-3 text-red-400" />
<span className="text-[10px] font-semibold text-red-400">部署</span>
<ArrowRight className="h-2.5 w-2.5 text-[var(--dg-text-secondary)] opacity-30" />
<div className="flex items-center gap-1 px-2 py-0.5 rounded bg-emerald-400/10 border border-emerald-400/20">
<Network className="h-3 w-3 text-emerald-400" />
<span className="text-[10px] font-medium text-emerald-400">Kubernetes</span>
</div>
</div>
<p className="text-[9px] text-[var(--dg-text-secondary)] opacity-40 pl-1">容器化部署 · 需镜像仓库推送</p>
</div>
<ArrowRight className="h-3 w-3 text-[var(--dg-text-secondary)] opacity-30" />
<div className="flex items-center gap-1">
<Container className="h-3.5 w-3.5 text-cyan-400" />
<span className="text-[11px] font-semibold text-cyan-400">镜像仓库</span>
</div>
<ArrowRight className="h-3 w-3 text-[var(--dg-text-secondary)] opacity-30" />
<div className="flex items-center gap-1">
<Rocket className="h-3.5 w-3.5 text-red-400" />
<span className="text-[11px] font-semibold text-red-400">部署</span>
</div>
<ArrowRight className="h-3 w-3 text-[var(--dg-text-secondary)] opacity-30" />
<div className="flex items-center gap-1">
<Network className="h-3.5 w-3.5 text-emerald-400" />
<span className="text-[11px] font-semibold text-emerald-400">K8s</span>
</div>
<span className="ml-auto text-[10px] text-[var(--dg-text-secondary)] opacity-40 tracking-wide">全流程可视化</span>
<span className="self-center ml-2 text-[10px] text-[var(--dg-text-secondary)] opacity-30 tracking-wide whitespace-nowrap">全流程可视化</span>
</div>
<div className="flex items-center gap-4">
@@ -401,7 +414,7 @@ export function PipelineManager() {
)}
<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发版' : pipeline.branch || 'main'}</span>
<span className="flex items-center gap-1">{(pipeline.deploy_target || 'ssh') === 'k8s' ? <><Network className="h-3 w-3" />K8s</> : <><Server className="h-3 w-3" />SSH</>}</span>
<span className="flex items-center gap-1">{(pipeline.deploy_target || 'ssh') === 'k8s' ? <><Network className="h-3 w-3" />Kubernetes</> : <><Server className="h-3 w-3" />SSH 主机</>}</span>
{repo && <span className="flex items-center gap-1"><Globe className="h-3 w-3" />{repo.name}</span>}
{registry && <span className="flex items-center gap-1"><Container className="h-3 w-3" />{registry.name}</span>}
{pipeline.last_run_at && <span><Clock className="h-3 w-3 inline mr-0.5" />{new Date(pipeline.last_run_at).toLocaleString('zh-CN')}</span>}
@@ -560,7 +573,7 @@ export function PipelineManager() {
<div className="flex gap-1 bg-[var(--dg-bg-secondary)] rounded-lg p-1 border border-[var(--dg-border)]">
{[
{ key: 'ssh', label: 'SSH 主机', icon: Server },
{ key: 'k8s', label: 'K8s 集群', icon: Network },
{ key: 'k8s', label: 'Kubernetes 集群', icon: Network },
].map(t => {
const active = newDeployTarget === t.key;
return (
@@ -819,7 +832,7 @@ export function PipelineManager() {
</SelectContent>
</Select>
</div>
<p className="text-[10px] text-[var(--dg-text-secondary)] opacity-50 mt-1">设为 1 则按顺序串行执行,超过 1 可并发构建</p>
<p className="text-[10px] text-[var(--dg-text-secondary)] opacity-50 mt-1">设为 1 则按顺序串行执行,默认 2 可并发构建</p>
</div>
</div>
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-[var(--dg-border)]">
@@ -889,7 +902,7 @@ function PipelineQueuePanel({ pipeline, clusterId: _cid }: { pipeline: any; clus
const running = queue?.running || 0;
const queued = queue?.queued || 0;
const queueIds: number[] = queue?.queue_ids || [];
const max = pipeline.max_executors || 1;
const max = pipeline.max_executors || 2;
const pendingRuns = queue?.pending_runs || [];
if (loading && !queue) return null;
+214 -86
View File
@@ -1,9 +1,9 @@
import { useState, useEffect } from 'react';
import { Wrench, RefreshCw, Loader2, Download, CheckCircle, XCircle, Clock, Settings2 } from 'lucide-react';
import { Wrench, RefreshCw, Loader2, Download, CheckCircle, XCircle, Clock, Settings2, ChevronDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { toast } from 'sonner';
import { deployApi } from '@/services/api';
@@ -12,14 +12,17 @@ interface ToolConfig {
category: string; install_method: 'preinstall' | 'lazy' | 'disabled';
binary_name: string; install_path: string; status: string;
installed_version: string; error_msg: string; enabled: boolean;
sort_order: number;
sort_order: number; default_version: string;
}
interface VersionOption {
version: string; label: string;
}
const categoryLabel: Record<string, string> = {
k8s: 'K8s', build: '构建', deploy: '部署', automation: '自动化', other: '其他',
build: '构建', deploy: '部署', automation: '自动化', other: '其他',
};
const categoryColor: Record<string, string> = {
k8s: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
build: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
deploy: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
automation: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
@@ -34,11 +37,21 @@ const methodColor: Record<string, string> = {
disabled: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
};
// Tools that support version selection
const VERSIONED_TOOLS = ['jdk', 'nodejs', 'maven'];
export default function ToolConfigTab() {
const [tools, setTools] = useState<ToolConfig[]>([]);
const [loading, setLoading] = useState(false);
const [installing, setInstalling] = useState<number | null>(null);
// Version dialog state
const [versionDialog, setVersionDialog] = useState<{ tool: ToolConfig; versions: VersionOption[] } | null>(null);
const [selectedVersion, setSelectedVersion] = useState('');
// Download test state
const [testing, setTesting] = useState<number | null>(null);
const fetchTools = async () => {
setLoading(true);
try {
@@ -50,17 +63,32 @@ export default function ToolConfigTab() {
useEffect(() => { fetchTools(); }, []);
const handleInstall = async (tool: ToolConfig) => {
const openVersionDialog = async (tool: ToolConfig) => {
try {
const res: any = await deployApi.getToolVersions(tool.id);
const versions = (res as any)?.versions || [];
if (versions.length === 0) {
// No versions — install directly
handleInstall(tool);
return;
}
setVersionDialog({ tool, versions });
setSelectedVersion(tool.default_version || versions[0]?.version || '');
} catch {
// Fallback: install with default version
handleInstall(tool);
}
};
const handleInstall = async (tool: ToolConfig, version?: string) => {
setInstalling(tool.id);
try {
await deployApi.installTool(tool.id);
toast.success(`${tool.display_name} 安装已触发`);
// Poll for status
const v = version || tool.default_version;
await deployApi.installTool(tool.id, v);
toast.success(`${tool.display_name}${v ? ` (${v})` : ''} 安装已触发`);
setVersionDialog(null);
setTimeout(async () => {
try {
await deployApi.checkTool(tool.id);
fetchTools();
} catch { /* ignore */ }
try { await deployApi.checkTool(tool.id); fetchTools(); } catch { /* ignore */ }
setInstalling(null);
}, 3000);
} catch {
@@ -85,6 +113,26 @@ export default function ToolConfigTab() {
} catch { toast.error('更新失败'); }
};
// Download speed test
const handleTestDownload = async (tool: ToolConfig) => {
setTesting(tool.id);
const start = Date.now();
try {
// Simulate a quick reachability test by fetching the version URL
const res: any = await deployApi.getToolVersions(tool.id);
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
const available = (res as any)?.versions?.length > 0;
if (available) {
toast.success(`${tool.display_name} 镜像可访问 (${elapsed}s)`);
} else {
toast(`${tool.display_name} 无法获取版本列表`, { description: '请检查网络连接' });
}
} catch {
toast.error(`${tool.display_name} 网络不可达`);
}
setTesting(null);
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
@@ -117,84 +165,164 @@ export default function ToolConfigTab() {
</tr>
</thead>
<tbody>
{tools.map(tool => (
<tr key={tool.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2">
<div className="font-medium text-[var(--dg-text-primary)]">{tool.display_name}</div>
<div className="text-xs text-[var(--dg-text-secondary)] font-mono">{tool.name}</div>
{tool.installed_version && tool.status === 'installed' && (
<div className="text-xs text-emerald-400 font-mono mt-0.5">v{tool.installed_version}</div>
)}
{tool.error_msg && (
<div className="text-xs text-red-400 mt-0.5 max-w-[200px] truncate" title={tool.error_msg}>{tool.error_msg}</div>
)}
</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}
</Badge>
</td>
<td className="px-3 py-2">
<Select
value={tool.install_method}
onValueChange={(v) => handleMethodChange(tool, v as 'preinstall' | 'lazy' | 'disabled')}
>
<SelectTrigger className="h-7 w-[90px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="preinstall">预装</SelectItem>
<SelectItem value="lazy">懒加载</SelectItem>
<SelectItem value="disabled">禁用</SelectItem>
</SelectContent>
</Select>
</td>
<td className="px-3 py-2">
{tool.status === 'installed' ? (
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-xs gap-1">
<CheckCircle className="w-3 h-3" />已安装
</Badge>
) : tool.status === 'installing' ? (
<Badge className="bg-amber-500/20 text-amber-400 border-amber-500/30 text-xs gap-1">
<Loader2 className="w-3 h-3 animate-spin" />安装中
</Badge>
) : tool.status === 'failed' ? (
<Badge className="bg-red-500/20 text-red-400 border-red-500/30 text-xs gap-1">
<XCircle className="w-3 h-3" />失败
</Badge>
) : (
<Badge className="bg-slate-500/20 text-slate-400 border-slate-500/30 text-xs gap-1">
<Clock className="w-3 h-3" />未安装
</Badge>
)}
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
{tool.install_method === 'lazy' && tool.status !== 'installed' && (
<Button
variant="outline" size="sm" className="h-7 text-xs"
onClick={() => handleInstall(tool)}
disabled={installing === tool.id}
>
{installing === tool.id ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Download className="w-3 h-3 mr-1" />
)}
安装
</Button>
{tools.map(tool => {
const hasVersions = VERSIONED_TOOLS.includes(tool.name);
return (
<tr key={tool.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2">
<div className="font-medium text-[var(--dg-text-primary)]">{tool.display_name}</div>
<div className="text-xs text-[var(--dg-text-secondary)] font-mono">{tool.name}</div>
{tool.description && (
<div className="text-[10px] text-[var(--dg-text-muted)] mt-0.5 max-w-[220px] leading-tight">{tool.description}</div>
)}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleCheckStatus(tool)} title="检测">
<Settings2 className="w-3.5 h-3.5" />
</Button>
</div>
</td>
</tr>
))}
{tool.installed_version && tool.status === 'installed' && (
<div className="text-xs text-emerald-400 font-mono mt-0.5">v{tool.installed_version}</div>
)}
{tool.error_msg && (
<div className="text-xs text-red-400 mt-0.5 max-w-[200px] truncate" title={tool.error_msg}>{tool.error_msg}</div>
)}
</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}
</Badge>
</td>
<td className="px-3 py-2">
<Select
value={tool.install_method}
onValueChange={(v) => handleMethodChange(tool, v as 'preinstall' | 'lazy' | 'disabled')}
>
<SelectTrigger className="h-7 w-[90px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="preinstall">预装</SelectItem>
<SelectItem value="lazy">懒加载</SelectItem>
<SelectItem value="disabled">禁用</SelectItem>
</SelectContent>
</Select>
</td>
<td className="px-3 py-2">
{tool.status === 'installed' ? (
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-xs gap-1">
<CheckCircle className="w-3 h-3" />已安装
</Badge>
) : tool.status === 'installing' ? (
<Badge className="bg-amber-500/20 text-amber-400 border-amber-500/30 text-xs gap-1">
<Loader2 className="w-3 h-3 animate-spin" />安装中
</Badge>
) : tool.status === 'failed' ? (
<Badge className="bg-red-500/20 text-red-400 border-red-500/30 text-xs gap-1">
<XCircle className="w-3 h-3" />失败
</Badge>
) : (
<Badge className="bg-slate-500/20 text-slate-400 border-slate-500/30 text-xs gap-1">
<Clock className="w-3 h-3" />未安装
</Badge>
)}
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
{tool.install_method === 'lazy' && tool.status !== 'installed' && (
hasVersions ? (
<Button
variant="outline" size="sm" className="h-7 text-xs"
onClick={() => openVersionDialog(tool)}
disabled={installing === tool.id}
>
{installing === tool.id ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Download className="w-3 h-3 mr-1" />
)}
安装
</Button>
) : (
<Button
variant="outline" size="sm" className="h-7 text-xs"
onClick={() => handleInstall(tool)}
disabled={installing === tool.id}
>
{installing === tool.id ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<Download className="w-3 h-3 mr-1" />
)}
安装
</Button>
)
)}
{tool.install_method === 'lazy' && (
<Button
variant="ghost" size="sm" className="h-7 text-xs text-[var(--dg-text-secondary)]"
onClick={() => handleTestDownload(tool)}
disabled={testing === tool.id}
>
{testing === tool.id ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<ChevronDown className="w-3 h-3 mr-1" />
)}
测试
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleCheckStatus(tool)} title="检测">
<Settings2 className="w-3.5 h-3.5" />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* ══════════ 版本选择弹框 ══════════ */}
<Dialog open={!!versionDialog} onOpenChange={() => setVersionDialog(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>选择安装版本</DialogTitle>
<DialogDescription>
{versionDialog?.tool.display_name} — 请选择要安装的版本
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
{versionDialog?.versions.map((v, i) => (
<button
key={v.version}
onClick={() => setSelectedVersion(v.version)}
className={`w-full flex items-center justify-between px-3 py-2.5 rounded-lg text-sm border transition-all ${
selectedVersion === v.version
? 'border-indigo-400/50 bg-indigo-400/10 text-[var(--dg-text-primary)]'
: 'border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-[var(--dg-text-secondary)] hover:border-[var(--dg-text-secondary)]/30'
}`}
>
<span>{v.label}</span>
<span className="text-xs font-mono text-[var(--dg-text-muted)]">{v.version}</span>
</button>
))}
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-[var(--dg-border)]">
<Button variant="outline" size="sm" onClick={() => setVersionDialog(null)}>取消</Button>
<Button
size="sm"
onClick={() => versionDialog && handleInstall(versionDialog.tool, selectedVersion)}
disabled={!selectedVersion || installing === versionDialog?.tool.id}
>
{installing === versionDialog?.tool.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin mr-1" />
) : (
<Download className="w-3.5 h-3.5 mr-1" />
)}
安装
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -87,6 +87,6 @@ export const PIPELINE_STEPS = [
{ name: '单元测试', status: 'success', duration: '2m 15s' },
{ name: '构建镜像', status: 'success', duration: '3m 30s' },
{ name: '推送镜像', status: 'success', duration: '1m 20s' },
{ name: '部署到K8s', status: 'running', duration: '--' },
{ name: '部署到Kubernetes', status: 'running', duration: '--' },
{ name: '健康检查', status: 'pending', duration: '--' },
];