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:
attacker committed 2026-06-29 23:24:05 +08:00
1 parent 186ece6495
commit ed7a5c07a7
8 files changed
+1211 -132

No files matched your search

+1 -1
View File
@@ -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';
+14
View File
@@ -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 }),
+418 -130
View File
@@ -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>
);
}