feat: 监控告警体系统一重构 + 自动化任务调度引擎 + 监控大屏拆分
告警中心 - 新增统一告警(alert_unified)、监控告警规则(monitor_alert_rule)、指标目录(alert_metric_catalog) - 通知策略与消息分发(alert_notify_policy/dispatch/message)、静默规则 - 告警引擎按阈值/SNMP 拆分(alert_engine_monitor/snmp) 监控采集 - SNMP 常量/模板与历史指标(snmp templates/constants、snmp_metrics_history、snmp_collect) - 中间件监控 Redis 采集与种子(middleware_monitor_redis/seed) - 站点探测 pkg(siteprobe) + 站点视图(site_view) 自动化任务 - cron 调度器(cronscheduler)、脚本运行器(scriptrunner)、系统任务(systask)及运行记录(cron_job_run、system_task) 审计与凭证 - 审计设置(audit_setting) + 历史清理(auditcleaner) 前端 - 监控页面(host/middleware/site/snmp)拆分为子模块并大屏化 - 告警中心多 Tab(规则/指标目录/通知策略/静默)、任务管理组件重构、Agent CLI Markdown 渲染 - 移除旧脚本卡片/详情/日志组件,统一为 ScriptTable/ScriptCodeEditor/TerminalOutput Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
092d0e4b9e
commit
471353e3d7
190 files changed
+16640
-3484
No files matched your search
@@ -1,119 +1,91 @@
|
||||
// 中间件监控 — Redis / MySQL 指标采集(后端直连模式 M15)
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
// 中间件监控 — Redis(单机/集群)/ MySQL 可视化可观测 // Logan.Li
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter,
|
||||
DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Database, Server, Activity, Loader2, Circle, Plus,
|
||||
Trash2, Pencil, Zap, RefreshCw, Settings2,
|
||||
} from 'lucide-react';
|
||||
import { Database, Server, Activity, Loader2, Plus, Zap, RefreshCw } from 'lucide-react';
|
||||
import { middlewareMonitorApi } from '@/api';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { MwOverviewCharts } from './middleware-monitor/MwOverviewCharts';
|
||||
import { MwTargetChartsPanel } from './middleware-monitor/MwTargetChartsPanel';
|
||||
import { InstanceCard } from './middleware-monitor/InstanceCard';
|
||||
import { MiddlewareDialog } from './middleware-monitor/MiddlewareDialog';
|
||||
import type { MwTarget } from './middleware-monitor/helpers';
|
||||
import { REDIS_CHARTS, MYSQL_CHARTS, evalMwHealth, worstHealthLevel } from './middleware-monitor/helpers';
|
||||
|
||||
interface MwTarget {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
description: string; // 子类型 redis/mysql
|
||||
latest_metrics: Record<string, number>;
|
||||
last_seen_at?: string;
|
||||
}
|
||||
type TabKey = 'redis' | 'mysql';
|
||||
const REFRESH_INTERVAL_MS = 30_000;
|
||||
|
||||
interface MwListResponse {
|
||||
list: MwTarget[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
function fmtMemory(bytes: number): string {
|
||||
if (bytes == null || bytes === 0) return '--';
|
||||
if (bytes > 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
|
||||
if (bytes > 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
|
||||
if (bytes > 1e3) return `${(bytes / 1e3).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
function fmtHitRate(v: number): string {
|
||||
if (v == null || Number.isNaN(v)) return '--';
|
||||
return `${(v * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function fmtVal(v: number, precision = 0): string {
|
||||
if (v == null || Number.isNaN(v)) return '--';
|
||||
return v.toFixed(precision);
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'online': return 'dg-text-green';
|
||||
case 'warning': return 'dg-text-yellow';
|
||||
case 'error':
|
||||
case 'offline': return 'dg-text-red';
|
||||
default: return 'dg-text-muted';
|
||||
}
|
||||
}
|
||||
|
||||
function statusBg(status: string): string {
|
||||
switch (status) {
|
||||
case 'online': return 'bg-emerald-400';
|
||||
case 'warning': return 'bg-amber-400';
|
||||
case 'error':
|
||||
case 'offline': return 'bg-red-400';
|
||||
default: return 'bg-[var(--dg-bg-active)]';
|
||||
}
|
||||
}
|
||||
interface MwListResponse { list: MwTarget[]; total: number }
|
||||
|
||||
export function MiddlewareMonitor() {
|
||||
const confirm = useConfirm();
|
||||
const [targets, setTargets] = useState<MwTarget[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [tab, setTab] = useState<TabKey>('redis');
|
||||
const [selectedRedis, setSelectedRedis] = useState<MwTarget | null>(null);
|
||||
const [selectedMysql, setSelectedMysql] = useState<MwTarget | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<MwTarget | null>(null);
|
||||
const [testing, setTesting] = useState<number | null>(null);
|
||||
const [seeding, setSeeding] = useState(false);
|
||||
const [lastRefreshAt, setLastRefreshAt] = useState<Date | null>(null);
|
||||
const hasLoaded = useRef(false);
|
||||
|
||||
const loadTargets = useCallback(async () => {
|
||||
const syncSelection = useCallback((list: MwTarget[]) => {
|
||||
const redis = list.filter((t) => t.description === 'redis');
|
||||
const mysql = list.filter((t) => t.description === 'mysql');
|
||||
setSelectedRedis((prev) => {
|
||||
if (prev && redis.some((t) => t.id === prev.id)) return redis.find((t) => t.id === prev.id) || null;
|
||||
return redis.find((t) => t.status === 'online') || redis[0] || null;
|
||||
});
|
||||
setSelectedMysql((prev) => {
|
||||
if (prev && mysql.some((t) => t.id === prev.id)) return mysql.find((t) => t.id === prev.id) || null;
|
||||
return mysql.find((t) => t.status === 'online') || mysql[0] || null;
|
||||
});
|
||||
if (redis.length === 0 && mysql.length > 0) setTab('mysql');
|
||||
}, []);
|
||||
|
||||
const loadTargets = useCallback(async (silent = false) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if (!silent) setLoading(true);
|
||||
else setRefreshing(true);
|
||||
setError('');
|
||||
const data = await middlewareMonitorApi.list() as MwListResponse;
|
||||
setTargets(data?.list || []);
|
||||
const list = data?.list || [];
|
||||
setTargets(list);
|
||||
syncSelection(list);
|
||||
setLastRefreshAt(new Date());
|
||||
hasLoaded.current = true;
|
||||
} catch (err) {
|
||||
const msg = (err as { message?: string }).message || '加载失败';
|
||||
setError(msg);
|
||||
setTargets([]);
|
||||
if (!silent) {
|
||||
setError((err as { message?: string }).message || '加载失败');
|
||||
setTargets([]);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
}, [syncSelection]);
|
||||
|
||||
useEffect(() => { loadTargets(); }, [loadTargets]);
|
||||
|
||||
// 按子类型分组
|
||||
const redisTargets = targets.filter(t => t.description === 'redis');
|
||||
const mysqlTargets = targets.filter(t => t.description === 'mysql');
|
||||
const otherTargets = targets.filter(t => t.description !== 'redis' && t.description !== 'mysql');
|
||||
// 30s 静默刷新,与采集周期对齐 // Logan.Li
|
||||
useEffect(() => {
|
||||
if (!hasLoaded.current || targets.length === 0) return;
|
||||
const timer = setInterval(() => loadTargets(true), REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [targets.length, loadTargets]);
|
||||
|
||||
const redisTarget = redisTargets.find(t => t.status === 'online') || redisTargets[0];
|
||||
const redisMetrics = redisTarget?.latest_metrics || {};
|
||||
const mysqlTarget = mysqlTargets.find(t => t.status === 'online') || mysqlTargets[0];
|
||||
const mysqlMetrics = mysqlTarget?.latest_metrics || {};
|
||||
const redisTargets = targets.filter((t) => t.description === 'redis');
|
||||
const mysqlTargets = targets.filter((t) => t.description === 'mysql');
|
||||
const onlineCount = targets.filter((t) => t.status === 'online').length;
|
||||
const warnCount = targets.filter((t) => worstHealthLevel(evalMwHealth(t)) !== 'ok').length;
|
||||
|
||||
const handleDelete = async (target: MwTarget) => {
|
||||
const ok = await confirm({
|
||||
title: '删除中间件监控',
|
||||
description: `确认删除 "${target.name}"?相关指标历史也将清除。`,
|
||||
confirmText: '删除',
|
||||
});
|
||||
const ok = await confirm({ title: '删除监控', description: `确认删除 "${target.name}"?`, confirmText: '删除' });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await middlewareMonitorApi.delete(target.id);
|
||||
@@ -128,12 +100,8 @@ export function MiddlewareMonitor() {
|
||||
setTesting(target.id);
|
||||
try {
|
||||
const res = await middlewareMonitorApi.test(target.id) as { success: boolean; message: string };
|
||||
if (res.success) {
|
||||
toast.success('连接成功', { description: res.message });
|
||||
} else {
|
||||
toast.error('连接失败', { description: res.message });
|
||||
}
|
||||
if (res.success) loadTargets();
|
||||
if (res.success) { toast.success('连接成功', { description: res.message }); loadTargets(true); }
|
||||
else toast.error('连接失败', { description: res.message });
|
||||
} catch (err) {
|
||||
toast.error('测试失败', { description: (err as { message?: string }).message });
|
||||
} finally {
|
||||
@@ -141,450 +109,179 @@ export function MiddlewareMonitor() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeedDefaults = async () => {
|
||||
setSeeding(true);
|
||||
try {
|
||||
const res = await middlewareMonitorApi.seedDefaults() as { created: number; message: string; failed?: string[] };
|
||||
await loadTargets();
|
||||
if (res.failed?.length) toast.error('部分失败', { description: res.message });
|
||||
else if (res.created > 0) toast.success('已添加默认监控', { description: res.message });
|
||||
else toast.success('默认目标已存在');
|
||||
} catch (err) {
|
||||
toast.error('添加失败', { description: (err as { message?: string }).message });
|
||||
} finally {
|
||||
setSeeding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCollectNow = async () => {
|
||||
try {
|
||||
const res = await middlewareMonitorApi.collectNow() as { collected: number; message: string };
|
||||
const res = await middlewareMonitorApi.collectNow() as { message: string };
|
||||
toast.success('采集完成', { description: res.message });
|
||||
loadTargets();
|
||||
loadTargets(true);
|
||||
} catch (err) {
|
||||
toast.error('采集失败', { description: (err as { message?: string }).message });
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = (type?: TabKey) => {
|
||||
setEditTarget(null);
|
||||
if (type) setTab(type);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const currentTargets = tab === 'redis' ? redisTargets : mysqlTargets;
|
||||
const selected = tab === 'redis' ? selectedRedis : selectedMysql;
|
||||
const setSelected = tab === 'redis' ? setSelectedRedis : setSelectedMysql;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">中间件监控</h2>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">中间件监控</h2>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5">
|
||||
Redis 单机/集群 · MySQL · 30s 自动刷新
|
||||
{lastRefreshAt && (
|
||||
<span className="ml-2">
|
||||
上次更新 {lastRefreshAt.toLocaleTimeString()}
|
||||
{refreshing && ' · 刷新中...'}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleCollectNow} className="gap-1.5">
|
||||
<Zap className="h-4 w-4" />
|
||||
立即采集
|
||||
<Zap className="h-4 w-4" />立即采集
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={loadTargets} className="gap-1.5">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
<Button variant="outline" size="sm" onClick={() => loadTargets(true)} disabled={refreshing} className="gap-1.5">
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />刷新
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setEditTarget(null); setDialogOpen(true); }} className="gap-1.5">
|
||||
<Plus className="h-4 w-4" />
|
||||
添加监控
|
||||
{targets.length === 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleSeedDefaults} disabled={seeding} className="gap-1.5">
|
||||
{seeding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Database className="h-4 w-4" />}
|
||||
添加平台默认
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={() => openCreate()} className="gap-1.5">
|
||||
<Plus className="h-4 w-4" />添加监控
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin dg-text-muted mr-2" />
|
||||
<span className="text-sm text-[var(--dg-text-muted)]">正在加载中间件指标...</span>
|
||||
<div className="flex items-center justify-center py-12 text-sm text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="h-5 w-5 animate-spin mr-2" />加载中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-red-500/30">
|
||||
<CardContent className="p-6 text-center">
|
||||
<Activity className="h-8 w-8 dg-text-red mx-auto mb-2" />
|
||||
<p className="text-sm dg-text-red">{error}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-2">请确认后端服务可用</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-red-500/30"><CardContent className="p-6 text-center dg-text-red text-sm">{error}</CardContent></Card>
|
||||
)}
|
||||
|
||||
{!loading && !error && targets.length === 0 && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-12 text-center">
|
||||
<Database className="h-12 w-12 dg-text-muted mx-auto mb-4" />
|
||||
<Database className="h-12 w-12 mx-auto mb-4 text-[var(--dg-text-muted)]" />
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">暂无中间件监控目标</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-2">点击「添加监控」配置 Redis / MySQL 连接</p>
|
||||
<Button size="sm" className="mt-4 gap-1.5" onClick={() => { setEditTarget(null); setDialogOpen(true); }}>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加第一个监控
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Redis 概览卡片 */}
|
||||
{redisTargets.length > 0 && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Database className="h-4 w-4 dg-text-red" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">Redis 监控</h3>
|
||||
{redisTarget && (
|
||||
<Badge variant="outline" className="ml-auto text-[10px] border-[var(--dg-border)] text-[var(--dg-text-muted)]">
|
||||
{redisTarget.name} ({redisTarget.ip}:{redisTarget.port})
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{redisTarget ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
<MetricTile label="连接数" value={fmtVal(redisMetrics.redis_connected_clients)} />
|
||||
<MetricTile label="内存" value={fmtMemory(redisMetrics.redis_used_memory)} />
|
||||
<MetricTile label="命中率" value={fmtHitRate(redisMetrics.redis_hit_rate)} />
|
||||
<MetricTile label="QPS" value={fmtVal(redisMetrics.redis_qps, 1)} />
|
||||
<MetricTile label="Key总数" value={fmtVal(redisMetrics.redis_db_size)} />
|
||||
<MetricTile label="阻塞客户端" value={fmtVal(redisMetrics.redis_blocked_clients)} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">无在线 Redis 实例</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* MySQL 概览卡片 */}
|
||||
{mysqlTargets.length > 0 && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Server className="h-4 w-4 dg-text-blue" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">MySQL 监控</h3>
|
||||
{mysqlTarget && (
|
||||
<Badge variant="outline" className="ml-auto text-[10px] border-[var(--dg-border)] text-[var(--dg-text-muted)]">
|
||||
{mysqlTarget.name} ({mysqlTarget.ip}:{mysqlTarget.port})
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{mysqlTarget ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
||||
<MetricTile label="连接数" value={fmtVal(mysqlMetrics.mysql_connections)} />
|
||||
<MetricTile label="活跃线程" value={fmtVal(mysqlMetrics.mysql_threads_running)} />
|
||||
<MetricTile label="QPS" value={fmtVal(mysqlMetrics.mysql_qps, 1)} />
|
||||
<MetricTile label="慢查询" value={fmtVal(mysqlMetrics.mysql_slow_queries)} />
|
||||
<MetricTile label="缓冲池命中率" value={fmtHitRate(mysqlMetrics.mysql_buffer_hit_rate)} />
|
||||
<MetricTile label="发送流量" value={fmtMemory(mysqlMetrics.mysql_bytes_sent)} />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">无在线 MySQL 实例</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 中间件实例列表 */}
|
||||
{targets.length > 0 && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Settings2 className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
监控目标列表({targets.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{targets.map(target => (
|
||||
<InstanceCard
|
||||
key={target.id}
|
||||
target={target}
|
||||
onEdit={() => { setEditTarget(target); setDialogOpen(true); }}
|
||||
onDelete={() => handleDelete(target)}
|
||||
onTest={() => handleTest(target)}
|
||||
testing={testing === target.id}
|
||||
/>
|
||||
))}
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<Button size="sm" onClick={handleSeedDefaults} disabled={seeding}>添加平台默认</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => openCreate()}>手动添加</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 其他类型 */}
|
||||
{otherTargets.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{otherTargets.map(target => (
|
||||
<Card key={target.id} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Circle className={`h-3 w-3 ${statusColor(target.status)}`} />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)] capitalize">{target.description}</h3>
|
||||
<Badge variant="outline" className="text-[10px] border-[var(--dg-border)] text-[var(--dg-text-muted)]">
|
||||
{target.name} ({target.ip})
|
||||
</Badge>
|
||||
</div>
|
||||
{Object.keys(target.latest_metrics || {}).length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{Object.entries(target.latest_metrics).map(([key, val]) => (
|
||||
<MetricTile key={key} label={key} value={fmtVal(val, 1)} />
|
||||
))}
|
||||
{!loading && !error && targets.length > 0 && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
{[
|
||||
{ label: '监控实例', value: targets.length, icon: Activity },
|
||||
{ label: '在线', value: onlineCount, icon: Database, color: 'dg-text-green' },
|
||||
{ label: '告警', value: warnCount, icon: Activity, color: warnCount > 0 ? 'dg-text-yellow' : 'dg-text-green' },
|
||||
{ label: 'Redis', value: redisTargets.length, icon: Database, color: 'dg-text-red' },
|
||||
{ label: 'MySQL', value: mysqlTargets.length, icon: Server, color: 'dg-text-blue' },
|
||||
].map((item) => (
|
||||
<Card key={item.label} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3 flex items-center gap-3">
|
||||
<item.icon className={`h-4 w-4 ${item.color || 'text-[var(--dg-text-muted)]'}`} />
|
||||
<div>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{item.label}</p>
|
||||
<p className={`text-lg font-bold ${item.color || ''}`}>{item.value}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">暂无指标数据</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<MwOverviewCharts targets={targets} />
|
||||
|
||||
<div className="flex gap-2">
|
||||
{([
|
||||
{ key: 'redis' as TabKey, label: 'Redis', count: redisTargets.length, icon: Database },
|
||||
{ key: 'mysql' as TabKey, label: 'MySQL', count: mysqlTargets.length, icon: Server },
|
||||
]).map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
tab === t.key
|
||||
? 'bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]'
|
||||
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<t.icon className="h-4 w-4" />{t.label}
|
||||
<span className="text-xs opacity-60">{t.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{currentTargets.length === 0 ? (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-8 text-center text-sm text-[var(--dg-text-muted)]">
|
||||
暂无 {tab === 'redis' ? 'Redis' : 'MySQL'} 实例,
|
||||
<button type="button" className="dg-text-cyan ml-1 underline" onClick={() => openCreate(tab)}>立即添加</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加/编辑弹框 */}
|
||||
<MiddlewareDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
editTarget={editTarget}
|
||||
onSuccess={loadTargets}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 指标小方块 */
|
||||
function MetricTile({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-tertiary)] rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{label}</p>
|
||||
<p className="text-lg font-bold text-[var(--dg-text-primary)]">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 实例卡片 */
|
||||
function InstanceCard({
|
||||
target, onEdit, onDelete, onTest, testing,
|
||||
}: {
|
||||
target: MwTarget;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onTest: () => void;
|
||||
testing: boolean;
|
||||
}) {
|
||||
const metrics = target.latest_metrics || {};
|
||||
const metricCount = Object.keys(metrics).length;
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--dg-bg-tertiary)] rounded-lg p-3 border border-[var(--dg-border)] hover:border-[var(--dg-border)]/80 transition-colors">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className={`inline-block w-2 h-2 rounded-full ${statusBg(target.status)}`} />
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)] truncate">{target.name}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] capitalize ${target.status === 'online' ? 'border-emerald-400/50 dg-text-green' : 'border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}
|
||||
>
|
||||
{target.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-2">
|
||||
{target.ip}:{target.port} · {target.description}
|
||||
</div>
|
||||
{metricCount > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{Object.entries(metrics).slice(0, 4).map(([key, val]) => (
|
||||
<span key={key} className="inline-flex items-center gap-1 text-[10px] bg-[var(--dg-bg-primary)] rounded px-1.5 py-0.5 text-[var(--dg-text-secondary)]">
|
||||
<span className="text-[var(--dg-text-muted)]">{key.replace(/^(redis|mysql)_/, '')}</span>
|
||||
<span className="font-mono font-medium text-[var(--dg-text-primary)]">
|
||||
{typeof val === 'number' ? val.toFixed(1) : val}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{metricCount > 4 && (
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">+{metricCount - 4} 项</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] mb-3">暂无指标</p>
|
||||
)}
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={onTest} disabled={testing}>
|
||||
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Zap className="h-3 w-3" />}
|
||||
测试
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={onEdit}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1 dg-text-red hover:dg-text-red" onClick={onDelete}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 添加/编辑中间件弹框 */
|
||||
function MiddlewareDialog({
|
||||
open, onOpenChange, editTarget, onSuccess,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
editTarget: MwTarget | null;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
name: '', type: 'redis', ip: '', port: 6379,
|
||||
password: '', username: '', database: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editTarget) {
|
||||
setForm({
|
||||
name: editTarget.name || '',
|
||||
type: editTarget.description || 'redis',
|
||||
ip: editTarget.ip || '',
|
||||
port: editTarget.port || 6379,
|
||||
password: '', // 不回显密码
|
||||
username: '',
|
||||
database: '',
|
||||
});
|
||||
} else {
|
||||
setForm({
|
||||
name: '', type: 'redis', ip: '', port: 6379,
|
||||
password: '', username: '', database: '',
|
||||
});
|
||||
}
|
||||
}, [editTarget, open]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim() || !form.ip.trim()) {
|
||||
toast.error('请填写名称和 IP');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.name.trim(),
|
||||
type: form.type,
|
||||
ip: form.ip.trim(),
|
||||
port: Number(form.port) || (form.type === 'redis' ? 6379 : 3306),
|
||||
interval: 30,
|
||||
};
|
||||
if (form.password) payload.password = form.password;
|
||||
if (form.type === 'mysql') {
|
||||
if (form.username) payload.username = form.username;
|
||||
if (form.database) payload.database = form.database;
|
||||
}
|
||||
|
||||
if (editTarget) {
|
||||
await middlewareMonitorApi.update(editTarget.id, payload);
|
||||
toast.success('更新成功');
|
||||
} else {
|
||||
await middlewareMonitorApi.create(payload);
|
||||
toast.success('创建成功,正在采集...');
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
toast.error(editTarget ? '更新失败' : '创建失败', {
|
||||
description: (err as { message?: string }).message,
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent showOverlay={false} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">
|
||||
{editTarget ? '编辑中间件监控' : '添加中间件监控'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-secondary)]">
|
||||
配置 Redis 或 MySQL 连接信息,平台将自动采集指标
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
|
||||
{/* 类型选择 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">中间件类型</Label>
|
||||
<div className="flex gap-2">
|
||||
{(['redis', 'mysql'] as const).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setForm(f => ({
|
||||
...f, type: t,
|
||||
port: t === 'redis' ? 6379 : 3306,
|
||||
}))}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
form.type === t
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
{t === 'redis' ? 'Redis' : 'MySQL'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* 名称 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">名称 *</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder={form.type === 'redis' ? '生产 Redis' : '生产 MySQL'}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
{/* IP + Port */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="col-span-2 space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">IP 地址 *</Label>
|
||||
<Input
|
||||
value={form.ip}
|
||||
onChange={e => setForm(f => ({ ...f, ip: e.target.value }))}
|
||||
placeholder="10.10.10.75"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">端口</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.port}
|
||||
onChange={e => setForm(f => ({ ...f, port: Number(e.target.value) }))}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 密码 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">
|
||||
密码 {editTarget && <span className="text-xs text-[var(--dg-text-muted)]">(留空不修改)</span>}
|
||||
</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
|
||||
placeholder="输入密码"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
</div>
|
||||
{/* MySQL 专属字段 */}
|
||||
{form.type === 'mysql' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">用户名</Label>
|
||||
<Input
|
||||
value={form.username}
|
||||
onChange={e => setForm(f => ({ ...f, username: e.target.value }))}
|
||||
placeholder="root"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col xl:flex-row gap-4 items-start">
|
||||
<div className="w-full xl:w-[min(260px,24%)] xl:shrink-0 space-y-2">
|
||||
{currentTargets.map((t) => (
|
||||
<InstanceCard
|
||||
key={t.id}
|
||||
target={t}
|
||||
selected={selected?.id === t.id}
|
||||
onSelect={() => setSelected(t)}
|
||||
onEdit={() => { setEditTarget(t); setDialogOpen(true); }}
|
||||
onDelete={() => handleDelete(t)}
|
||||
onTest={() => handleTest(t)}
|
||||
testing={testing === t.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">数据库</Label>
|
||||
<Input
|
||||
value={form.database}
|
||||
onChange={e => setForm(f => ({ ...f, database: e.target.value }))}
|
||||
placeholder="information_schema"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
|
||||
<div className="flex-1 min-w-0 w-full">
|
||||
<MwTargetChartsPanel
|
||||
target={selected}
|
||||
chartDefs={tab === 'redis' ? REDIS_CHARTS : MYSQL_CHARTS}
|
||||
accent={tab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={saving}>
|
||||
{saving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{editTarget ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
|
||||
<MiddlewareDialog open={dialogOpen} onOpenChange={setDialogOpen} editTarget={editTarget} onSuccess={() => loadTargets(true)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user