- P0: 实例访问控制全线接入权限组 (829测试) - P1: permissions字段激活 + 操作级权限检查 (843测试) - 前端: 删除数据权限页面, 权限组合并为唯一入口 - 新增: CodeMirror编辑器 + 查询模板 + 慢查询分析 - 新增: MongoDB管理页面 + Dashboard增强 - 修复: Pydantic V2迁移 + 前端TS错误修复
387 lines
19 KiB
TypeScript
387 lines
19 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { ArrowLeft, Database, Table2, Terminal, BarChart3, RefreshCw, Search, ChevronRight, FileJson } from 'lucide-react';
|
|
import { mongoApi } from '@/api/mongo';
|
|
|
|
type TabKey = 'collections' | 'documents' | 'cli' | 'stats';
|
|
|
|
export default function MongoDBDetailPage() {
|
|
var params = useParams<{ id: string }>();
|
|
var navigate = useNavigate();
|
|
var instanceId = Number(params.id);
|
|
|
|
var [activeTab, setActiveTab] = useState<TabKey>('collections');
|
|
var [databases, setDatabases] = useState<any[]>([]);
|
|
var [selectedDb, setSelectedDb] = useState('');
|
|
var [collections, setCollections] = useState<any[]>([]);
|
|
var [selectedCollection, setSelectedCollection] = useState('');
|
|
var [collectionStats, setCollectionStats] = useState<any>(null);
|
|
var [documents, setDocuments] = useState<any[]>([]);
|
|
var [docFilter, setDocFilter] = useState('{}');
|
|
var [docCount, setDocCount] = useState(0);
|
|
var [docPage, setDocPage] = useState(0);
|
|
var [pageSize] = useState(20);
|
|
var [cmdInput, setCmdInput] = useState('');
|
|
var [cmdOutput, setCmdOutput] = useState<string[]>([]);
|
|
var [loading, setLoading] = useState(false);
|
|
var [error, setError] = useState('');
|
|
|
|
var tabs: { key: TabKey; label: string; icon: React.ElementType }[] = [
|
|
{ key: 'collections', label: '集合管理', icon: Database },
|
|
{ key: 'documents', label: '文档浏览', icon: Table2 },
|
|
{ key: 'cli', label: '查询控制台', icon: Terminal },
|
|
{ key: 'stats', label: '统计信息', icon: BarChart3 },
|
|
];
|
|
|
|
useEffect(function () {
|
|
setLoading(true);
|
|
mongoApi.getDatabases(instanceId).then(function (res: any) {
|
|
var dbs = res.databases || res || [];
|
|
setDatabases(Array.isArray(dbs) ? dbs : []);
|
|
if (dbs.length > 0 && !selectedDb) {
|
|
setSelectedDb(typeof dbs[0] === 'string' ? dbs[0] : (dbs[0].name || ''));
|
|
}
|
|
}).catch(function () { setDatabases([]); }).finally(function () { setLoading(false); });
|
|
}, [instanceId]);
|
|
|
|
useEffect(function () {
|
|
if (!selectedDb) { setCollections([]); return; }
|
|
setLoading(true);
|
|
mongoApi.getCollections(instanceId, selectedDb).then(function (res: any) {
|
|
var cols = res.collections || res || [];
|
|
setCollections(Array.isArray(cols) ? cols : []);
|
|
}).catch(function () { setCollections([]); }).finally(function () { setLoading(false); });
|
|
}, [instanceId, selectedDb]);
|
|
|
|
useEffect(function () {
|
|
if (!selectedCollection || !selectedDb) { setCollectionStats(null); return; }
|
|
mongoApi.getCollectionStats(instanceId, selectedCollection, selectedDb).then(function (res: any) {
|
|
setCollectionStats(res || null);
|
|
}).catch(function () { setCollectionStats(null); });
|
|
}, [instanceId, selectedDb, selectedCollection]);
|
|
|
|
var fetchDocuments = function () {
|
|
if (!selectedCollection || !selectedDb) return;
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
var filterObj = JSON.parse(docFilter);
|
|
mongoApi.query(instanceId, {
|
|
database: selectedDb,
|
|
collection: selectedCollection,
|
|
operation: 'find',
|
|
filter: filterObj,
|
|
limit: pageSize,
|
|
skip: docPage * pageSize,
|
|
}).then(function (res: any) {
|
|
var result = res.result || res;
|
|
setDocuments(Array.isArray(result) ? result : []);
|
|
setDocCount(Array.isArray(result) ? result.length : 0);
|
|
}).catch(function (err: any) {
|
|
setError(err.response ? err.response.data ? err.response.data.detail : '查询失败' : '查询失败');
|
|
setDocuments([]);
|
|
}).finally(function () { setLoading(false); });
|
|
} catch (e) {
|
|
setError('JSON 格式错误');
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
var handleCmdExecute = function () {
|
|
if (!cmdInput.trim()) return;
|
|
setCmdOutput(function (prev) { return [...prev, '> ' + cmdInput]; });
|
|
setLoading(true);
|
|
try {
|
|
var queryObj = JSON.parse(cmdInput);
|
|
mongoApi.query(instanceId, queryObj).then(function (res: any) {
|
|
var result = res.result || res;
|
|
setCmdOutput(function (prev) {
|
|
return [...prev, JSON.stringify(result, null, 2)];
|
|
});
|
|
}).catch(function (err: any) {
|
|
setCmdOutput(function (prev) {
|
|
return [...prev, '错误: ' + (err.response ? err.response.data ? err.response.data.detail : err.message : err.message)];
|
|
});
|
|
}).finally(function () { setLoading(false); });
|
|
} catch (e) {
|
|
setCmdOutput(function (prev) { return [...prev, '错误: JSON 格式不正确']; });
|
|
setLoading(false);
|
|
}
|
|
setCmdInput('');
|
|
};
|
|
|
|
var handleSelectCollection = function (col: string) {
|
|
setSelectedCollection(col);
|
|
setActiveTab('documents');
|
|
setDocPage(0);
|
|
setDocuments([]);
|
|
};
|
|
|
|
var formatSize = function (bytes: number) {
|
|
if (!bytes || bytes === 0) return '0 B';
|
|
var units = ['B', 'KB', 'MB', 'GB'];
|
|
var i = 0;
|
|
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
|
|
return bytes.toFixed(1) + ' ' + units[i];
|
|
};
|
|
|
|
return (
|
|
<div className="h-full flex flex-col">
|
|
<div className="flex items-center gap-3 px-5 py-3 border-b border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
|
|
<button onClick={function () { navigate('/instances'); }} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)]">
|
|
<ArrowLeft className="w-4 h-4" />
|
|
</button>
|
|
<FileJson className="w-5 h-5 text-[#66bb6a]" />
|
|
<h1 className="text-base font-semibold">MongoDB 实例 #{instanceId}</h1>
|
|
<span className="text-xs text-[var(--dg-text-muted)]">数据库管理</span>
|
|
<button onClick={function () { window.location.reload(); }} className="ml-auto p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)]" title="刷新">
|
|
<RefreshCw className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex border-b border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
|
|
{tabs.map(function (tab) {
|
|
var Icon = tab.icon;
|
|
var isActive = activeTab === tab.key;
|
|
return (
|
|
<button key={tab.key} onClick={function () { setActiveTab(tab.key); }}
|
|
className={'flex items-center gap-1.5 px-4 py-2.5 text-sm transition-colors border-b-2 '
|
|
+ (isActive ? 'border-[#66bb6a] text-[#66bb6a] font-medium' : 'border-transparent text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]')}>
|
|
<Icon className="w-4 h-4" />
|
|
{tab.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 px-5 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-primary)]">
|
|
<span className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">数据库:</span>
|
|
<select value={selectedDb} onChange={function (e) { setSelectedDb(e.target.value); setSelectedCollection(''); setCollections([]); }}
|
|
className="text-sm bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-2 py-1 text-[var(--dg-text-primary)]">
|
|
<option value="">选择数据库</option>
|
|
{databases.map(function (db: any) {
|
|
var name = typeof db === 'string' ? db : (db.name || '');
|
|
return <option key={name} value={name}>{name}</option>;
|
|
})}
|
|
</select>
|
|
{selectedDb && (
|
|
<span className="text-xs text-[var(--dg-text-muted)]">
|
|
{collections.length} 个集合
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto p-5">
|
|
{activeTab === 'collections' && (
|
|
<div>
|
|
{databases.length === 0 && !loading && (
|
|
<div className="text-center py-10 text-[var(--dg-text-muted)]">
|
|
<Database className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
|
<p className="text-sm">暂无数据库,请确认连接状态</p>
|
|
</div>
|
|
)}
|
|
{collections.length > 0 && (
|
|
<div className="border border-[var(--dg-border)] rounded-lg overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="bg-[var(--dg-bg-secondary)]">
|
|
<th className="text-left px-4 py-2.5 font-medium text-[var(--dg-text-muted)]">集合名称</th>
|
|
<th className="text-left px-4 py-2.5 font-medium text-[var(--dg-text-muted)]">类型</th>
|
|
<th className="text-right px-4 py-2.5 font-medium text-[var(--dg-text-muted)]">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{collections.map(function (col: any) {
|
|
var name = typeof col === 'string' ? col : (col.name || col.collectionName || '');
|
|
var colType = typeof col === 'string' ? 'collection' : (col.type || 'collection');
|
|
return (
|
|
<tr key={name} className="border-t border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
|
<td className="px-4 py-2.5">
|
|
<div className="flex items-center gap-2">
|
|
<Table2 className="w-4 h-4 text-[#66bb6a]" />
|
|
<span className="font-mono text-sm">{name}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-2.5">
|
|
<span className={'px-2 py-0.5 rounded text-xs font-medium '
|
|
+ (colType === 'view' ? 'bg-purple-500/10 text-purple-400' : 'bg-blue-500/10 text-blue-400')}>
|
|
{colType}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-2.5 text-right">
|
|
<button onClick={function () { handleSelectCollection(name); }}
|
|
className="text-blue-400 hover:text-blue-300 text-sm flex items-center gap-1 ml-auto">
|
|
查看文档 <ChevronRight className="w-3 h-3" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
{collections.length === 0 && selectedDb && !loading && (
|
|
<div className="text-center py-10 text-[var(--dg-text-muted)]">
|
|
<p className="text-sm">该数据库中暂无集合</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'documents' && (
|
|
<div>
|
|
{!selectedCollection ? (
|
|
<div className="text-center py-10 text-[var(--dg-text-muted)]">
|
|
<Table2 className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
|
<p className="text-sm">请从集合管理中选择一个集合</p>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<span className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">筛选:</span>
|
|
<input
|
|
value={docFilter}
|
|
onChange={function (e) { setDocFilter(e.target.value); }}
|
|
placeholder='{"field": "value"}'
|
|
className="flex-1 text-sm font-mono bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded px-3 py-1.5 text-[var(--dg-text-primary)]"
|
|
/>
|
|
<button onClick={fetchDocuments} disabled={loading}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-[#66bb6a] text-white rounded text-sm hover:bg-[#4caf50] disabled:opacity-50">
|
|
<Search className="w-3.5 h-3.5" /> 查询
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mb-3 text-xs text-[var(--dg-text-muted)]">
|
|
集合: <span className="text-[var(--dg-text-primary)] font-mono">{selectedCollection}</span>
|
|
{docCount > 0 && <span> | 返回 {docCount} 条文档</span>}
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-3 p-3 bg-red-500/10 text-red-400 rounded text-sm">{error}</div>
|
|
)}
|
|
|
|
{documents.length > 0 ? (
|
|
<div className="border border-[var(--dg-border)] rounded-lg overflow-auto max-h-[500px]">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-[var(--dg-bg-secondary)] sticky top-0">
|
|
<tr>
|
|
{Object.keys(documents[0] || {}).map(function (key) {
|
|
return <th key={key} className="text-left px-3 py-2 font-medium text-[var(--dg-text-muted)] whitespace-nowrap">{key}</th>;
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{documents.map(function (doc, idx) {
|
|
return (
|
|
<tr key={idx} className="border-t border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
|
{Object.keys(documents[0] || {}).map(function (key) {
|
|
var val = doc[key];
|
|
var display = typeof val === 'object' ? JSON.stringify(val) : String(val == null ? 'null' : val);
|
|
return <td key={key} className="px-3 py-2 font-mono text-xs max-w-[300px] truncate">{display}</td>;
|
|
})}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
!loading && !error && (
|
|
<div className="text-center py-6 text-[var(--dg-text-muted)] text-sm">
|
|
点击"查询"按钮加载文档
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{documents.length >= pageSize && (
|
|
<div className="flex items-center justify-center gap-2 mt-4">
|
|
<button onClick={function () { setDocPage(Math.max(0, docPage - 1)); fetchDocuments(); }}
|
|
disabled={docPage === 0} className="px-3 py-1 text-sm rounded border border-[var(--dg-border)] disabled:opacity-30 hover:bg-[var(--dg-bg-hover)]">
|
|
上一页
|
|
</button>
|
|
<span className="text-xs text-[var(--dg-text-muted)]">第 {docPage + 1} 页</span>
|
|
<button onClick={function () { setDocPage(docPage + 1); fetchDocuments(); }}
|
|
disabled={documents.length < pageSize} className="px-3 py-1 text-sm rounded border border-[var(--dg-border)] disabled:opacity-30 hover:bg-[var(--dg-bg-hover)]">
|
|
下一页
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'cli' && (
|
|
<div className="flex flex-col h-full">
|
|
<div className="mb-2 text-xs text-[var(--dg-text-muted)]">
|
|
输入 JSON 格式的查询命令(需含 collection、operation 字段)
|
|
</div>
|
|
<div className="flex-1 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg overflow-hidden flex flex-col">
|
|
<div className="flex-1 overflow-auto p-3 font-mono text-xs min-h-[300px] max-h-[400px]">
|
|
{cmdOutput.map(function (line, idx) {
|
|
return <pre key={idx} className="whitespace-pre-wrap break-all mb-2">{line}</pre>;
|
|
})}
|
|
{cmdOutput.length === 0 && <span className="text-[var(--dg-text-muted)]">等待输入查询命令...</span>}
|
|
</div>
|
|
<div className="border-t border-[var(--dg-border)] flex">
|
|
<input
|
|
value={cmdInput}
|
|
onChange={function (e) { setCmdInput(e.target.value); }}
|
|
onKeyDown={function (e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleCmdExecute(); } }}
|
|
placeholder='{"collection":"users", "operation":"find", "filter":{}}'
|
|
className="flex-1 bg-transparent px-3 py-2.5 font-mono text-sm text-[var(--dg-text-primary)] outline-none"
|
|
/>
|
|
<button onClick={handleCmdExecute} disabled={loading || !cmdInput.trim()}
|
|
className="px-4 text-[#66bb6a] hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 text-sm font-medium">
|
|
执行
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'stats' && (
|
|
<div>
|
|
{!selectedCollection ? (
|
|
<div className="text-center py-10 text-[var(--dg-text-muted)]">
|
|
<BarChart3 className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
|
<p className="text-sm">请从集合管理中选择一个集合查看统计</p>
|
|
</div>
|
|
) : collectionStats ? (
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="col-span-2 border border-[var(--dg-border)] rounded-lg p-4">
|
|
<h3 className="text-sm font-medium mb-3">集合: {selectedCollection}</h3>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{[
|
|
{ label: '命名空间', value: collectionStats.ns || '-' },
|
|
{ label: '文档数量', value: (collectionStats.count || 0).toLocaleString() },
|
|
{ label: '数据大小', value: formatSize(collectionStats.size || 0) },
|
|
{ label: '平均文档大小', value: formatSize(collectionStats.avgObjSize || 0) },
|
|
{ label: '索引大小', value: formatSize(collectionStats.totalIndexSize || 0) },
|
|
{ label: '索引数量', value: collectionStats.nindexes || 0 },
|
|
{ label: '存储大小', value: formatSize(collectionStats.storageSize || 0) },
|
|
{ label: '是否固定集合', value: collectionStats.capped ? '是' : '否' },
|
|
].map(function (item) {
|
|
return (
|
|
<div key={item.label} className="flex justify-between">
|
|
<span className="text-xs text-[var(--dg-text-muted)]">{item.label}</span>
|
|
<span className="text-xs font-mono text-[var(--dg-text-primary)]">{item.value}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : !loading ? (
|
|
<div className="text-center py-6 text-[var(--dg-text-muted)] text-sm">
|
|
无法获取统计信息
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|