feat: 多数据库类型支持 — Redis/MongoDB/PostgreSQL 数据库+表浏览
后端: - InstanceService.list_databases: Redis 通过 CONFIG GET databases 返回 db0-db15 - InstanceService.list_databases: MongoDB 通过 pymongo list_database_names 返回库列表 - InstanceService.list_tables: MongoDB 通过 list_collection_names 返回集合列表 - PostgreSQL 数据库/表列表已有支持 前端: - TicketCreatePage: 所有数据库类型统一走后端 listDatabases API - Redis 数据库改为下拉选择(db0-db15),替代手动输入 - 浏览面板: MySQL/PG 显示表结构+索引, MongoDB 显示集合, Redis 显示 Keys - 新增 renderCollectionsTab 用于 MongoDB 集合浏览 - isMySQL 重命名为 isSQL, 新增 isMongo 类型判断
This commit is contained in:
1 parent
fb167233bc
commit
ed13243bb0
2 files changed
+94
-20
No files matched your search
@@ -23,7 +23,7 @@ class InstanceService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def list_databases(instance: Instance) -> List[str]:
|
async def list_databases(instance: Instance) -> List[str]:
|
||||||
"""List databases for a MySQL instance."""
|
"""List databases for the given instance type."""
|
||||||
if instance.db_type == "mysql":
|
if instance.db_type == "mysql":
|
||||||
conn = await aiomysql.connect(
|
conn = await aiomysql.connect(
|
||||||
host=instance.host, port=instance.port,
|
host=instance.host, port=instance.port,
|
||||||
@@ -45,6 +45,31 @@ class InstanceService:
|
|||||||
return [r["datname"] for r in rows]
|
return [r["datname"] for r in rows]
|
||||||
finally:
|
finally:
|
||||||
await conn.close()
|
await conn.close()
|
||||||
|
elif instance.db_type == "redis":
|
||||||
|
from app.services.redis_connector import RedisConnector
|
||||||
|
conn = await RedisConnector.get_connection(instance)
|
||||||
|
try:
|
||||||
|
db_count = await conn.config_get("databases")
|
||||||
|
num = int(db_count.get("databases", 16))
|
||||||
|
return [f"db{i}" for i in range(num)]
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
elif instance.db_type == "mongo":
|
||||||
|
from pymongo import MongoClient
|
||||||
|
pwd = instance.password
|
||||||
|
if pwd and not pwd.startswith("$"):
|
||||||
|
from app.core.crypto import decrypt_password
|
||||||
|
try:
|
||||||
|
pwd = decrypt_password(pwd)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
uri = f"mongodb://{instance.username}:{pwd}@{instance.host}:{instance.port}/admin"
|
||||||
|
client = MongoClient(uri, serverSelectionTimeoutMS=5000)
|
||||||
|
try:
|
||||||
|
dbs = client.list_database_names()
|
||||||
|
return [d for d in dbs if d not in ("admin", "config", "local")]
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
elif instance.db_type == "sqlite":
|
elif instance.db_type == "sqlite":
|
||||||
return [instance.database or "main"]
|
return [instance.database or "main"]
|
||||||
else:
|
else:
|
||||||
@@ -80,6 +105,23 @@ class InstanceService:
|
|||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
finally:
|
finally:
|
||||||
await conn.close()
|
await conn.close()
|
||||||
|
elif instance.db_type == "mongo":
|
||||||
|
from pymongo import MongoClient
|
||||||
|
pwd = instance.password
|
||||||
|
if pwd and not pwd.startswith("$"):
|
||||||
|
from app.core.crypto import decrypt_password
|
||||||
|
try:
|
||||||
|
pwd = decrypt_password(pwd)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
uri = f"mongodb://{instance.username}:{pwd}@{instance.host}:{instance.port}/{database}"
|
||||||
|
client = MongoClient(uri, serverSelectionTimeoutMS=5000)
|
||||||
|
try:
|
||||||
|
db_obj = client[database]
|
||||||
|
colls = db_obj.list_collection_names()
|
||||||
|
return [{"name": c} for c in colls]
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
elif instance.db_type == "sqlite":
|
elif instance.db_type == "sqlite":
|
||||||
import sqlite3
|
import sqlite3
|
||||||
db_path = instance.host
|
db_path = instance.host
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ export default function TicketCreatePage() {
|
|||||||
const [loadingRedisDetail, setLoadingRedisDetail] = useState(false);
|
const [loadingRedisDetail, setLoadingRedisDetail] = useState(false);
|
||||||
|
|
||||||
// -- Browser Tab --
|
// -- Browser Tab --
|
||||||
const [browserTab, setBrowserTab] = useState<'tables' | 'indexes' | 'redis'>('tables');
|
const [browserTab, setBrowserTab] = useState<'tables' | 'indexes' | 'redis' | 'collections'>('tables');
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
/* Fetch instances */
|
/* Fetch instances */
|
||||||
@@ -227,17 +227,17 @@ export default function TicketCreatePage() {
|
|||||||
setDatabases([]);
|
setDatabases([]);
|
||||||
setTables([]);
|
setTables([]);
|
||||||
const inst = instances.find(i => i.id === Number(instanceId));
|
const inst = instances.find(i => i.id === Number(instanceId));
|
||||||
if (inst?.db_type === 'redis') {
|
if (!inst) return;
|
||||||
setDatabase('db0');
|
// 所有类型都走后端 listDatabases API(Redis 返回 db0-db15, MongoDB 返回库名列表)
|
||||||
setDatabases(['db0']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoadingDatabases(true);
|
setLoadingDatabases(true);
|
||||||
instanceApi.listDatabases(Number(instanceId))
|
instanceApi.listDatabases(Number(instanceId))
|
||||||
.then((res: unknown) => {
|
.then((res: unknown) => {
|
||||||
const data = (res as { databases?: string[] })?.databases || (res as string[]) || [];
|
const data = (res as { databases?: string[] })?.databases || (res as string[]) || [];
|
||||||
setDatabases(Array.isArray(data) ? data as string[] : []);
|
const list = Array.isArray(data) ? data as string[] : [];
|
||||||
if (Array.isArray(data) && data.length > 0) setDatabase(String(data[0]));
|
setDatabases(list);
|
||||||
|
if (list.length > 0) {
|
||||||
|
setDatabase(inst.db_type === 'redis' ? 'db0' : String(list[0]));
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => setDatabases([]))
|
.catch(() => setDatabases([]))
|
||||||
.finally(() => setLoadingDatabases(false));
|
.finally(() => setLoadingDatabases(false));
|
||||||
@@ -250,7 +250,10 @@ export default function TicketCreatePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId || !database) { setTables([]); return; }
|
if (!instanceId || !database) { setTables([]); return; }
|
||||||
const inst = instances.find(i => i.id === Number(instanceId));
|
const inst = instances.find(i => i.id === Number(instanceId));
|
||||||
if (!inst || inst.db_type !== 'mysql') return;
|
if (!inst) return;
|
||||||
|
// Redis 无表概念,跳过
|
||||||
|
if (inst.db_type === 'redis') { setTables([]); return; }
|
||||||
|
// MySQL / PG / MongoDB 都走 listTables API
|
||||||
setLoadingTables(true);
|
setLoadingTables(true);
|
||||||
instanceDetailApi.listTables(Number(instanceId), database)
|
instanceDetailApi.listTables(Number(instanceId), database)
|
||||||
.then((data) => setTables(Array.isArray(data) ? data : []))
|
.then((data) => setTables(Array.isArray(data) ? data : []))
|
||||||
@@ -390,7 +393,8 @@ export default function TicketCreatePage() {
|
|||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
const isRedis = selectedInstance?.db_type === 'redis';
|
const isRedis = selectedInstance?.db_type === 'redis';
|
||||||
const isMySQL = selectedInstance?.db_type === 'mysql' || selectedInstance?.db_type === 'postgresql' || selectedInstance?.db_type === 'postgres';
|
const isSQL = selectedInstance?.db_type === 'mysql' || selectedInstance?.db_type === 'postgresql' || selectedInstance?.db_type === 'postgres';
|
||||||
|
const isMongo = selectedInstance?.db_type === 'mongo';
|
||||||
|
|
||||||
const riskLevel = useMemo(() => {
|
const riskLevel = useMemo(() => {
|
||||||
if (!auditResult) return '';
|
if (!auditResult) return '';
|
||||||
@@ -450,11 +454,14 @@ export default function TicketCreatePage() {
|
|||||||
if (!instanceId || !database) return null;
|
if (!instanceId || !database) return null;
|
||||||
|
|
||||||
// Determine available tabs
|
// Determine available tabs
|
||||||
const tabs: { id: 'tables' | 'indexes' | 'redis'; label: string; icon: React.ElementType }[] = [];
|
const tabs: { id: 'tables' | 'indexes' | 'redis' | 'collections'; label: string; icon: React.ElementType }[] = [];
|
||||||
if (isMySQL) {
|
if (isSQL) {
|
||||||
tabs.push({ id: 'tables', label: '表结构', icon: Table2 });
|
tabs.push({ id: 'tables', label: '表结构', icon: Table2 });
|
||||||
tabs.push({ id: 'indexes', label: '索引', icon: BarChart3 });
|
tabs.push({ id: 'indexes', label: '索引', icon: BarChart3 });
|
||||||
}
|
}
|
||||||
|
if (isMongo) {
|
||||||
|
tabs.push({ id: 'collections', label: '集合', icon: Table2 });
|
||||||
|
}
|
||||||
if (isRedis) {
|
if (isRedis) {
|
||||||
tabs.push({ id: 'redis', label: 'Redis Keys', icon: KeyRound });
|
tabs.push({ id: 'redis', label: 'Redis Keys', icon: KeyRound });
|
||||||
}
|
}
|
||||||
@@ -481,11 +488,12 @@ export default function TicketCreatePage() {
|
|||||||
<span className="ml-auto text-[10px] text-[var(--dg-text-muted)] px-3">点击插入到 SQL</span>
|
<span className="ml-auto text-[10px] text-[var(--dg-text-muted)] px-3">点击插入到 SQL</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Content */}
|
{/* Tab content */}
|
||||||
<div className="max-h-[320px] overflow-y-auto">
|
<div className="max-h-[320px] overflow-y-auto">
|
||||||
{browserTab === 'tables' && renderTablesTab()}
|
{browserTab === 'tables' && renderTablesTab()}
|
||||||
{browserTab === 'indexes' && renderIndexesTab()}
|
{browserTab === 'indexes' && renderIndexesTab()}
|
||||||
{browserTab === 'redis' && renderRedisTab()}
|
{browserTab === 'redis' && renderRedisTab()}
|
||||||
|
{browserTab === 'collections' && renderCollectionsTab()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -721,6 +729,31 @@ export default function TicketCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* ---- Collections Tab (MongoDB) ---- */
|
||||||
|
const renderCollectionsTab = () => {
|
||||||
|
if (loadingTables) return (
|
||||||
|
<div className="flex items-center gap-2 p-4 text-xs text-[var(--dg-text-muted)]">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" /> 加载集合列表...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (tables.length === 0) return (
|
||||||
|
<div className="text-xs text-[var(--dg-text-muted)] text-center py-4">无集合</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className="p-2 space-y-0.5">
|
||||||
|
{tables.map((t) => (
|
||||||
|
<button key={t.name}
|
||||||
|
onClick={() => setSqlContent(prev => prev + (prev && !prev.endsWith('\n') ? '\n' : '') + `db.${t.name}.find()`)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm text-left hover:bg-[var(--dg-bg-hover)] transition-colors group">
|
||||||
|
<Table2 className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
|
||||||
|
<span className="text-[var(--dg-text-primary)] truncate">{t.name}</span>
|
||||||
|
<span className="ml-auto text-[10px] text-[var(--dg-text-muted)] opacity-0 group-hover:opacity-100 transition-opacity">插入</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/* ================================================================== */
|
/* ================================================================== */
|
||||||
/* STEP 1: Fill Info */
|
/* STEP 1: Fill Info */
|
||||||
/* ================================================================== */
|
/* ================================================================== */
|
||||||
@@ -765,12 +798,11 @@ export default function TicketCreatePage() {
|
|||||||
数据库 <span className="text-red-400">*</span>
|
数据库 <span className="text-red-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
{isRedis ? (
|
{isRedis ? (
|
||||||
<div className="flex items-center gap-2">
|
<select value={database} onChange={(e) => setDatabase(e.target.value)}
|
||||||
<input value={database} onChange={(e) => setDatabase(e.target.value)}
|
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors">
|
||||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors"
|
{databases.length > 0 ? databases.map((db) => <option key={db} value={db}>{db}</option>)
|
||||||
placeholder="db0" />
|
: <option value="db0">db0</option>}
|
||||||
<span className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">Redis</span>
|
</select>
|
||||||
</div>
|
|
||||||
) : databases.length > 0 ? (
|
) : databases.length > 0 ? (
|
||||||
<select value={database} onChange={(e) => setDatabase(e.target.value)}
|
<select value={database} onChange={(e) => setDatabase(e.target.value)}
|
||||||
disabled={loadingDatabases}
|
disabled={loadingDatabases}
|
||||||
|
|||||||
Reference in new issue
Block a user