feat(monitor): M15 中间件监控 — Redis/MySQL 后端直连采集引擎
后端: - 新增 controller/middleware_monitor.go (500+ 行) — CRUD + 测试连接 + 采集引擎 - 后台 goroutine 每 30s 自动连接 Redis/MySQL 采集指标 - Redis: go-redis v9 INFO+DBSIZE 命令采集 12 项指标 - MySQL: database/sql SHOW GLOBAL STATUS 采集 12 项指标 - 指标写入 MetricHistory 表,7 天自动清理 - 8 个 RESTful 路由注册到 routes.go 前端: - MiddlewareMonitor.tsx 从只读卡片升级为完整管理页面 - 添加监控弹框(Redis/MySQL 类型选择+连接配置表单) - 实例卡片操作栏(测试/编辑/删除) + 工具栏(立即采集/刷新) - middlewareMonitorApi 8 个方法 验证: go build✅ npm build✅ 17/17 回归通过 Redis 12 指标✅ MySQL 12 指标✅
This commit is contained in:
1 parent
186ece6495
commit
ed7a5c07a7
8 files changed
+1211
-132
No files matched your search
@@ -7,4 +7,4 @@ export { traceApi } from './trace';
|
||||
export { traceDatasourceApi, type TraceDataSource } from './trace-datasource';
|
||||
export { dashboardApi } from './grafana';
|
||||
export { promDatasourceApi, zabbixDatasourceApi } from './datasource';
|
||||
export { metricsApi, snmpApi, prometheusProxyApi, zabbixProxyApi } from './metrics';
|
||||
export { metricsApi, snmpApi, prometheusProxyApi, zabbixProxyApi, middlewareMonitorApi } from './metrics';
|
||||
@@ -22,6 +22,20 @@ export const snmpApi = {
|
||||
deleteDevice: (id: number | string) => http.delete(`/snmp/devices/${id}`),
|
||||
};
|
||||
|
||||
// 中间件监控 — Redis/MySQL 后端直连采集 (M15)
|
||||
export const middlewareMonitorApi = {
|
||||
list: (params?: Record<string, string | number | undefined>) =>
|
||||
http.get('/middleware-monitors', params),
|
||||
create: (data: Record<string, unknown>) => http.post('/middleware-monitors', data),
|
||||
update: (id: number | string, data: Record<string, unknown>) =>
|
||||
http.put(`/middleware-monitors/${id}`, data),
|
||||
delete: (id: number | string) => http.delete(`/middleware-monitors/${id}`),
|
||||
test: (id: number | string) => http.post(`/middleware-monitors/${id}/test`),
|
||||
collectNow: () => http.post('/middleware-monitors/collect'),
|
||||
getMetrics: (id: number | string, params?: Record<string, string | number | undefined>) =>
|
||||
http.get(`/middleware-monitors/${id}/metrics`, params),
|
||||
};
|
||||
|
||||
export const prometheusProxyApi = {
|
||||
connect: (url: string) => http.post('/prometheus/connect', { url }),
|
||||
dashboard: (url: string, type: string) => http.post('/prometheus/dashboard', { url, type }),
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
// 中间件监控 — Redis / MySQL 指标采集(实时 API 数据)
|
||||
// 中间件监控 — Redis / MySQL 指标采集(后端直连模式 M15)
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Database, Server, Activity, Loader2, Circle } from 'lucide-react';
|
||||
import { metricsApi } from '@/api';
|
||||
import type { JsonData } from '@/types/common';
|
||||
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 { middlewareMonitorApi } from '@/api';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
|
||||
interface TargetInfo {
|
||||
interface MwTarget {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
ip: string;
|
||||
status: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
description: string; // 子类型 redis/mysql
|
||||
latest_metrics: Record<string, number>;
|
||||
last_seen_at?: string;
|
||||
}
|
||||
|
||||
interface TargetsResponse {
|
||||
list: TargetInfo[];
|
||||
interface MwListResponse {
|
||||
list: MwTarget[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 格式化内存值 (bytes → human readable) */
|
||||
function fmtMemory(bytes: number): string {
|
||||
if (bytes == null || bytes === 0) return '--';
|
||||
if (bytes > 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
|
||||
@@ -29,24 +42,22 @@ function fmtMemory(bytes: number): string {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
/** 格式化命中率 (0-1 float → %) */
|
||||
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 'text-emerald-400';
|
||||
case 'warning': return 'text-amber-400';
|
||||
case 'error': return 'text-red-400';
|
||||
case 'error':
|
||||
case 'offline': return 'text-red-400';
|
||||
default: return 'text-slate-500';
|
||||
}
|
||||
}
|
||||
@@ -55,46 +66,112 @@ function statusBg(status: string): string {
|
||||
switch (status) {
|
||||
case 'online': return 'bg-emerald-400';
|
||||
case 'warning': return 'bg-amber-400';
|
||||
case 'error': return 'bg-red-400';
|
||||
case 'error':
|
||||
case 'offline': return 'bg-red-400';
|
||||
default: return 'bg-slate-500';
|
||||
}
|
||||
}
|
||||
|
||||
export function MiddlewareMonitor() {
|
||||
const [targets, setTargets] = useState<TargetInfo[]>([]);
|
||||
const confirm = useConfirm();
|
||||
const [targets, setTargets] = useState<MwTarget[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<MwTarget | null>(null);
|
||||
const [testing, setTesting] = useState<number | null>(null);
|
||||
|
||||
const loadTargets = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const data = await metricsApi.getTargets({ type: 'middleware', page_size: 200 }) as TargetsResponse;
|
||||
const data = await middlewareMonitorApi.list() as MwListResponse;
|
||||
setTargets(data?.list || []);
|
||||
} catch (err) {
|
||||
setError((err as JsonData).message || '加载失败');
|
||||
const msg = (err as { message?: string }).message || '加载失败';
|
||||
setError(msg);
|
||||
setTargets([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadTargets(); }, []);
|
||||
useEffect(() => { loadTargets(); }, [loadTargets]);
|
||||
|
||||
// 按类型分组
|
||||
const redisTargets = targets.filter(t => t.type === 'redis' || t.name?.toLowerCase().includes('redis'));
|
||||
const mysqlTargets = targets.filter(t => t.type === 'mysql' || t.name?.toLowerCase().includes('mysql'));
|
||||
// 按子类型分组
|
||||
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');
|
||||
|
||||
// 聚合 Redis 指标 (取第一个在线实例)
|
||||
const redisTarget = redisTargets.find(t => t.status === 'online') || redisTargets[0];
|
||||
const redisMetrics = redisTarget?.latest_metrics || {};
|
||||
|
||||
// 聚合 MySQL 指标
|
||||
const mysqlTarget = mysqlTargets.find(t => t.status === 'online') || mysqlTargets[0];
|
||||
const mysqlMetrics = mysqlTarget?.latest_metrics || {};
|
||||
|
||||
const handleDelete = async (target: MwTarget) => {
|
||||
const ok = await confirm({
|
||||
title: '删除中间件监控',
|
||||
description: `确认删除 "${target.name}"?相关指标历史也将清除。`,
|
||||
confirmText: '删除',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await middlewareMonitorApi.delete(target.id);
|
||||
toast.success('已删除');
|
||||
loadTargets();
|
||||
} catch (err) {
|
||||
toast.error('删除失败', { description: (err as { message?: string }).message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async (target: MwTarget) => {
|
||||
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();
|
||||
} catch (err) {
|
||||
toast.error('测试失败', { description: (err as { message?: string }).message });
|
||||
} finally {
|
||||
setTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCollectNow = async () => {
|
||||
try {
|
||||
const res = await middlewareMonitorApi.collectNow() as { collected: number; message: string };
|
||||
toast.success('采集完成', { description: res.message });
|
||||
loadTargets();
|
||||
} catch (err) {
|
||||
toast.error('采集失败', { description: (err as { message?: string }).message });
|
||||
}
|
||||
};
|
||||
|
||||
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 gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleCollectNow} className="gap-1.5">
|
||||
<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>
|
||||
<Button size="sm" onClick={() => { setEditTarget(null); setDialogOpen(true); }} 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 text-slate-500 mr-2" />
|
||||
@@ -107,7 +184,7 @@ export function MiddlewareMonitor() {
|
||||
<CardContent className="p-6 text-center">
|
||||
<Activity className="h-8 w-8 text-red-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-2">请确认后端服务可用且已配置中间件采集</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-2">请确认后端服务可用</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -117,133 +194,134 @@ export function MiddlewareMonitor() {
|
||||
<CardContent className="p-12 text-center">
|
||||
<Database className="h-12 w-12 text-slate-600 mx-auto mb-4" />
|
||||
<p className="text-sm text-[var(--dg-text-muted)]">暂无中间件监控目标</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-2">需在 Agent 配置 Redis / MySQL 连接信息后启用采集</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 卡片 */}
|
||||
<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 text-red-400" />
|
||||
<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})
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!redisTarget ? (
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mb-4">需在 Agent 配置 Redis 连接地址和密码后启用采集。</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 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, 0)}
|
||||
/>
|
||||
{/* 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 text-red-400" />
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{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 卡片 */}
|
||||
<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 text-blue-400" />
|
||||
<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})
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!mysqlTarget ? (
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mb-4">需在 Agent 配置 MySQL 连接地址、用户名和密码后启用采集。</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<MetricTile
|
||||
label="连接数"
|
||||
value={fmtVal(mysqlMetrics.mysql_connections)}
|
||||
/>
|
||||
<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)}
|
||||
/>
|
||||
{/* 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 text-blue-400" />
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{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">
|
||||
<Activity className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<Settings2 className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<h3 className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
中间件实例({targets.length})
|
||||
监控目标列表({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} />
|
||||
<InstanceCard
|
||||
key={target.id}
|
||||
target={target}
|
||||
onEdit={() => { setEditTarget(target); setDialogOpen(true); }}
|
||||
onDelete={() => handleDelete(target)}
|
||||
onTest={() => handleTest(target)}
|
||||
testing={testing === target.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 其他中间件类型的扩展指标 */}
|
||||
{targets.filter(t => !redisTargets.includes(t) && !mysqlTargets.includes(t)).length > 0 && (
|
||||
{/* 其他类型 */}
|
||||
{otherTargets.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{targets
|
||||
.filter(t => !redisTargets.includes(t) && !mysqlTargets.includes(t))
|
||||
.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.type}</h3>
|
||||
<Badge variant="outline" className="text-[10px] border-[var(--dg-border)] text-[var(--dg-text-muted)]">
|
||||
{target.name} ({target.ip})
|
||||
</Badge>
|
||||
{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)} />
|
||||
))}
|
||||
</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)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">暂无指标数据</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
) : (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">暂无指标数据</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加/编辑弹框 */}
|
||||
<MiddlewareDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
editTarget={editTarget}
|
||||
onSuccess={loadTargets}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -258,8 +336,16 @@ function MetricTile({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 实例状态卡片 */
|
||||
function InstanceCard({ target }: { target: TargetInfo }) {
|
||||
/** 实例卡片 */
|
||||
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;
|
||||
|
||||
@@ -278,13 +364,13 @@ function InstanceCard({ target }: { target: TargetInfo }) {
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-2">
|
||||
{target.ip} · {target.type}
|
||||
{target.ip}:{target.port} · {target.description}
|
||||
</div>
|
||||
{metricCount > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<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}</span>
|
||||
<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>
|
||||
@@ -295,8 +381,210 @@ function InstanceCard({ target }: { target: TargetInfo }) {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">暂无指标</p>
|
||||
<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 text-red-400 hover:text-red-300" 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>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user