feat: TicketCreatePage tab化 + SchemaSidebar + Redis快速搜索 + Dashboard导航修复
TicketCreatePage 重构: - SQL编辑器右侧新增数据库浏览器面板(tab化) - 表结构Tab: 表列表→字段展开(PK/索引标记)→点击插入SQL - 索引Tab: 选中表的索引详情(索引名/类型/字段/基数) - Redis Keys Tab: DB选择器+Key模式搜索+Key列表+类型/TTL/值查看 SchemaSidebar 新组件(365行): - MySQL/PostgreSQL: 左侧数据库表树→字段→点击插入SQL - Redis: Key树(DB选择→模式搜索→Key值查看, 类似Another Redis Desktop Manager) - 用于 QueriesPage 查询页面 Dashboard: - 快捷按钮改用 useNavigate + onClick 替代 <a href> API增强: - redis.ts: 新增 getKeys/createKey/deleteKey 方法 - instanceDetail.ts: 导出 TableMeta/TableDetail/ColumnInfo/IndexInfo 类型接口
This commit is contained in:
1 parent
8471f368eb
commit
0fb947dcb5
6 files changed
+1020
-476
No files matched your search
@@ -1,48 +1,83 @@
|
|||||||
import request from './request';
|
import request from './request';
|
||||||
|
|
||||||
|
/** 实例详情 API — 数据库/表/字段/索引/Redis Key */
|
||||||
export const instanceDetailApi = {
|
export const instanceDetailApi = {
|
||||||
// Databases
|
/** 获取 MySQL 数据库列表 */
|
||||||
listDatabases: (id: number) =>
|
listDatabases: (instanceId: number) =>
|
||||||
request.get<any, string[]>('/instances/' + id + '/detail/databases'),
|
request.get<any, string[]>(`/instances/${instanceId}/detail/databases`),
|
||||||
|
|
||||||
// Tables
|
/** 获取表列表(含引擎、行数等) */
|
||||||
listTables: (id: number, database: string) =>
|
listTables: (instanceId: number, database: string) =>
|
||||||
request.get<any, any[]>('/instances/' + id + '/detail/tables', { params: { database: database } }),
|
request.get<any, TableMeta[]>(`/instances/${instanceId}/detail/tables`, {
|
||||||
|
params: { database },
|
||||||
|
}),
|
||||||
|
|
||||||
// Table Detail
|
/** 获取表详情(DDL + 字段 + 索引) */
|
||||||
getTableDetail: (id: number, database: string, table: string) =>
|
getTableDetail: (instanceId: number, database: string, table: string) =>
|
||||||
request.get<any, any>('/instances/' + id + '/detail/table-detail', { params: { database: database, table: table } }),
|
request.get<any, TableDetail>(`/instances/${instanceId}/detail/table-detail`, {
|
||||||
|
params: { database, table },
|
||||||
|
}),
|
||||||
|
|
||||||
// Users
|
/** SHOW PROCESSLIST */
|
||||||
listUsers: (id: number) =>
|
getProcesslist: (instanceId: number) =>
|
||||||
request.get<any, any[]>('/instances/' + id + '/detail/users'),
|
request.get<any, any[]>(`/instances/${instanceId}/detail/processlist`),
|
||||||
|
|
||||||
// Grants
|
/** SHOW GLOBAL STATUS */
|
||||||
getGrants: (id: number, user: string, host: string) =>
|
getStatus: (instanceId: number) =>
|
||||||
request.get<any, any>('/instances/' + id + '/detail/grants', { params: { user: user, host: host } }),
|
request.get<any, { key_metrics: Record<string, string>; all_status: any[] }>(
|
||||||
|
`/instances/${instanceId}/detail/status`
|
||||||
|
),
|
||||||
|
|
||||||
// Processlist
|
/** 终止进程 */
|
||||||
getProcesslist: (id: number) =>
|
killProcess: (instanceId: number, pid: number) =>
|
||||||
request.get<any, any[]>('/instances/' + id + '/detail/processlist'),
|
request.post<any, any>(`/instances/${instanceId}/detail/processlist/${pid}/kill`),
|
||||||
|
|
||||||
// Kill Process
|
/** 获取数据库用户列表 */
|
||||||
killProcess: (id: number, pid: number) =>
|
listUsers: (instanceId: number) =>
|
||||||
request.post<any, any>('/instances/' + id + '/detail/processlist/' + pid + '/kill'),
|
request.get<any, any[]>(`/instances/${instanceId}/detail/users`),
|
||||||
|
|
||||||
// Status
|
|
||||||
getStatus: (id: number) =>
|
|
||||||
request.get<any, any>('/instances/' + id + '/detail/status'),
|
|
||||||
|
|
||||||
// Variables
|
|
||||||
getVariables: (id: number) =>
|
|
||||||
request.get<any, any[]>('/instances/' + id + '/detail/variables'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const backupApi = {
|
/** 表元信息 */
|
||||||
list: (instanceId?: number) =>
|
export interface TableMeta {
|
||||||
request.get<any, any[]>('/backups', { params: instanceId ? { instance_id: instanceId } : {} }),
|
name: string;
|
||||||
create: (data: any) =>
|
engine: string;
|
||||||
request.post<any, any>('/backups', data),
|
rows: number;
|
||||||
get: (id: number) =>
|
data_length: number;
|
||||||
request.get<any, any>('/backups/' + id),
|
index_length: number;
|
||||||
};
|
comment: string;
|
||||||
|
collation: string;
|
||||||
|
create_time: string;
|
||||||
|
update_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表详情 */
|
||||||
|
export interface TableDetail {
|
||||||
|
ddl: string;
|
||||||
|
columns: ColumnInfo[];
|
||||||
|
indexes: IndexInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnInfo {
|
||||||
|
Field: string;
|
||||||
|
Type: string;
|
||||||
|
Null: string;
|
||||||
|
Key: string;
|
||||||
|
Default: string | null;
|
||||||
|
Extra: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndexInfo {
|
||||||
|
Table: string;
|
||||||
|
Non_unique: number;
|
||||||
|
Key_name: string;
|
||||||
|
Seq_in_index: number;
|
||||||
|
Column_name: string;
|
||||||
|
Collation: string;
|
||||||
|
Cardinality: number;
|
||||||
|
Sub_part: string | null;
|
||||||
|
Packed: string | null;
|
||||||
|
Null: string;
|
||||||
|
Index_type: string;
|
||||||
|
Comment: string;
|
||||||
|
Index_comment: string;
|
||||||
|
}
|
||||||
@@ -28,4 +28,16 @@ export const redisApi = {
|
|||||||
// 执行命令
|
// 执行命令
|
||||||
executeCommand: (instanceId: number, data: { db: number; command: string }) =>
|
executeCommand: (instanceId: number, data: { db: number; command: string }) =>
|
||||||
request.post<any, any>('/redis/' + instanceId + '/command', data),
|
request.post<any, any>('/redis/' + instanceId + '/command', data),
|
||||||
|
|
||||||
|
// 获取键列表(兼容旧接口)
|
||||||
|
getKeys: (instanceId: number, params?: { db?: number; pattern?: string; count?: number; type?: string; cursor?: number }) =>
|
||||||
|
request.get<any, any>('/redis/' + instanceId + '/keys', { params }),
|
||||||
|
|
||||||
|
// 创建键
|
||||||
|
createKey: (instanceId: number, data: { db?: number; key: string; type?: string; value: any; ttl?: number }) =>
|
||||||
|
request.post<any, any>('/redis/' + instanceId + '/keys', data),
|
||||||
|
|
||||||
|
// 删除键
|
||||||
|
deleteKey: (instanceId: number, key: string, db?: number) =>
|
||||||
|
request.delete<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), { params: { db: db ?? 0 } }),
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
ChevronDown, ChevronRight, Database, Table2, Columns3,
|
||||||
|
KeyRound, Search, Loader2, FolderOpen, Server,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail';
|
||||||
|
import { redisApi } from '@/api/redis';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
instanceId: string;
|
||||||
|
database: string;
|
||||||
|
dbType: string;
|
||||||
|
onInsert: (text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RedisKeyItem {
|
||||||
|
key: string;
|
||||||
|
type: string;
|
||||||
|
ttl: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function formatTTL(ttl: number): string {
|
||||||
|
if (ttl < 0) return '永不过期';
|
||||||
|
if (ttl < 60) return `${ttl}s`;
|
||||||
|
if (ttl < 3600) return `${Math.floor(ttl / 60)}m`;
|
||||||
|
if (ttl < 86400) return `${Math.floor(ttl / 3600)}h`;
|
||||||
|
return `${Math.floor(ttl / 86400)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================== */
|
||||||
|
/* COMPONENT */
|
||||||
|
/* ================================================================== */
|
||||||
|
|
||||||
|
export default function SchemaSidebar({ instanceId, database, dbType, onInsert }: Props) {
|
||||||
|
const isMySQL = dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql';
|
||||||
|
const isRedis = dbType === 'redis';
|
||||||
|
|
||||||
|
// -- MySQL State --
|
||||||
|
const [tables, setTables] = useState<TableMeta[]>([]);
|
||||||
|
const [loadingTables, setLoadingTables] = useState(false);
|
||||||
|
const [expandedTable, setExpandedTable] = useState<string | null>(null);
|
||||||
|
const [tableColumns, setTableColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||||
|
const [tableSearch, setTableSearch] = useState('');
|
||||||
|
|
||||||
|
// -- Redis State --
|
||||||
|
const [redisDb, setRedisDb] = useState(0);
|
||||||
|
const [redisPattern, setRedisPattern] = useState('*');
|
||||||
|
const [redisKeys, setRedisKeys] = useState<RedisKeyItem[]>([]);
|
||||||
|
const [loadingRedis, setLoadingRedis] = useState(false);
|
||||||
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||||
|
const [keyDetail, setKeyDetail] = useState<any>(null);
|
||||||
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
||||||
|
|
||||||
|
// -- Tab --
|
||||||
|
const [activeTab, setActiveTab] = useState<'tables' | 'redis'>('tables');
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Fetch tables */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId || !database || !isMySQL) { setTables([]); return; }
|
||||||
|
setLoadingTables(true);
|
||||||
|
instanceDetailApi.listTables(Number(instanceId), database)
|
||||||
|
.then((data) => setTables(Array.isArray(data) ? data : []))
|
||||||
|
.catch(() => setTables([]))
|
||||||
|
.finally(() => setLoadingTables(false));
|
||||||
|
}, [instanceId, database, isMySQL]);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Fetch columns on expand */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const handleExpandTable = useCallback(async (tableName: string) => {
|
||||||
|
if (expandedTable === tableName) { setExpandedTable(null); return; }
|
||||||
|
setExpandedTable(tableName);
|
||||||
|
setTableColumns([]);
|
||||||
|
setLoadingColumns(true);
|
||||||
|
try {
|
||||||
|
const detail = await instanceDetailApi.getTableDetail(Number(instanceId), database, tableName);
|
||||||
|
setTableColumns(detail?.columns || []);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
finally { setLoadingColumns(false); }
|
||||||
|
}, [expandedTable, instanceId, database]);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Fetch Redis keys */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const fetchRedisKeys = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setLoadingRedis(true);
|
||||||
|
try {
|
||||||
|
const res = await redisApi.scanKeys(Number(instanceId), {
|
||||||
|
db: redisDb, pattern: redisPattern || '*', count: 100,
|
||||||
|
});
|
||||||
|
const keys = (res?.keys || []).map((k: any) => ({
|
||||||
|
key: typeof k === 'string' ? k : k.key,
|
||||||
|
type: typeof k === 'string' ? 'string' : (k.type || 'string'),
|
||||||
|
ttl: k.ttl ?? -1,
|
||||||
|
}));
|
||||||
|
setRedisKeys(keys);
|
||||||
|
} catch { setRedisKeys([]); }
|
||||||
|
finally { setLoadingRedis(false); }
|
||||||
|
}, [instanceId, redisDb, redisPattern]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRedis && instanceId) fetchRedisKeys();
|
||||||
|
}, [isRedis, instanceId, fetchRedisKeys]);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Fetch Redis key detail */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const handleSelectKey = useCallback(async (key: string) => {
|
||||||
|
setSelectedKey(key);
|
||||||
|
setLoadingDetail(true);
|
||||||
|
try {
|
||||||
|
const detail = await redisApi.getKeyDetail(Number(instanceId), key, redisDb);
|
||||||
|
setKeyDetail(detail);
|
||||||
|
} catch { setKeyDetail(null); }
|
||||||
|
finally { setLoadingDetail(false); }
|
||||||
|
}, [instanceId, redisDb]);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
/* Filtered tables */
|
||||||
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const filteredTables = tables.filter(t =>
|
||||||
|
!tableSearch || t.name.toLowerCase().includes(tableSearch.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ================================================================== */
|
||||||
|
/* RENDER */
|
||||||
|
/* ================================================================== */
|
||||||
|
|
||||||
|
if (!instanceId) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||||
|
<FolderOpen className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||||
|
<span className="text-xs font-medium text-[var(--dg-text-muted)]">数据库导航</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<p className="text-xs text-[var(--dg-text-muted)] text-center px-4">请先选择实例</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col bg-[var(--dg-bg-secondary)]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||||
|
<Server className="w-3.5 h-3.5 text-[var(--dg-text-muted)]" />
|
||||||
|
<span className="text-xs font-medium truncate flex-1">{dbType.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
{(isMySQL || isRedis) && (
|
||||||
|
<div className="flex border-b border-[var(--dg-border)]">
|
||||||
|
{isMySQL && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('tables')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1 px-2 py-2 text-[11px] font-medium transition-all border-b-2 ${
|
||||||
|
activeTab === 'tables'
|
||||||
|
? 'text-blue-400 border-blue-400 bg-blue-500/5'
|
||||||
|
: 'text-[var(--dg-text-muted)] border-transparent hover:text-[var(--dg-text-secondary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Table2 className="w-3 h-3" /> 表
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isRedis && (
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('redis')}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-1 px-2 py-2 text-[11px] font-medium transition-all border-b-2 ${
|
||||||
|
activeTab === 'redis'
|
||||||
|
? 'text-blue-400 border-blue-400 bg-blue-500/5'
|
||||||
|
: 'text-[var(--dg-text-muted)] border-transparent hover:text-[var(--dg-text-secondary)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<KeyRound className="w-3 h-3" /> Keys
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{activeTab === 'tables' && isMySQL && (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="px-2 py-2 border-b border-[var(--dg-border-light)]">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||||
|
<input
|
||||||
|
value={tableSearch}
|
||||||
|
onChange={(e) => setTableSearch(e.target.value)}
|
||||||
|
className="w-full pl-7 pr-2 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
|
||||||
|
placeholder="搜索表..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table List */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{loadingTables ? (
|
||||||
|
<div className="flex items-center gap-2 p-3 text-xs text-[var(--dg-text-muted)]">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" /> 加载中...
|
||||||
|
</div>
|
||||||
|
) : filteredTables.length === 0 ? (
|
||||||
|
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无表</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-[var(--dg-border-light)]">
|
||||||
|
{filteredTables.map(t => (
|
||||||
|
<div key={t.name}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExpandTable(t.name)}
|
||||||
|
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs hover:bg-[var(--dg-bg-hover)] transition-colors group"
|
||||||
|
>
|
||||||
|
{expandedTable === t.name
|
||||||
|
? <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||||
|
: <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||||
|
}
|
||||||
|
<Table2 className="w-3 h-3 text-blue-400" />
|
||||||
|
<span
|
||||||
|
className="font-mono truncate flex-1 text-left hover:text-blue-400 transition-colors"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onInsert(t.name); }}
|
||||||
|
title="点击插入"
|
||||||
|
>
|
||||||
|
{t.name}
|
||||||
|
</span>
|
||||||
|
{t.rows !== undefined && (
|
||||||
|
<span className="text-[9px] text-[var(--dg-text-muted)]">{t.rows.toLocaleString()}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Columns */}
|
||||||
|
{expandedTable === t.name && (
|
||||||
|
<div className="ml-5 border-l border-[var(--dg-border-light)]">
|
||||||
|
{loadingColumns ? (
|
||||||
|
<div className="flex items-center gap-1 p-2 text-[10px] text-[var(--dg-text-muted)]">
|
||||||
|
<Loader2 className="w-2.5 h-2.5 animate-spin" /> ...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
tableColumns.map(col => (
|
||||||
|
<button
|
||||||
|
key={col.Field}
|
||||||
|
onClick={() => onInsert(col.Field)}
|
||||||
|
className="flex items-center gap-1.5 w-full px-2 py-1 text-[11px] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||||
|
title={`${col.Type} ${col.Null === 'NO' ? 'NOT NULL' : ''}`}
|
||||||
|
>
|
||||||
|
<Columns3 className="w-2.5 h-2.5 text-[var(--dg-text-muted)]" />
|
||||||
|
<span className="font-mono text-[var(--dg-text-secondary)] hover:text-blue-400">{col.Field}</span>
|
||||||
|
<span className="text-[9px] text-[var(--dg-text-muted)] ml-auto">{col.Type}</span>
|
||||||
|
{col.Key === 'PRI' && <span className="text-[8px] px-1 bg-yellow-500/10 text-yellow-400 rounded">PK</span>}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'redis' && isRedis && (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* DB + Search */}
|
||||||
|
<div className="px-2 py-2 border-b border-[var(--dg-border-light)] space-y-1.5">
|
||||||
|
<select
|
||||||
|
value={redisDb}
|
||||||
|
onChange={(e) => { setRedisDb(Number(e.target.value)); }}
|
||||||
|
className="w-full px-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
{Array.from({ length: 16 }, (_, i) => (
|
||||||
|
<option key={i} value={i}>DB{i}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||||
|
<input
|
||||||
|
value={redisPattern}
|
||||||
|
onChange={(e) => setRedisPattern(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && fetchRedisKeys()}
|
||||||
|
className="w-full pl-7 pr-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
|
||||||
|
placeholder="*"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={fetchRedisKeys}
|
||||||
|
disabled={loadingRedis}
|
||||||
|
className="w-full px-2 py-1 bg-blue-500/15 text-blue-400 rounded text-[11px] hover:bg-blue-500/25 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loadingRedis ? <Loader2 className="w-3 h-3 animate-spin mx-auto" /> : '扫描'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key List + Detail */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{redisKeys.length === 0 ? (
|
||||||
|
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">
|
||||||
|
{loadingRedis ? '扫描中...' : '无 Keys'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-[var(--dg-border-light)]">
|
||||||
|
{redisKeys.map(k => (
|
||||||
|
<div key={k.key}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelectKey(k.key)}
|
||||||
|
className={`flex items-center gap-1.5 w-full px-2 py-1.5 text-xs transition-colors ${
|
||||||
|
selectedKey === k.key ? 'bg-blue-500/10 text-blue-400' : 'hover:bg-[var(--dg-bg-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<KeyRound className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||||
|
<span className="font-mono truncate flex-1 text-left">{k.key}</span>
|
||||||
|
<span className="text-[9px] px-1 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">{k.type}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Key Detail */}
|
||||||
|
{selectedKey === k.key && (
|
||||||
|
<div className="px-2 pb-2">
|
||||||
|
{loadingDetail ? (
|
||||||
|
<div className="flex items-center gap-1 p-1 text-[10px] text-[var(--dg-text-muted)]">
|
||||||
|
<Loader2 className="w-2.5 h-2.5 animate-spin" /> ...
|
||||||
|
</div>
|
||||||
|
) : keyDetail ? (
|
||||||
|
<div className="p-2 bg-[var(--dg-bg-primary)] rounded border border-[var(--dg-border)] space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">{keyDetail.type}</span>
|
||||||
|
<span className="text-[10px] text-[var(--dg-text-muted)]">TTL: {formatTTL(keyDetail.ttl)}</span>
|
||||||
|
</div>
|
||||||
|
<pre className="text-[10px] font-mono text-[var(--dg-text-secondary)] overflow-auto max-h-[120px] whitespace-pre-wrap">
|
||||||
|
{typeof keyDetail.value === 'object'
|
||||||
|
? JSON.stringify(keyDetail.value, null, 2)
|
||||||
|
: String(keyDetail.value)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
ClipboardList, Clock, CheckCircle, AlertTriangle,
|
ClipboardList, Clock, CheckCircle, AlertTriangle,
|
||||||
Database, ChevronRight, X, AlertCircle, Info, Bell,
|
Database, ChevronRight, X, AlertCircle, Info, Bell,
|
||||||
@@ -125,6 +126,7 @@ export default function Dashboard() {
|
|||||||
const chartInstanceRef = useRef<echarts.ECharts | null>(null);
|
const chartInstanceRef = useRef<echarts.ECharts | null>(null);
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
|
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
|
||||||
const [readIds, setReadIds] = useState<Set<string>>(new Set());
|
const [readIds, setReadIds] = useState<Set<string>>(new Set());
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
@@ -338,9 +340,9 @@ export default function Dashboard() {
|
|||||||
}, [stats]);
|
}, [stats]);
|
||||||
|
|
||||||
const quickActions = [
|
const quickActions = [
|
||||||
{ label: '新建工单', icon: FileText, href: '/tickets', color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20' },
|
{ label: '新建工单', icon: FileText, onClick: () => navigate('/tickets/create'), color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20' },
|
||||||
{ label: '新建查询', icon: Search, href: '/queries', color: 'text-green-400', bg: 'bg-green-400/10', border: 'border-green-400/20' },
|
{ label: '新建查询', icon: Search, onClick: () => navigate('/queries'), color: 'text-green-400', bg: 'bg-green-400/10', border: 'border-green-400/20' },
|
||||||
{ label: '添加实例', icon: Database, href: '/instances', color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20' },
|
{ label: '添加实例', icon: Database, onClick: () => navigate('/instances'), color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TrendIcon = ({ trend }: { trend: 'up' | 'down' | 'neutral' }) => {
|
const TrendIcon = ({ trend }: { trend: 'up' | 'down' | 'neutral' }) => {
|
||||||
@@ -371,15 +373,15 @@ export default function Dashboard() {
|
|||||||
{/* ---- Quick Actions ---- */}
|
{/* ---- Quick Actions ---- */}
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
{quickActions.map(action => (
|
{quickActions.map(action => (
|
||||||
<a
|
<button
|
||||||
key={action.label}
|
key={action.label}
|
||||||
href={action.href}
|
onClick={action.onClick}
|
||||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border transition-all ${action.bg} ${action.color} ${action.border} hover:opacity-80`}
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border transition-all ${action.bg} ${action.color} ${action.border} hover:opacity-80`}
|
||||||
>
|
>
|
||||||
<action.icon className="w-4 h-4" />
|
<action.icon className="w-4 h-4" />
|
||||||
{action.label}
|
{action.label}
|
||||||
<ArrowUpRight className="w-3 h-3 opacity-50" />
|
<ArrowUpRight className="w-3 h-3 opacity-50" />
|
||||||
</a>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Plus, X, AlertTriangle, CheckCircle, Play, Loader2,
|
Plus, X, AlertTriangle, CheckCircle, Play, Loader2,
|
||||||
ChevronDown, Clock, FileText, Sparkles, Star,
|
ChevronDown, ChevronRight, Clock, FileText, Sparkles, Star,
|
||||||
StarOff, Download, Search, ArrowUpDown,
|
StarOff, Download, Search, ArrowUpDown, Database, Table2,
|
||||||
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose,
|
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, Columns3,
|
||||||
|
KeyRound, FolderOpen, Server, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useActionPermission } from '@/hooks/useAuth';
|
import { useActionPermission } from '@/hooks/useAuth';
|
||||||
@@ -12,7 +13,10 @@ import EmptyState from '@/components/EmptyState';
|
|||||||
import { queryApi } from '@/api/query';
|
import { queryApi } from '@/api/query';
|
||||||
import { instanceApi } from '@/api/instance';
|
import { instanceApi } from '@/api/instance';
|
||||||
import SqlEditor from '@/components/SqlEditor';
|
import SqlEditor from '@/components/SqlEditor';
|
||||||
|
import SchemaSidebar from '@/components/SchemaSidebar';
|
||||||
import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
|
import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
|
||||||
|
import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail';
|
||||||
|
import { redisApi } from '@/api/redis';
|
||||||
import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
|
import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -299,6 +303,7 @@ function NewQueryPanel({
|
|||||||
const [rowHeight] = useState(36);
|
const [rowHeight] = useState(36);
|
||||||
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
|
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 });
|
||||||
const resultContainerRef = useRef<HTMLDivElement>(null);
|
const resultContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [showSidebar, setShowSidebar] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
instanceApi.list().then(res => {
|
instanceApi.list().then(res => {
|
||||||
@@ -564,6 +569,20 @@ function NewQueryPanel({
|
|||||||
<FileText className="w-4 h-4 text-[var(--dg-text-secondary)]" />
|
<FileText className="w-4 h-4 text-[var(--dg-text-secondary)]" />
|
||||||
查询语句 <span className="text-red-400">*</span>
|
查询语句 <span className="text-red-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{instanceId && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSidebar(!showSidebar)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition-all ${
|
||||||
|
showSidebar
|
||||||
|
? 'bg-blue-500/15 text-blue-400 border-blue-500/30'
|
||||||
|
: 'text-[var(--dg-text-secondary)] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Database className="w-3.5 h-3.5" />
|
||||||
|
{showSidebar ? '隐藏导航' : '显示导航'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowTemplates(!showTemplates)}
|
onClick={() => setShowTemplates(!showTemplates)}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)] border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)] rounded-lg transition-all"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)] border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)] rounded-lg transition-all"
|
||||||
@@ -572,6 +591,7 @@ function NewQueryPanel({
|
|||||||
快捷模板 ({templates.length})
|
快捷模板 ({templates.length})
|
||||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${showTemplates ? 'rotate-180' : ''}`} />
|
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${showTemplates ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showTemplates && (
|
{showTemplates && (
|
||||||
@@ -626,9 +646,23 @@ function NewQueryPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CodeMirror Editor */}
|
{/* CodeMirror Editor + Schema Sidebar */}
|
||||||
<div className="dg-card overflow-hidden">
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-4">
|
||||||
<SqlEditor
|
{/* Schema Sidebar */}
|
||||||
|
{showSidebar && instanceId && (
|
||||||
|
<div className="lg:col-span-1 h-[280px]">
|
||||||
|
<SchemaSidebar
|
||||||
|
instanceId={instanceId}
|
||||||
|
database={database}
|
||||||
|
dbType={dbType}
|
||||||
|
onInsert={(text) => setSql(prev => prev + (prev.trim() ? ' ' : '') + text + ' ')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Editor */}
|
||||||
|
<div className={showSidebar && instanceId ? 'lg:col-span-3' : 'lg:col-span-4'}>
|
||||||
|
<div className="dg-card overflow-hidden">
|
||||||
|
<SqlEditor
|
||||||
value={sql}
|
value={sql}
|
||||||
onChange={setSql}
|
onChange={setSql}
|
||||||
placeholder={placeholderMap[dbType]}
|
placeholder={placeholderMap[dbType]}
|
||||||
@@ -655,6 +689,8 @@ function NewQueryPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Security notice */}
|
{/* Security notice */}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user