- SchemaSidebar: MySQL 表列表加载改为 6 行骨架屏 animate-pulse - SchemaSidebar: Redis key 列表加载改为 6 行骨架屏 animate-pulse - RedisDetailPage: key 表格加载时显示 5 行骨架屏 + 加载中提示 - 替换原来的纯文字/spinner,视觉上更流畅
310 lines
19 KiB
TypeScript
310 lines
19 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { useParams, useNavigate } from 'react-router-dom';
|
||
import { ArrowLeft, Key, Activity, Terminal, Gauge, Plus, RefreshCw, Search } from 'lucide-react';
|
||
import { redisApi } from '@/api/redis';
|
||
|
||
type TabKey = 'keys' | 'monitor' | 'cli' | 'perf';
|
||
|
||
export default function RedisDetailPage() {
|
||
var params = useParams<{ id: string }>();
|
||
var navigate = useNavigate();
|
||
var instanceId = Number(params.id);
|
||
|
||
var [info, setInfo] = useState<Record<string, any>>({});
|
||
var [activeTab, setActiveTab] = useState<TabKey>('keys');
|
||
var [selectedDb, setSelectedDb] = useState(0);
|
||
var [keyPattern, setKeyPattern] = useState('*');
|
||
var [keyType, setKeyType] = useState('');
|
||
var [keys, setKeys] = useState<any[]>([]);
|
||
var [selectedKey, setSelectedKey] = useState<any>(null);
|
||
var [showAddKey, setShowAddKey] = useState(false);
|
||
var [newKey, setNewKey] = useState({ key: '', value: '', type: 'string', ttl: -1 });
|
||
var [cmdInput, setCmdInput] = useState('');
|
||
var [cmdOutput, setCmdOutput] = useState<string[]>([]);
|
||
var [slowlog, setSlowlog] = useState<any[]>([]);
|
||
var [clients, setClients] = useState<any[]>([]);
|
||
var [loading, setLoading] = useState(false);
|
||
|
||
var tabs: { key: TabKey; label: string; icon: React.ElementType }[] = [
|
||
{ key: 'keys', label: '键管理', icon: Key },
|
||
{ key: 'monitor', label: '监控信息', icon: Activity },
|
||
{ key: 'cli', label: '命令行', icon: Terminal },
|
||
{ key: 'perf', label: '性能分析', icon: Gauge },
|
||
];
|
||
|
||
useEffect(function () {
|
||
redisApi.getInfo(instanceId).then(function (res: any) {
|
||
setInfo(res.info || res || {});
|
||
}).catch(function () { setInfo({}); });
|
||
}, [instanceId]);
|
||
|
||
var fetchKeys = function () {
|
||
setLoading(true);
|
||
redisApi.getKeys(instanceId, { db: selectedDb, pattern: keyPattern, type: keyType || undefined, count: 50 }).then(function (res: any) {
|
||
setKeys(res.keys || res || []);
|
||
}).catch(function () { setKeys([]); }).finally(function () { setLoading(false); });
|
||
};
|
||
|
||
var handleAddKey = function () {
|
||
redisApi.createKey(instanceId, { db: selectedDb, key: newKey.key, value: newKey.value, type: newKey.type, ttl: newKey.ttl > 0 ? newKey.ttl : undefined }).then(function () {
|
||
setShowAddKey(false);
|
||
setNewKey({ key: '', value: '', type: 'string', ttl: -1 });
|
||
fetchKeys();
|
||
}).catch(function () {});
|
||
};
|
||
|
||
var handleDeleteKey = function (keyName: string) {
|
||
if (!confirm('确定删除键 ' + keyName + '?')) return;
|
||
redisApi.deleteKey(instanceId, keyName, selectedDb).then(function () { fetchKeys(); }).catch(function () {});
|
||
};
|
||
|
||
var handleCommand = function () {
|
||
if (!cmdInput.trim()) return;
|
||
var cmd = cmdInput;
|
||
setCmdInput('');
|
||
setCmdOutput(function (prev) { return prev.concat(['> ' + cmd]); });
|
||
redisApi.executeCommand(instanceId, { db: selectedDb, command: cmd }).then(function (res: any) {
|
||
setCmdOutput(function (prev) { return prev.concat([String(res.result || res.data || JSON.stringify(res))]); });
|
||
}).catch(function (e: any) {
|
||
setCmdOutput(function (prev) { return prev.concat(['ERROR: ' + (e?.response?.data?.detail || '执行失败')]); });
|
||
});
|
||
};
|
||
|
||
useEffect(function () {
|
||
if (activeTab === 'perf') {
|
||
redisApi.getSlowlog(instanceId, 20).then(function (res: any) { setSlowlog(res.slowlog || res || []); }).catch(function () { setSlowlog([]); });
|
||
redisApi.getClients(instanceId).then(function (res: any) { setClients(res.clients || res || []); }).catch(function () { setClients([]); });
|
||
}
|
||
}, [activeTab, instanceId]);
|
||
|
||
var infoSections = Object.entries(info).filter(function (e) { return typeof e[1] === 'object' && e[1] !== null; });
|
||
var infoScalars = Object.entries(info).filter(function (e) { return typeof e[1] !== 'object'; });
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
{/* 头部 */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<button onClick={function () { navigate('/instances'); }} className="w-9 h-9 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]">
|
||
<ArrowLeft className="w-4 h-4" />
|
||
</button>
|
||
<div>
|
||
<h1 className="text-xl font-semibold">Redis 实例</h1>
|
||
<p className="text-sm text-[var(--dg-text-muted)]">#{instanceId}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 信息卡片 */}
|
||
{infoScalars.length > 0 && (
|
||
<div className="grid grid-cols-4 gap-3">
|
||
{infoScalars.slice(0, 4).map(function (e) {
|
||
return <div key={e[0]} className="dg-card p-3"><div className="text-lg font-bold">{String(e[1])}</div><div className="text-xs text-[var(--dg-text-muted)]">{e[0]}</div></div>;
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Tab */}
|
||
<div className="flex gap-0 border-b border-[var(--dg-border)]">
|
||
{tabs.map(function (tab) {
|
||
var Icon = tab.icon;
|
||
return <button key={tab.key} onClick={function () { setActiveTab(tab.key); }}
|
||
className={'flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px '
|
||
+ (activeTab === tab.key ? 'border-blue-500 text-blue-400' : 'border-transparent text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]')}>
|
||
<Icon className="w-4 h-4" />{tab.label}
|
||
</button>;
|
||
})}
|
||
</div>
|
||
|
||
{/* 键管理 */}
|
||
{activeTab === 'keys' && (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<select value={selectedDb} onChange={function (e) { setSelectedDb(Number(e.target.value)); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm w-24">
|
||
{Array.from({ length: 16 }, function (_, i) { return i; }).map(function (i) { return <option key={i} value={i}>DB {i}</option>; })}
|
||
</select>
|
||
<div className="relative flex-1 max-w-xs">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
||
<input value={keyPattern} onChange={function (e) { setKeyPattern(e.target.value); }} className="w-full pl-10 pr-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" placeholder="pattern, 如 user:*" />
|
||
</div>
|
||
<select value={keyType} onChange={function (e) { setKeyType(e.target.value); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
|
||
<option value="">全部类型</option>
|
||
<option value="string">String</option><option value="hash">Hash</option><option value="list">List</option><option value="set">Set</option><option value="zset">ZSet</option>
|
||
</select>
|
||
<button onClick={fetchKeys} disabled={loading} className="px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] disabled:opacity-50"><RefreshCw className="w-4 h-4" /></button>
|
||
<button onClick={function () { setShowAddKey(true); }} className="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm flex items-center gap-1"><Plus className="w-4 h-4" />新增</button>
|
||
</div>
|
||
|
||
{/* 键列表 */}
|
||
<div className="overflow-x-auto">
|
||
{loading ? (
|
||
<div className="space-y-2 p-4">
|
||
{Array.from({ length: 5 }, function (_, i) {
|
||
return <div key={i} className="flex items-center gap-4">
|
||
<div className="h-4 bg-[var(--dg-bg-tertiary)] rounded animate-pulse flex-1" />
|
||
<div className="h-4 bg-[var(--dg-bg-tertiary)] rounded animate-pulse w-16" />
|
||
<div className="h-4 bg-[var(--dg-bg-tertiary)] rounded animate-pulse w-12" />
|
||
<div className="h-4 bg-[var(--dg-bg-tertiary)] rounded animate-pulse w-20" />
|
||
</div>;
|
||
})}
|
||
<div className="text-center text-xs text-[var(--dg-text-muted)] pt-2">加载中...</div>
|
||
</div>
|
||
) : keys.length === 0 ? (
|
||
<div className="text-center py-8 text-[var(--dg-text-muted)]">点击刷新加载键列表</div>
|
||
) : (
|
||
<table className="w-full text-sm">
|
||
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||
<th className="px-4 py-2">键名</th><th className="px-4 py-2">类型</th><th className="px-4 py-2">TTL</th><th className="px-4 py-2">操作</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
{keys.map(function (k: any, i: number) {
|
||
return <tr key={i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
||
<td className="px-4 py-2 font-mono text-xs">{k.key || k.name || k}</td>
|
||
<td className="px-4 py-2">{k.type || '-'}</td>
|
||
<td className="px-4 py-2">{k.ttl != null ? k.ttl : '-'}</td>
|
||
<td className="px-4 py-2 flex gap-2">
|
||
<button onClick={function () {
|
||
redisApi.getKeyDetail(instanceId, k.key || k.name || k, selectedDb).then(function (res: any) { setSelectedKey(res); }).catch(function () {});
|
||
}} className="text-xs text-blue-400 hover:text-blue-300">查看</button>
|
||
<button onClick={function () { handleDeleteKey(k.key || k.name || k); }} className="text-xs text-red-400 hover:text-red-300">删除</button>
|
||
</td>
|
||
</tr>;
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 监控信息 */}
|
||
{activeTab === 'monitor' && (
|
||
<div className="space-y-4">
|
||
{infoSections.map(function (section) {
|
||
var entries = Object.entries(section[1] as Record<string, any>);
|
||
return (
|
||
<div key={section[0]} className="dg-card p-4">
|
||
<h3 className="text-sm font-semibold mb-3">{section[0]}</h3>
|
||
<div className="grid grid-cols-3 gap-2">
|
||
{entries.map(function (e) {
|
||
return <div key={e[0]} className="flex justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] rounded text-xs"><span className="text-[var(--dg-text-muted)]">{e[0]}</span><span className="font-mono">{String(e[1])}</span></div>;
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* 命令行 */}
|
||
{activeTab === 'cli' && (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center gap-3">
|
||
<select value={selectedDb} onChange={function (e) { setSelectedDb(Number(e.target.value)); }} className="px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm w-24">
|
||
{Array.from({ length: 16 }, function (_, i) { return i; }).map(function (i) { return <option key={i} value={i}>DB {i}</option>; })}
|
||
</select>
|
||
<input value={cmdInput} onChange={function (e) { setCmdInput(e.target.value); }}
|
||
onKeyDown={function (e) { if (e.key === 'Enter') handleCommand(); }}
|
||
className="flex-1 px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono" placeholder="输入 Redis 命令..." />
|
||
<button onClick={handleCommand} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">执行</button>
|
||
</div>
|
||
<div className="dg-card p-4 min-h-[300px] max-h-[400px] overflow-y-auto font-mono text-xs space-y-1">
|
||
{cmdOutput.length === 0 && <div className="text-[var(--dg-text-muted)]">等待输入命令...</div>}
|
||
{cmdOutput.map(function (line, i) {
|
||
return <div key={i} className={line.startsWith('>') ? 'text-green-400' : line.startsWith('ERROR') ? 'text-red-400' : 'text-[var(--dg-text-secondary)]'}>{line}</div>;
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 性能分析 */}
|
||
{activeTab === 'perf' && (
|
||
<div className="space-y-4">
|
||
<div className="dg-card p-4">
|
||
<h3 className="text-sm font-semibold mb-3">慢日志</h3>
|
||
<table className="w-full text-sm">
|
||
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||
<th className="px-4 py-2">命令</th><th className="px-4 py-2">耗时(μs)</th><th className="px-4 py-2">时间</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
{slowlog.map(function (s: any, i: number) {
|
||
return <tr key={i} className="border-b border-[var(--dg-border)]">
|
||
<td className="px-4 py-2 font-mono text-xs">{Array.isArray(s.command) ? s.command.join(' ') : s.command || '-'}</td>
|
||
<td className="px-4 py-2">{s.duration || s.execution_time || '-'}</td>
|
||
<td className="px-4 py-2">{s.timestamp || '-'}</td>
|
||
</tr>;
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
{slowlog.length === 0 && <div className="text-center py-4 text-[var(--dg-text-muted)] text-xs">暂无慢日志</div>}
|
||
</div>
|
||
<div className="dg-card p-4">
|
||
<h3 className="text-sm font-semibold mb-3">客户端列表</h3>
|
||
<table className="w-full text-sm">
|
||
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
|
||
<th className="px-4 py-2">ID</th><th className="px-4 py-2">地址</th><th className="px-4 py-2">DB</th><th className="px-4 py-2">空闲(s)</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
{clients.map(function (c: any, i: number) {
|
||
return <tr key={i} className="border-b border-[var(--dg-border)]">
|
||
<td className="px-4 py-2">{c.id || c.fd || '-'}</td>
|
||
<td className="px-4 py-2">{c.addr || c.host || '-'}</td>
|
||
<td className="px-4 py-2">{c.db || '-'}</td>
|
||
<td className="px-4 py-2">{c.idle || '-'}</td>
|
||
</tr>;
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 键详情弹窗 */}
|
||
{selectedKey && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||
<div className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-lg max-h-[70vh] overflow-y-auto p-6" onClick={function (e) { e.stopPropagation(); }}>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="font-semibold">键详情</h3>
|
||
<button onClick={function () { setSelectedKey(null); }} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">✕</button>
|
||
</div>
|
||
<div className="space-y-3">
|
||
<div className="flex gap-2 text-sm"><span className="text-[var(--dg-text-muted)]">键名:</span><span className="font-mono">{selectedKey.key || selectedKey.name}</span></div>
|
||
<div className="flex gap-2 text-sm"><span className="text-[var(--dg-text-muted)]">类型:</span><span>{selectedKey.type}</span></div>
|
||
<div className="flex gap-2 text-sm"><span className="text-[var(--dg-text-muted)]">TTL:</span><span>{selectedKey.ttl != null ? selectedKey.ttl + 's' : '-'}</span></div>
|
||
<div className="text-sm"><span className="text-[var(--dg-text-muted)]">值:</span>
|
||
<pre className="mt-1 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg text-xs font-mono max-h-60 overflow-y-auto">
|
||
{typeof selectedKey.value === 'object' ? JSON.stringify(selectedKey.value, null, 2) : String(selectedKey.value || '-')}
|
||
</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 新增键弹窗 */}
|
||
{showAddKey && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||
<div className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-md p-6" onClick={function (e) { e.stopPropagation(); }}>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="font-semibold">新增键</h3>
|
||
<button onClick={function () { setShowAddKey(false); }} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">✕</button>
|
||
</div>
|
||
<div className="space-y-3">
|
||
<div><label className="text-sm text-[var(--dg-text-muted)]">类型</label><select value={newKey.type} onChange={function (e) { setNewKey(Object.assign({}, newKey, { type: e.target.value })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm">
|
||
<option value="string">String</option><option value="hash">Hash</option><option value="list">List</option><option value="set">Set</option><option value="zset">ZSet</option>
|
||
</select></div>
|
||
<div><label className="text-sm text-[var(--dg-text-muted)]">键名</label><input value={newKey.key} onChange={function (e) { setNewKey(Object.assign({}, newKey, { key: e.target.value })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" placeholder="key" /></div>
|
||
<div><label className="text-sm text-[var(--dg-text-muted)]">值</label><textarea value={newKey.value} onChange={function (e) { setNewKey(Object.assign({}, newKey, { value: e.target.value })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono min-h-[80px]" placeholder="value" /></div>
|
||
<div><label className="text-sm text-[var(--dg-text-muted)]">TTL (秒, -1 永不过期)</label><input type="number" value={newKey.ttl} onChange={function (e) { setNewKey(Object.assign({}, newKey, { ttl: Number(e.target.value) })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" /></div>
|
||
</div>
|
||
<div className="flex justify-end gap-3 mt-4">
|
||
<button onClick={function () { setShowAddKey(false); }} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg text-sm">取消</button>
|
||
<button onClick={handleAddKey} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">添加</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|