sync: 同步 sql-platform-pro 最新代码 — 权限体系安全修复 v0.8.1
- P0: 实例访问控制全线接入权限组 (829测试) - P1: permissions字段激活 + 操作级权限检查 (843测试) - 前端: 删除数据权限页面, 权限组合并为唯一入口 - 新增: CodeMirror编辑器 + 查询模板 + 慢查询分析 - 新增: MongoDB管理页面 + Dashboard增强 - 修复: Pydantic V2迁移 + 前端TS错误修复
This commit is contained in:
1 parent
6fcd9465cf
commit
4c9427e546
105 files changed
+23680
-471
No files matched your search
@@ -9,7 +9,6 @@ import TicketsPage from '@/pages/TicketsPage';
|
||||
import QueriesPage from '@/pages/QueriesPage';
|
||||
import RolesPage from '@/pages/RolesPage';
|
||||
import UsersPage from '@/pages/UsersPage';
|
||||
import DataPermissionPage from '@/pages/DataPermissionPage';
|
||||
import AuditRulesPage from '@/pages/AuditRulesPage';
|
||||
import WorkflowPage from '@/pages/WorkflowPage';
|
||||
import AutoTaskPage from '@/pages/AutoTaskPage';
|
||||
@@ -18,9 +17,11 @@ import AuditLogsPage from '@/pages/AuditLogsPage';
|
||||
import DataDictionaryPage from '@/pages/DataDictionaryPage';
|
||||
import InstanceDetailPage from '@/pages/InstanceDetailPage';
|
||||
import RedisDetailPage from '@/pages/RedisDetailPage';
|
||||
import MongoDBDetailPage from '@/pages/MongoDBDetailPage';
|
||||
import TicketCreatePage from '@/pages/TicketCreatePage';
|
||||
import TicketDetailPage from '@/pages/TicketDetailPage';
|
||||
import InstanceGroupPage from '@/pages/InstanceGroupPage';
|
||||
import SlowQueryPage from '@/pages/SlowQueryPage';
|
||||
|
||||
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
|
||||
function RouteGuard() {
|
||||
@@ -60,14 +61,15 @@ function AppRoutes() {
|
||||
<Route path="instance-groups" element={<InstanceGroupPage />} />
|
||||
<Route path="instances/:id" element={<InstanceDetailPage />} />
|
||||
<Route path="redis/:id" element={<RedisDetailPage />} />
|
||||
<Route path="mongo/:id" element={<MongoDBDetailPage />} />
|
||||
<Route path="data-dictionary" element={<DataDictionaryPage />} />
|
||||
<Route path="tickets" element={<TicketsPage />} />
|
||||
<Route path="tickets/create" element={<TicketCreatePage />} />
|
||||
<Route path="tickets/:id" element={<TicketDetailPage />} />
|
||||
<Route path="queries" element={<QueriesPage />} />
|
||||
<Route path="slow-query" element={<SlowQueryPage />} />
|
||||
<Route path="roles" element={<RolesPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="data-permission" element={<DataPermissionPage />} />
|
||||
<Route path="audit-rules" element={<AuditRulesPage />} />
|
||||
<Route path="audit-logs" element={<AuditLogsPage />} />
|
||||
<Route path="workflow" element={<WorkflowPage />} />
|
||||
@@ -92,4 +94,4 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
@@ -0,0 +1,24 @@
|
||||
import request from './request';
|
||||
|
||||
export var mongoApi = {
|
||||
getDatabases: function (id: number) {
|
||||
return request.get('/mongo/' + id + '/databases');
|
||||
},
|
||||
getCollections: function (id: number, database: string) {
|
||||
return request.get('/mongo/' + id + '/databases/' + encodeURIComponent(database) + '/collections');
|
||||
},
|
||||
getCollectionStats: function (id: number, collection: string, database?: string) {
|
||||
var params: Record<string, any> = {};
|
||||
if (database) { params.database = database; }
|
||||
return request.get('/mongo/' + id + '/collections/' + encodeURIComponent(collection) + '/stats', { params: params });
|
||||
},
|
||||
query: function (id: number, data: Record<string, any>) {
|
||||
return request.post('/mongo/' + id + '/query', data);
|
||||
},
|
||||
execute: function (id: number, data: Record<string, any>) {
|
||||
return request.post('/mongo/' + id + '/execute', data);
|
||||
},
|
||||
testConnection: function (id: number) {
|
||||
return request.post('/mongo/' + id + '/test-connection');
|
||||
},
|
||||
};
|
||||
@@ -10,16 +10,42 @@ request.interceptors.request.use((config) => {
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
console.log(
|
||||
`[API] ${config.method?.toUpperCase()} ${config.baseURL}${config.url}`,
|
||||
config.params ? 'params=' + JSON.stringify(config.params) : '',
|
||||
config.data ? 'data=' + JSON.stringify(config.data).substring(0, 200) : ''
|
||||
);
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
request.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(response) => {
|
||||
console.log(
|
||||
`[API] ${response.status} ${response.config.url}`,
|
||||
Array.isArray(response.data) ? `(${response.data.length} items)` : ''
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
const status = error.response?.status;
|
||||
const url = error.config?.url;
|
||||
const detail = error.response?.data?.detail;
|
||||
|
||||
if (status === 401) {
|
||||
console.warn(`[API] 401 Unauthorized: ${url} — redirecting to login`);
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
} else if (status === 500) {
|
||||
console.error(`[API] 500 Internal Error: ${url}`, detail || error.message);
|
||||
} else if (status === 422) {
|
||||
console.warn(`[API] 422 Validation Error: ${url}`, error.response?.data);
|
||||
} else {
|
||||
console.warn(`[API] ${status || 'NETWORK'} Error: ${url}`, detail || error.message);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import request from './request';
|
||||
|
||||
export const slowLogApi = {
|
||||
list: (instanceId: number) =>
|
||||
request.get<any, any>(`/slow-logs/instances/${instanceId}`),
|
||||
|
||||
collect: (instanceId: number, database: string = '', limit: number = 50) =>
|
||||
request.post<any, any>(`/slow-logs/instances/${instanceId}/collect`, null, {
|
||||
params: { database, limit },
|
||||
}),
|
||||
|
||||
report: (instanceId: number) =>
|
||||
request.get<any, any>(`/slow-logs/instances/${instanceId}/report`),
|
||||
|
||||
topQueries: () =>
|
||||
request.get<any, any>('/slow-logs/dashboard/top'),
|
||||
};
|
||||
@@ -0,0 +1,390 @@
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
import { EditorState, Compartment } from '@codemirror/state';
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { EditorView, keymap, lineNumbers, placeholder as cmPlaceholder } from '@codemirror/view';
|
||||
import { sql, SQLDialect } from '@codemirror/lang-sql';
|
||||
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import {
|
||||
syntaxHighlighting,
|
||||
defaultHighlightStyle,
|
||||
bracketMatching,
|
||||
} from '@codemirror/language';
|
||||
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
|
||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||
import { rectangularSelection } from '@codemirror/rectangular-selection';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SqlEditorProps {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
placeholder?: string;
|
||||
language?: 'sql' | 'redis' | 'mongodb';
|
||||
height?: string;
|
||||
readOnly?: boolean;
|
||||
onExecute?: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Language configs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const REDIS_KEYWORDS = [
|
||||
'GET', 'SET', 'KEYS', 'HGET', 'HSET', 'HGETALL', 'HDEL', 'HMSET',
|
||||
'ZADD', 'ZRANGE', 'ZREM', 'ZRANK', 'ZSCORE', 'ZCARD',
|
||||
'LPUSH', 'RPUSH', 'LPOP', 'RPOP', 'LRANGE', 'LLEN',
|
||||
'SADD', 'SMEMBERS', 'SREM', 'SCARD', 'SISMEMBER',
|
||||
'INFO', 'CLIENT', 'CONFIG', 'DEL', 'EXISTS', 'EXPIRE', 'TTL',
|
||||
'TYPE', 'RENAME', 'DBSIZE', 'FLUSHDB', 'FLUSHALL',
|
||||
'PING', 'ECHO', 'SELECT', 'MOVE', 'OBJECT', 'SORT',
|
||||
'MULTI', 'EXEC', 'DISCARD', 'WATCH', 'UNWATCH',
|
||||
'PUBLISH', 'SUBSCRIBE', 'UNSUBSCRIBE', 'PSUBSCRIBE',
|
||||
'SCAN', 'HSCAN', 'SSCAN', 'ZSCAN',
|
||||
'INCR', 'DECR', 'INCRBY', 'DECRBY', 'INCRBYFLOAT',
|
||||
'APPEND', 'STRLEN', 'MGET', 'MSET', 'SETNX', 'SETEX',
|
||||
'AUTH', 'QUIT', 'BGSAVE', 'SAVE', 'LASTSAVE',
|
||||
'SLOWLOG', 'MONITOR', 'DEBUG', 'SHUTDOWN', 'SLAVEOF', 'REPLICAOF',
|
||||
];
|
||||
|
||||
const MONGODB_KEYWORDS = [
|
||||
// CRUD
|
||||
'find', 'findOne', 'insertOne', 'insertMany', 'updateOne', 'updateMany',
|
||||
'deleteOne', 'deleteMany', 'replaceOne', 'findOneAndUpdate', 'findOneAndDelete',
|
||||
'findOneAndReplace', 'bulkWrite', 'aggregate', 'countDocuments', 'distinct',
|
||||
// Aggregation stages
|
||||
'match', 'group', 'project', 'sort', 'limit', 'skip', 'unwind', 'lookup',
|
||||
'addFields', 'set', 'unset', 'facet', 'bucket', 'bucketAuto', 'sortByCount',
|
||||
'graphLookup', 'out', 'merge', 'replaceRoot', 'sample', 'count',
|
||||
// Operators
|
||||
'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'and', 'or', 'not',
|
||||
'nor', 'exists', 'type', 'regex', 'expr', 'cond', 'switch', 'if', 'then',
|
||||
'else', 'let', 'map', 'filter', 'reduce', 'range', 'concatArrays',
|
||||
'avg', 'sum', 'min', 'max', 'push', 'addToSet', 'first', 'last',
|
||||
// Index / admin
|
||||
'createIndex', 'dropIndex', 'getIndexes', 'explain', 'hint',
|
||||
// Collection / DB
|
||||
'use', 'show', 'db', 'collection', 'collections', 'databases',
|
||||
// JS keywords commonly used
|
||||
'function', 'return', 'var', 'let', 'const', 'if', 'else', 'for', 'while',
|
||||
'true', 'false', 'null', 'undefined', 'new', 'this', 'typeof', 'instanceof',
|
||||
'ObjectId', 'Date', 'ISODate', 'NumberLong', 'NumberInt', 'NumberDecimal',
|
||||
'Regex', 'BinData', 'Timestamp', 'MinKey', 'MaxKey', 'Symbol', 'DBRef',
|
||||
];
|
||||
|
||||
function getSQLExtension(language: SqlEditorProps['language']): Extension {
|
||||
const lang = language ?? 'sql';
|
||||
|
||||
if (lang === 'redis') {
|
||||
const dialect = SQLDialect.define({
|
||||
keywords: REDIS_KEYWORDS.join(' '),
|
||||
});
|
||||
return sql({ dialect });
|
||||
}
|
||||
|
||||
if (lang === 'mongodb') {
|
||||
const dialect = SQLDialect.define({
|
||||
keywords: MONGODB_KEYWORDS.join(' '),
|
||||
});
|
||||
return sql({ dialect });
|
||||
}
|
||||
|
||||
return sql();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom theme to match app CSS variables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const appTheme = EditorView.theme({
|
||||
'&': {
|
||||
fontSize: '13px',
|
||||
backgroundColor: 'var(--dg-bg-primary)',
|
||||
color: 'var(--dg-text-primary)',
|
||||
border: '1px solid var(--dg-border)',
|
||||
borderRadius: '6px',
|
||||
},
|
||||
'.cm-content': {
|
||||
caretColor: 'var(--dg-text-primary)',
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace",
|
||||
},
|
||||
'.cm-cursor': {
|
||||
borderLeftColor: 'var(--dg-text-primary)',
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: 'rgba(66, 133, 244, 0.3) !important',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--dg-bg-tertiary)',
|
||||
color: 'var(--dg-text-muted)',
|
||||
border: 'none',
|
||||
borderRight: '1px solid var(--dg-border)',
|
||||
},
|
||||
'.cm-activeLineGutter': {
|
||||
backgroundColor: 'var(--dg-bg-tertiary)',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.04)',
|
||||
},
|
||||
'.cm-placeholder': {
|
||||
color: 'var(--dg-text-muted)',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatSQL helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SQL_BREAK_KEYWORDS = [
|
||||
'SELECT',
|
||||
'FROM',
|
||||
'WHERE',
|
||||
'ORDER BY',
|
||||
'GROUP BY',
|
||||
'HAVING',
|
||||
'LIMIT',
|
||||
'OFFSET',
|
||||
'JOIN',
|
||||
'INNER JOIN',
|
||||
'LEFT JOIN',
|
||||
'RIGHT JOIN',
|
||||
'FULL JOIN',
|
||||
'CROSS JOIN',
|
||||
'LEFT OUTER JOIN',
|
||||
'RIGHT OUTER JOIN',
|
||||
'FULL OUTER JOIN',
|
||||
'ON',
|
||||
'UNION',
|
||||
'UNION ALL',
|
||||
'INTERSECT',
|
||||
'EXCEPT',
|
||||
'INSERT INTO',
|
||||
'VALUES',
|
||||
'UPDATE',
|
||||
'SET',
|
||||
'DELETE FROM',
|
||||
'CREATE TABLE',
|
||||
'ALTER TABLE',
|
||||
'DROP TABLE',
|
||||
];
|
||||
|
||||
const SQL_KEYWORDS_UPPER = new Set([
|
||||
'SELECT', 'FROM', 'WHERE', 'ORDER', 'BY', 'GROUP', 'HAVING', 'LIMIT', 'OFFSET',
|
||||
'JOIN', 'INNER', 'LEFT', 'RIGHT', 'FULL', 'OUTER', 'CROSS', 'ON',
|
||||
'AND', 'OR', 'NOT', 'IN', 'IS', 'NULL', 'LIKE', 'BETWEEN', 'AS',
|
||||
'ASC', 'DESC', 'DISTINCT', 'ALL', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END',
|
||||
'EXISTS', 'UNION', 'INTERSECT', 'EXCEPT',
|
||||
'INSERT', 'INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE', 'CREATE', 'ALTER',
|
||||
'DROP', 'TABLE', 'INDEX', 'VIEW', 'TRIGGER', 'PROCEDURE', 'FUNCTION',
|
||||
'PRIMARY', 'KEY', 'FOREIGN', 'REFERENCES', 'UNIQUE', 'CHECK', 'DEFAULT',
|
||||
'GRANT', 'REVOKE', 'COMMIT', 'ROLLBACK', 'BEGIN', 'TRANSACTION',
|
||||
'IF', 'ELSE', 'WHILE', 'FOR', 'RETURN',
|
||||
'COUNT', 'SUM', 'AVG', 'MIN', 'MAX',
|
||||
'TRUE', 'FALSE', 'BOOLEAN', 'INTEGER', 'BIGINT', 'FLOAT', 'DOUBLE',
|
||||
'VARCHAR', 'TEXT', 'DATE', 'TIMESTAMP', 'BLOB', 'JSON',
|
||||
'TRUNCATE', 'EXPLAIN', 'ANALYZE', 'SHOW', 'DESCRIBE', 'USE', 'DATABASE',
|
||||
]);
|
||||
|
||||
export function formatSQL(input: string): string {
|
||||
// Normalise whitespace
|
||||
let formatted = input.replace(/\s+/g, ' ').trim();
|
||||
|
||||
// Uppercase SQL keywords (word-boundary aware)
|
||||
formatted = formatted.replace(/\b([A-Za-z_]+)\b/g, (match) => {
|
||||
const upper = match.toUpperCase();
|
||||
return SQL_KEYWORDS_UPPER.has(upper) ? upper : match;
|
||||
});
|
||||
|
||||
// Insert newlines before major clauses
|
||||
// We iterate in the order they might appear; use a single pass regex approach.
|
||||
for (const kw of SQL_BREAK_KEYWORDS) {
|
||||
// Only break on keyword that is not already at start of a line
|
||||
const escaped = kw.replace(/\s+/g, '\\s+');
|
||||
const re = new RegExp(`(?<!^)(?<!\\n)\\b(${escaped})\\b`, 'gi');
|
||||
formatted = formatted.replace(re, '\n$1');
|
||||
}
|
||||
|
||||
// Clean up any double newlines
|
||||
formatted = formatted.replace(/\n{2,}/g, '\n');
|
||||
|
||||
return formatted.trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SqlEditor component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SqlEditor: React.FC<SqlEditorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Enter SQL query…',
|
||||
language = 'sql',
|
||||
height = '200px',
|
||||
readOnly = false,
|
||||
onExecute,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const langCompartmentRef = useRef(new Compartment());
|
||||
const readOnlyCompartmentRef = useRef(new Compartment());
|
||||
const placeholderCompartmentRef = useRef(new Compartment());
|
||||
const isExternalUpdateRef = useRef(false);
|
||||
|
||||
// Keep onChange in a stable ref so callbacks never go stale
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
// Ctrl+Enter handler
|
||||
const runKeymap = useCallback(
|
||||
(): Extension[] => [
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Mod-Enter',
|
||||
run: () => {
|
||||
if (onExecute) {
|
||||
onExecute();
|
||||
} else {
|
||||
window.dispatchEvent(new CustomEvent('sql-editor:execute'));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Mod-Shift-f',
|
||||
run: (view: EditorView) => {
|
||||
const current = view.state.doc.toString();
|
||||
const formatted = formatSQL(current);
|
||||
if (formatted !== current) {
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.state.doc.length,
|
||||
insert: formatted,
|
||||
},
|
||||
});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
[onExecute],
|
||||
);
|
||||
|
||||
// Create editor once
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const updateListener = EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged && !isExternalUpdateRef.current) {
|
||||
onChangeRef.current(update.state.doc.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
history(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
highlightSelectionMatches(),
|
||||
rectangularSelection(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
appTheme,
|
||||
oneDark,
|
||||
langCompartmentRef.current.of(getSQLExtension(language)),
|
||||
readOnlyCompartmentRef.current.of(
|
||||
EditorState.readOnly.of(readOnly),
|
||||
),
|
||||
placeholderCompartmentRef.current.of(cmPlaceholder(placeholder)),
|
||||
updateListener,
|
||||
...runKeymap(),
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
]),
|
||||
EditorView.lineWrapping,
|
||||
],
|
||||
});
|
||||
|
||||
const view = new EditorView({
|
||||
state,
|
||||
parent: containerRef.current,
|
||||
});
|
||||
|
||||
viewRef.current = view;
|
||||
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
// We intentionally only run this once on mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Sync external value changes into the editor (avoid loops)
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
|
||||
const currentContent = view.state.doc.toString();
|
||||
if (currentContent !== value) {
|
||||
isExternalUpdateRef.current = true;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||
});
|
||||
isExternalUpdateRef.current = false;
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// Update language when it changes
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
effects: langCompartmentRef.current.reconfigure(getSQLExtension(language)),
|
||||
});
|
||||
}, [language]);
|
||||
|
||||
// Update readOnly when it changes
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
effects: readOnlyCompartmentRef.current.reconfigure(
|
||||
EditorState.readOnly.of(readOnly),
|
||||
),
|
||||
});
|
||||
}, [readOnly]);
|
||||
|
||||
// Update placeholder when it changes
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
effects: placeholderCompartmentRef.current.reconfigure(
|
||||
cmPlaceholder(placeholder ?? ''),
|
||||
),
|
||||
});
|
||||
}, [placeholder]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ height, minHeight: height }}
|
||||
className="sql-editor-wrapper"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SqlEditor;
|
||||
@@ -43,7 +43,6 @@ const routePermissionMap: Record<string, UserRole[]> = {
|
||||
queries: ['admin', 'dba', 'developer', 'guest'],
|
||||
roles: ['admin'],
|
||||
users: ['admin'],
|
||||
'data-permission': ['admin', 'dba'],
|
||||
'audit-logs': ['admin', 'dba'],
|
||||
workflow: ['admin', 'dba'],
|
||||
'auto-task': ['admin'],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Ticket, DBInstance, QueryAudit, AuditRule, Workflow, NotificationChannel, Role, User, DataPermission, PermissionNode, DashboardStats } from '@/types';
|
||||
import type { Ticket, DBInstance, QueryAudit, AuditRule, Workflow, NotificationChannel, Role, User, PermissionNode, DashboardStats } from '@/types';
|
||||
|
||||
export const dashboardStats: DashboardStats = {
|
||||
ticket_total: 128,
|
||||
@@ -23,12 +23,12 @@ export const menuSections = [
|
||||
export const menuItems = [
|
||||
{ id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'overview', roles: ['admin', 'dba', 'developer', 'guest'] },
|
||||
{ id: 'instances', label: '数据源', icon: 'Database', section: 'datasource', roles: ['admin', 'dba'] },
|
||||
{ id: 'instance-groups', label: '权限组', icon: 'Shield', section: 'datasource', roles: ['admin'] },
|
||||
{ id: 'instance-groups', label: '权限管理', icon: 'Lock', section: 'permission', roles: ['admin'] },
|
||||
{ id: 'tickets', label: '数据变更', icon: 'ClipboardList', section: 'ticket', roles: ['admin', 'dba', 'developer'] },
|
||||
{ id: 'queries', label: '查询审计', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer', 'guest'] },
|
||||
{ id: 'slow-query', label: '慢查询分析', icon: 'Timer', section: 'ticket', roles: ['admin', 'dba'] },
|
||||
{ id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] },
|
||||
{ id: 'users', label: '用户管理', icon: 'Users', section: 'permission', roles: ['admin'] },
|
||||
{ id: 'data-permission', label: '数据权限', icon: 'Lock', section: 'permission', roles: ['admin', 'dba'] },
|
||||
{ id: 'audit-logs', label: '审计日志', icon: 'FileText', section: 'system', roles: ['admin', 'dba'] },
|
||||
{ id: 'workflow', label: '审批流程', icon: 'GitBranch', section: 'system', roles: ['admin', 'dba'] },
|
||||
{ id: 'auto-task', label: '自动任务', icon: 'Clock', section: 'system', roles: ['admin'] },
|
||||
@@ -162,8 +162,8 @@ export const notificationChannels: NotificationChannel[] = [
|
||||
];
|
||||
|
||||
export const roles: Role[] = [
|
||||
{ id: 'admin', name: '超级管理员', description: '拥有系统的所有权限,包括用户管理、系统配置等', icon: 'Crown', iconClass: 'text-purple-400 bg-purple-400/10', userCount: 2, permissionCount: 56, permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'queries', 'roles', 'users', 'data-permission', 'audit-rules', 'audit-logs', 'workflow', 'auto-task', 'announcement', 'config-platform', 'notifications'] },
|
||||
{ id: 'dba', name: '数据库管理员', description: '负责数据库运维、工单审批和 SQL 审核', icon: 'Database', iconClass: 'text-blue-400 bg-blue-400/10', userCount: 5, permissionCount: 32, permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'tickets', 'queries', 'data-permission', 'audit-rules', 'audit-logs', 'workflow'] },
|
||||
{ id: 'admin', name: '超级管理员', description: '拥有系统的所有权限,包括用户管理、系统配置等', icon: 'Crown', iconClass: 'text-purple-400 bg-purple-400/10', userCount: 2, permissionCount: 56, permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'queries', 'roles', 'users', 'audit-rules', 'audit-logs', 'workflow', 'auto-task', 'announcement', 'config-platform', 'notifications'] },
|
||||
{ id: 'dba', name: '数据库管理员', description: '负责数据库运维、工单审批和 SQL 审核', icon: 'Database', iconClass: 'text-blue-400 bg-blue-400/10', userCount: 5, permissionCount: 32, permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'tickets', 'queries', 'audit-rules', 'audit-logs', 'workflow'] },
|
||||
{ id: 'developer', name: '开发人员', description: '可以提交变更工单和执行数据查询', icon: 'Code', iconClass: 'text-green-400 bg-green-400/10', userCount: 28, permissionCount: 12, permissions: ['dashboard', 'tickets', 'queries'] },
|
||||
{ id: 'guest', name: '访客', description: '仅可查看,无法执行任何操作', icon: 'Eye', iconClass: 'text-gray-400 bg-gray-400/10', userCount: 8, permissionCount: 4, permissions: ['dashboard', 'queries'] },
|
||||
];
|
||||
@@ -178,14 +178,6 @@ export const users: User[] = [
|
||||
{ id: 7, username: 'sunba', email: 'sunba@company.com', role: 'guest', roleName: '访客', department: '测试部', lastLogin: '2024-01-13 11:00:00', status: 'active', avatar: '孙' },
|
||||
];
|
||||
|
||||
export const dataPermissions: DataPermission[] = [
|
||||
{ id: 1, user: 'zhangsan', instance: '生产主库 (MySQL)', database: 'user_db', table: 'users', columns: '*', rowFilter: '', operations: ['SELECT', 'INSERT', 'UPDATE'] },
|
||||
{ id: 2, user: 'lisi', instance: '生产主库 (MySQL)', database: 'order_db', table: 'orders', columns: 'id,order_no,amount,status,created_at', rowFilter: 'created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)', operations: ['SELECT'] },
|
||||
{ id: 3, user: 'wangwu', instance: '测试库 (MySQL)', database: 'test_db', table: '*', columns: '*', rowFilter: '', operations: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'] },
|
||||
{ id: 4, user: 'zhangsan', instance: 'PG 主库', database: 'analytics', table: 'user_behavior', columns: 'user_id,event_type,page_url,timestamp', rowFilter: '', operations: ['SELECT'] },
|
||||
{ id: 5, user: 'admin', instance: 'Redis集群-01', database: '0', table: '*', columns: '*', rowFilter: '', operations: ['GET', 'SET', 'DEL', 'EXPIRE'] },
|
||||
];
|
||||
|
||||
export const permissionTreeData: PermissionNode[] = [
|
||||
{ id: 'dashboard', label: '仪表盘(查看统计、图表)' },
|
||||
{ id: 'datasource', label: '数据源管理', children: [
|
||||
@@ -208,7 +200,6 @@ export const permissionTreeData: PermissionNode[] = [
|
||||
{ id: 'permission', label: '权限管理', children: [
|
||||
{ id: 'roles', label: '角色权限(创建、编辑、分配权限)' },
|
||||
{ id: 'users', label: '用户管理(创建、编辑、禁用)' },
|
||||
{ id: 'data-permission', label: '数据权限(配置表级、字段级、行级权限)' },
|
||||
]},
|
||||
{ id: 'system', label: '系统配置', children: [
|
||||
{ id: 'audit-rules', label: '审核规则(启用/禁用、添加规则)' },
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 查询模板系统
|
||||
* 提供 10 个预设模板,适配 MySQL、PostgreSQL、Redis、MongoDB 四种数据库类型
|
||||
*/
|
||||
|
||||
export type DBType = 'mysql' | 'postgresql' | 'redis' | 'mongodb';
|
||||
|
||||
export type QueryTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
sql: Partial<Record<DBType, string>>;
|
||||
supportedTypes: DBType[];
|
||||
};
|
||||
|
||||
export const queryTemplates: QueryTemplate[] = [
|
||||
{
|
||||
id: 'table-structure',
|
||||
name: '查询表结构',
|
||||
description: '查看指定表或集合的结构定义,包括字段、类型、索引等信息',
|
||||
category: '表结构',
|
||||
sql: {
|
||||
mysql: 'SHOW CREATE TABLE {table};',
|
||||
postgresql:
|
||||
"SELECT column_name, data_type, is_nullable, column_default FROM information_schema.columns WHERE table_name = '{table}' ORDER BY ordinal_position;",
|
||||
mongodb: 'db.{collection}.getIndexes()',
|
||||
},
|
||||
supportedTypes: ['mysql', 'postgresql', 'mongodb'],
|
||||
},
|
||||
{
|
||||
id: 'slow-queries',
|
||||
name: '查询慢SQL',
|
||||
description: '查看慢查询日志,定位执行时间较长的SQL语句',
|
||||
category: '性能分析',
|
||||
sql: {
|
||||
mysql: 'SELECT * FROM mysql.slow_log LIMIT 20;',
|
||||
postgresql:
|
||||
'SELECT * FROM pg_stat_statements ORDER BY total_time DESC LIMIT 20;',
|
||||
},
|
||||
supportedTypes: ['mysql', 'postgresql'],
|
||||
},
|
||||
{
|
||||
id: 'table-size',
|
||||
name: '查询表大小',
|
||||
description: '查看数据库中各表或集合的存储空间占用情况',
|
||||
category: '数据统计',
|
||||
sql: {
|
||||
mysql: "SELECT table_name, data_length, index_length FROM information_schema.tables WHERE table_schema = '{db}';",
|
||||
postgresql:
|
||||
"SELECT relname, pg_relation_size(oid) FROM pg_class WHERE relkind='r';",
|
||||
redis: 'INFO memory',
|
||||
mongodb: 'db.stats()',
|
||||
},
|
||||
supportedTypes: ['mysql', 'postgresql', 'redis', 'mongodb'],
|
||||
},
|
||||
{
|
||||
id: 'active-connections',
|
||||
name: '查询活跃连接',
|
||||
description: '查看当前数据库的活跃连接数及连接详情',
|
||||
category: '性能分析',
|
||||
sql: {
|
||||
mysql: 'SHOW PROCESSLIST;',
|
||||
postgresql: 'SELECT * FROM pg_stat_activity;',
|
||||
redis: 'CLIENT LIST',
|
||||
mongodb: 'db.currentOp()',
|
||||
},
|
||||
supportedTypes: ['mysql', 'postgresql', 'redis', 'mongodb'],
|
||||
},
|
||||
{
|
||||
id: 'indexes',
|
||||
name: '查询索引',
|
||||
description: '查看指定表或集合的索引信息',
|
||||
category: '表结构',
|
||||
sql: {
|
||||
mysql: 'SHOW INDEX FROM {table};',
|
||||
postgresql:
|
||||
"SELECT * FROM pg_indexes WHERE tablename = '{table}';",
|
||||
mongodb: 'db.{collection}.getIndexes()',
|
||||
},
|
||||
supportedTypes: ['mysql', 'postgresql', 'mongodb'],
|
||||
},
|
||||
{
|
||||
id: 'lock-waits',
|
||||
name: '查询锁等待',
|
||||
description: '查看当前数据库中存在的锁等待情况,排查锁冲突',
|
||||
category: '性能分析',
|
||||
sql: {
|
||||
mysql: 'SELECT * FROM information_schema.innodb_lock_waits;',
|
||||
postgresql: 'SELECT * FROM pg_locks WHERE NOT granted;',
|
||||
},
|
||||
supportedTypes: ['mysql', 'postgresql'],
|
||||
},
|
||||
{
|
||||
id: 'top-n',
|
||||
name: 'TOP N查询',
|
||||
description: '查询指定表中按ID倒序排列的前N条记录',
|
||||
category: '基础查询',
|
||||
sql: {
|
||||
mysql: 'SELECT * FROM {table} ORDER BY id DESC LIMIT {n};',
|
||||
postgresql: 'SELECT * FROM {table} ORDER BY id DESC LIMIT {n};',
|
||||
mongodb: 'db.{collection}.find().sort({_id:-1}).limit({n})',
|
||||
},
|
||||
supportedTypes: ['mysql', 'postgresql', 'mongodb'],
|
||||
},
|
||||
{
|
||||
id: 'fuzzy-search',
|
||||
name: '模糊搜索',
|
||||
description: '根据关键词对指定字段进行模糊匹配查询',
|
||||
category: '基础查询',
|
||||
sql: {
|
||||
mysql: "SELECT * FROM {table} WHERE {column} LIKE '%{keyword}%';",
|
||||
postgresql: "SELECT * FROM {table} WHERE {column} LIKE '%{keyword}%';",
|
||||
redis: 'KEYS *{keyword}*',
|
||||
mongodb: 'db.{collection}.find({field: /{keyword}/})',
|
||||
},
|
||||
supportedTypes: ['mysql', 'postgresql', 'redis', 'mongodb'],
|
||||
},
|
||||
{
|
||||
id: 'statistics',
|
||||
name: '查询统计信息',
|
||||
description: '查看表或集合的统计信息,包括行数、大小、索引等',
|
||||
category: '数据统计',
|
||||
sql: {
|
||||
mysql: "SHOW TABLE STATUS LIKE '{table}';",
|
||||
postgresql:
|
||||
"SELECT * FROM pg_stat_user_tables WHERE relname='{table}';",
|
||||
redis: 'INFO keyspace',
|
||||
mongodb: 'db.{collection}.stats()',
|
||||
},
|
||||
supportedTypes: ['mysql', 'postgresql', 'redis', 'mongodb'],
|
||||
},
|
||||
{
|
||||
id: 'execution-plan',
|
||||
name: '查询执行计划',
|
||||
description: '查看查询语句的执行计划,用于SQL性能优化分析',
|
||||
category: '性能分析',
|
||||
sql: {
|
||||
mysql: 'EXPLAIN SELECT ...',
|
||||
postgresql: 'EXPLAIN ANALYZE SELECT ...',
|
||||
mongodb: 'db.{collection}.find({...}).explain()',
|
||||
},
|
||||
supportedTypes: ['mysql', 'postgresql', 'mongodb'],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取模板在指定数据库类型下的查询语句
|
||||
* @param template 查询模板
|
||||
* @param dbType 数据库类型
|
||||
* @returns 对应的查询语句,如果不支持则返回提示信息
|
||||
*/
|
||||
export function getTemplateSQL(
|
||||
template: QueryTemplate,
|
||||
dbType: DBType
|
||||
): string {
|
||||
const sql = template.sql[dbType];
|
||||
if (!sql) {
|
||||
return `-- 当前模板「${template.name}」不支持 ${dbType} 数据库类型`;
|
||||
}
|
||||
return sql;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定数据库类型支持的所有模板
|
||||
* @param dbType 数据库类型
|
||||
* @returns 该数据库类型支持的模板列表
|
||||
*/
|
||||
export function getTemplatesForType(dbType: DBType): QueryTemplate[] {
|
||||
return queryTemplates.filter((template) =>
|
||||
template.supportedTypes.includes(dbType)
|
||||
);
|
||||
}
|
||||
@@ -55,9 +55,9 @@ export default function AuditLogsPage() {
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(pageSize) });
|
||||
if (filterAction) params.set('action', filterAction);
|
||||
const res = await request.get(`/audit/logs?${params}`);
|
||||
const data = res.data as AuditLogResponse;
|
||||
const data = (res?.data ?? res ?? {}) as AuditLogResponse;
|
||||
setLogs(data.items || []);
|
||||
setTotal(data.total);
|
||||
setTotal(data.total || 0);
|
||||
} catch (e) {
|
||||
console.error('获取审计日志失败', e);
|
||||
} finally {
|
||||
@@ -156,4 +156,4 @@ export default function AuditLogsPage() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ClipboardList, Clock, CheckCircle, AlertTriangle,
|
||||
Database, ChevronRight,
|
||||
X, AlertCircle, Info, Bell
|
||||
X, AlertCircle, Info, Bell, Activity, TrendingUp, Zap, Server,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { dbInstances, statusMap, statusColorMap, dbTypeColorMap, tickets, announcements } from '@/lib/mock';
|
||||
@@ -296,6 +296,127 @@ export default function Dashboard() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 数据源健康概览 + 查询统计 */}
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
{/* 数据源健康概览 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-blue-400" />
|
||||
数据源健康概览
|
||||
</CardTitle>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{onlineInstances}/{dbInstances.length} 在线</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{/* 按类型统计 */}
|
||||
<div className="grid grid-cols-4 gap-2 p-4 border-b border-[var(--dg-border-light)]">
|
||||
{(['mysql', 'postgresql', 'redis', 'mongodb'] as const).map(type => {
|
||||
const typeInstances = dbInstances.filter(i => i.dbType === type);
|
||||
const typeOnline = typeInstances.filter(i => i.status === 'online').length;
|
||||
const iconMap: Record<string, string> = { mysql: '/db-logos/mysql.svg', postgresql: '/db-logos/postgres.svg', redis: '/db-logos/redis.svg', mongodb: '/db-logos/mongo.svg' };
|
||||
const labelMap: Record<string, string> = { mysql: 'MySQL', postgresql: 'PostgreSQL', redis: 'Redis', mongodb: 'MongoDB' };
|
||||
return (
|
||||
<div key={type} className="text-center p-2.5 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
||||
<img src={iconMap[type]} alt="" className="w-6 h-6 object-contain mx-auto mb-1.5" />
|
||||
<div className="text-sm font-semibold">{typeInstances.length}</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)]">{labelMap[type]}</div>
|
||||
<div className="text-[10px] mt-1">
|
||||
<span className={typeOnline === typeInstances.length && typeInstances.length > 0 ? 'text-green-400' : typeOnline > 0 ? 'text-yellow-400' : 'text-[var(--dg-text-muted)]'}>
|
||||
{typeOnline}/{typeInstances.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 最近告警实例 */}
|
||||
{dbInstances.filter(i => i.status === 'warning' || i.status === 'offline').length > 0 && (
|
||||
<div className="p-4">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-2">需要关注</div>
|
||||
{dbInstances.filter(i => i.status === 'warning' || i.status === 'offline').slice(0, 3).map(inst => (
|
||||
<div key={inst.id} className="flex items-center gap-3 py-2">
|
||||
<span className={`w-2 h-2 rounded-full ${inst.status === 'offline' ? 'bg-red-400' : 'bg-yellow-400'}`} />
|
||||
<span className="text-sm flex-1">{inst.name}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</span>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full ${inst.status === 'offline' ? 'bg-red-500/15 text-red-400' : 'bg-yellow-500/15 text-yellow-400'}`}>
|
||||
{inst.status === 'offline' ? '离线' : '告警'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 查询统计 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-green-400" />
|
||||
查询统计
|
||||
</CardTitle>
|
||||
<div className="flex gap-1.5">
|
||||
{['今日', '本周', '本月'].map((p, i) => (
|
||||
<button key={p} className={`px-3 py-1 text-xs rounded-md transition-colors ${i === 0 ? 'bg-green-500/15 text-green-400' : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'}`}>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
{/* 统计指标 */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
||||
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">总查询数</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold">1,284</div>
|
||||
<div className="text-[10px] text-green-400 mt-1">↑ 12% vs 昨日</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="w-3.5 h-3.5 text-orange-400" />
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">平均耗时</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold">23ms</div>
|
||||
<div className="text-[10px] text-green-400 mt-1">↓ 8% vs 昨日</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-red-400" />
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">慢查询</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold">7</div>
|
||||
<div className="text-[10px] text-red-400 mt-1">↑ 3 vs 昨日</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 查询类型分布 */}
|
||||
<div className="space-y-2.5">
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">查询类型分布</div>
|
||||
{[
|
||||
{ label: 'MySQL', count: 682, total: 1284, color: 'bg-[#00758f]' },
|
||||
{ label: 'PostgreSQL', count: 312, total: 1284, color: 'bg-[#336791]' },
|
||||
{ label: 'Redis', count: 198, total: 1284, color: 'bg-[#dc382d]' },
|
||||
{ label: 'MongoDB', count: 92, total: 1284, color: 'bg-[#47a248]' },
|
||||
].map(item => (
|
||||
<div key={item.label} className="flex items-center gap-3">
|
||||
<span className="text-xs w-20 text-[var(--dg-text-secondary)]">{item.label}</span>
|
||||
<div className="flex-1 h-2 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
||||
<div className={`h-full rounded-full ${item.color}`} style={{ width: `${(item.count / item.total) * 100}%` }} />
|
||||
</div>
|
||||
<span className="text-xs w-12 text-right text-[var(--dg-text-muted)]">{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 待处理工单 */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Search, Edit2, Trash2, Database, Table, Filter, Shield } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { dataPermissions } from '@/lib/mock';
|
||||
|
||||
export default function DataPermissionPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = dataPermissions.filter(p =>
|
||||
!search || p.user.includes(search) || p.instance.includes(search) || p.database.includes(search)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">数据权限</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置用户对数据库、表、字段的访问权限,支持行级权限控制</p>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
|
||||
<Plus className="w-4 h-4" /> 添加权限
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: '权限规则数', value: dataPermissions.length, icon: Shield, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
||||
{ label: '受控用户', value: new Set(dataPermissions.map(p => p.user)).size, icon: Database, color: 'text-purple-400', bg: 'bg-purple-400/10' },
|
||||
{ label: '受控实例', value: new Set(dataPermissions.map(p => p.instance)).size, icon: Table, color: 'text-green-400', bg: 'bg-green-400/10' },
|
||||
{ label: '行级过滤规则', value: dataPermissions.filter(p => p.rowFilter).length, icon: Filter, color: 'text-orange-400', bg: 'bg-orange-400/10' },
|
||||
].map((s, i) => (
|
||||
<Card key={i} className="dg-card">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
|
||||
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative 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 className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none" placeholder="搜索用户/实例/数据库" value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
<th className="text-left px-5 py-3 font-semibold">用户</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">实例</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">数据库</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">表</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">字段权限</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">行级过滤</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">操作权限</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(p => (
|
||||
<tr key={p.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<td className="px-5 py-3.5 font-medium">{p.user}</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{p.instance}</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{p.database}</td>
|
||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{p.table}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
{p.columns === '*' ? (
|
||||
<span className="px-2 py-1 bg-green-500/15 text-green-400 text-xs rounded-md">全部字段</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{p.columns.split(',').map(c => (
|
||||
<span key={c} className="px-2 py-1 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] text-xs rounded-md">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
{p.rowFilter ? (
|
||||
<span className="px-2 py-1 bg-yellow-500/15 text-yellow-400 text-xs rounded-md">{p.rowFilter}</span>
|
||||
) : (
|
||||
<span className="text-[var(--dg-text-muted)]">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{p.operations.map(op => (
|
||||
<span key={op} className="px-2 py-1 bg-blue-500/15 text-blue-400 text-xs rounded-md">{op}</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<div className="flex gap-2">
|
||||
<button className="text-blue-400 hover:text-blue-300"><Edit2 className="w-4 h-4" /></button>
|
||||
<button className="text-red-400 hover:text-red-300"><Trash2 className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -317,44 +317,21 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Database Brand SVG Icons ---------- */
|
||||
/* ---------- Database Brand Logo Icons (from logo.wine) ---------- */
|
||||
function MySQLIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 256 256" className={className}>
|
||||
<path d="M235.6 184.1c-13.2-1.3-24.5-10.2-29.8-22.1a7.7 7.7 0 01-.4-1.5c-1.2-3.2-1.5-7-.7-10.8 2-9.5 9.9-15.2 18.6-14.3 8.7.9 15.6 8.1 16.2 17.7.5 7.6-3.2 14.3-9 18.2a1.3 1.3 0 00-.5 1.5c2.7 7.4 8 12.6 14.9 14.4a1.3 1.3 0 01-.8 2.4c-3-.5-5.9-2.4-8.5-5.5z" fill="#00758F"/>
|
||||
<path d="M28.3 200.3c0 .3.2.5.5.5h5.6c.3 0 .5-.2.5-.5v-19.4h7.8c.3 0 .5-.2.5-.5v-4.4c0-.3-.2-.5-.5-.5H20.5c-.3 0-.5.2-.5.5v4.4c0 .3.2.5.5.5h7.8v19.4zm22.5 0c0 .3.2.5.5.5h5.6c.3 0 .5-.2.5-.5v-24.8c0-.3-.2-.5-.5-.5h-5.6c-.3 0-.5.2-.5.5v24.8zm15.6 0c0 .3.2.5.5.5h13.2c.3 0 .5-.2.5-.5v-4.4c0-.3-.2-.5-.5-.5h-7.1v-19.9c0-.3-.2-.5-.5-.5h-5.6c-.3 0-.5.2-.5.5v24.8zm22.5 0c0 .3.2.5.5.5h5.6c.3 0 .5-.2.5-.5v-24.8c0-.3-.2-.5-.5-.5h-5.6c-.3 0-.5.2-.5.5v24.8zm16.8-12.4c0-7.5 5.7-12.9 13.2-12.9 4.6 0 8.1 1.9 10.5 5l-3.9 3.2c-1.7-2.2-3.9-3.3-6.4-3.3-4.6 0-7.9 3.4-7.9 8s3.3 8 7.9 8c2.5 0 4.7-1.1 6.4-3.3l3.9 3.2c-2.4 3.1-5.9 5-10.5 5-7.5 0-13.2-5.4-13.2-12.9z" fill="#F29111"/>
|
||||
<ellipse cx="128" cy="56" rx="90" ry="28" fill="none" stroke="#00758F" strokeWidth="6"/>
|
||||
</svg>
|
||||
);
|
||||
return <img src="/db-logos/mysql.svg" alt="MySQL" className={className} />;
|
||||
}
|
||||
|
||||
function PostgreSQLIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 256 256" className={className}>
|
||||
<path d="M191.5 18.2c-1.2-10.2-7.8-17.6-18.3-18C163.6-.3 153 4 145.2 12c-7.2 7.3-11 17-10.6 27 .3 5.7 1.5 11 3.6 15.7-3.2 7.5-7.6 18.2-10.5 25.8-4.5 11.8-13.4 29.7-19.6 29.7-1.5 0-3.2-.8-5-2.4-6.5-5.8-14.4-8.8-22.9-8.8-11 0-20.7 5.2-25.5 13.6-3.6 6.3-3.7 13.5-.4 19.7 4 7.4 12.5 12.8 22 14l.7.1c6.6.6 13.6-2.3 20.7-8.6 3.3-2.9 7.6-4.5 12.4-4.7 7.4-.3 14.8 3.3 18.7 9.1 3.4 5.1 3.5 11.5.2 16.6-2.2 3.4-5.3 5.8-9 7.1-3 1-5.3 3.3-6.3 6.3-1 3.1-.5 6.4 1.4 9 3.8 5.2 9.3 7.9 15.8 7.9 4.5 0 9.2-1.3 13.5-3.8 4.6-2.7 7.9-6.7 9.6-11.7 1.4-4.1 1.5-8.5.2-12.8l-.1-.3c-2-6.5.3-13.8 5.7-17.9 5.6-4.2 13-4.5 18.9-.7 8.8 5.6 14.1 13.2 15.4 22 .6 3.8.4 7.7-.6 11.4" fill="none" stroke="#336791" strokeWidth="10" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
);
|
||||
return <img src="/db-logos/postgres.svg" alt="PostgreSQL" className={className} />;
|
||||
}
|
||||
|
||||
function RedisIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 256 256" className={className}>
|
||||
<path d="M128 190L20.5 145.3v-20.6l107.5 44.7 107.5-44.7v20.6L128 190z" fill="#912626"/>
|
||||
<path d="M128 149.4L20.5 104.7V84.1L128 128.8l107.5-44.7v20.6L128 149.4z" fill="#C6302B"/>
|
||||
<path d="M128 108.8L20.5 64.1V43.5L128 88.2l107.5-44.7v20.6L128 108.8z" fill="#912626"/>
|
||||
<path d="M128 66.5L73.2 43.7 128 20.9l54.8 22.8L128 66.5z" fill="#C6302B"/>
|
||||
<path d="M128 235.2l-107.5-44.7v-20.6L128 214.6l107.5-44.7v20.6L128 235.2z" fill="#912626"/>
|
||||
</svg>
|
||||
);
|
||||
return <img src="/db-logos/redis.svg" alt="Redis" className={className} />;
|
||||
}
|
||||
|
||||
function MongoDBIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 256 256" className={className}>
|
||||
<path d="M128 20c-4.4 16-10 28-17.6 39.2C101.6 72.4 88.8 84 78 98c-14.8 19.2-23.2 40.4-23.2 65.2 0 38.8 31.2 72.8 73.2 72.8s73.2-34 73.2-72.8c0-24.8-8.4-46-23.2-65.2C167.2 84 154.4 72.4 145.6 59.2 138 48 132.4 36 128 20z" fill="#439934"/>
|
||||
<path d="M128 20c-2 12-6.8 22-12.8 31.2C107.6 63.6 98 73.6 89.6 85.2 78 100.4 72 118 72 138c0 30 22 56 56 56s56-26 56-56c0-20-6-37.6-17.6-52.8C158 73.6 148.4 63.6 140.8 51.2 134.8 42 130 32 128 20z" fill="#4AA841"/>
|
||||
</svg>
|
||||
);
|
||||
return <img src="/db-logos/mongo.svg" alt="MongoDB" className={className} />;
|
||||
}
|
||||
|
||||
/* ---------- CreateInstanceForm ---------- */
|
||||
@@ -377,6 +354,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPwd, setShowPwd] = useState(false);
|
||||
const [database, setDatabase] = useState('');
|
||||
const [redisDb, setRedisDb] = useState('0');
|
||||
const [env, setEnv] = useState('development');
|
||||
const [customEnv, setCustomEnv] = useState('');
|
||||
const [accessMode, setAccessMode] = useState<'readwrite' | 'read' | 'write'>('readwrite');
|
||||
@@ -474,7 +452,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
|
||||
<button key={t.value} type="button" onClick={() => handleTypeChange(t.value)}
|
||||
className={'flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all '
|
||||
+ (dbType === t.value ? 'border-blue-500 bg-blue-500/5' : 'border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]')}>
|
||||
<t.Icon className="w-8 h-8" />
|
||||
<t.Icon className="w-10 h-10 object-contain" />
|
||||
<span className="text-xs font-medium">{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -561,7 +539,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
|
||||
) : (
|
||||
<div>
|
||||
<label className={labelCls}>数据库编号</label>
|
||||
<input className={inputCls} placeholder="默认 0" />
|
||||
<input value={redisDb} onChange={e => setRedisDb(e.target.value)} className={inputCls} placeholder="默认 0" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -664,4 +642,4 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Plus, Clock, X,
|
||||
AlertTriangle, CheckCircle, Play, Loader2,
|
||||
Plus, X, AlertTriangle, CheckCircle, Play, Loader2,
|
||||
ChevronDown, Clock, FileText, Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useActionPermission } from '@/hooks/useAuth';
|
||||
@@ -9,6 +9,9 @@ import PageHeader from '@/components/PageHeader';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { queryApi } from '@/api/query';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
import SqlEditor from '@/components/SqlEditor';
|
||||
import { queryTemplates, getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
|
||||
import type { DBType } from '@/lib/queryTemplates';
|
||||
|
||||
interface QueryResult {
|
||||
columns: string[];
|
||||
@@ -22,32 +25,46 @@ interface QueryHistoryItem {
|
||||
sql: string;
|
||||
instance_id: number;
|
||||
database: string;
|
||||
db_type: string;
|
||||
status: string;
|
||||
execution_time_ms: number;
|
||||
result_preview: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const dbTypeLabel: Record<string, string> = {
|
||||
mysql: 'MySQL', postgresql: 'PostgreSQL', redis: 'Redis', mongodb: 'MongoDB',
|
||||
};
|
||||
|
||||
const dbTypeIcon: Record<string, string> = {
|
||||
mysql: '/db-logos/mysql.svg',
|
||||
postgresql: '/db-logos/postgres.svg',
|
||||
redis: '/db-logos/redis.svg',
|
||||
mongodb: '/db-logos/mongo.svg',
|
||||
};
|
||||
|
||||
export default function QueriesPage() {
|
||||
const canCreate = useActionPermission('query.create');
|
||||
const [showNewQuery, setShowNewQuery] = useState(false);
|
||||
|
||||
// 查询历史暂时用本地状态(后端没有查询历史列表 API)
|
||||
const [history, setHistory] = useState<QueryHistoryItem[]>([]);
|
||||
|
||||
if (showNewQuery) {
|
||||
return <NewQueryPanel onCancel={() => setShowNewQuery(false)} onExecuted={(item) => { setHistory(prev => [item, ...prev]); }} />;
|
||||
return (
|
||||
<NewQueryPanel
|
||||
onCancel={() => setShowNewQuery(false)}
|
||||
onExecuted={(item) => { setHistory(prev => [item, ...prev]); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="查询审计"
|
||||
description="在线 SQL 查询工具,支持 SELECT 查询和结果预览"
|
||||
description="在线查询工具,支持 MySQL / PostgreSQL / Redis / MongoDB 多类型数据源"
|
||||
action={canCreate ? { label: '新建查询', icon: Plus, onClick: () => setShowNewQuery(true), permission: 'query.create' } : undefined}
|
||||
/>
|
||||
|
||||
{/* 查询历史 */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-0">
|
||||
{history.length === 0 ? (
|
||||
@@ -57,7 +74,8 @@ export default function QueriesPage() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
||||
<th className="text-left px-5 py-3 font-semibold">SQL 语句</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">类型</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">语句</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">执行时长</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">状态</th>
|
||||
<th className="text-left px-5 py-3 font-semibold">时间</th>
|
||||
@@ -66,8 +84,13 @@ export default function QueriesPage() {
|
||||
<tbody>
|
||||
{history.map((q, i) => (
|
||||
<tr key={i} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<td className="px-5 py-3.5">
|
||||
{q.db_type && dbTypeIcon[q.db_type] && (
|
||||
<img src={dbTypeIcon[q.db_type]} alt={q.db_type} className="w-5 h-5 object-contain" />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3.5 max-w-md">
|
||||
<code className="text-xs font-mono text-blue-400 truncate block">{q.sql.substring(0, 100)}</code>
|
||||
<code className="text-xs font-mono text-blue-400 truncate block">{q.sql.substring(0, 120)}</code>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={q.execution_time_ms > 1000 ? 'text-red-400 font-medium' : ''}>{q.execution_time_ms}ms</span>
|
||||
@@ -90,7 +113,7 @@ export default function QueriesPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- NewQueryPanel ---------- */
|
||||
/* ---------- NewQueryPanel with templates + CodeMirror ---------- */
|
||||
function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecuted: (item: QueryHistoryItem) => void }) {
|
||||
const [sql, setSql] = useState('');
|
||||
const [instanceId, setInstanceId] = useState('');
|
||||
@@ -99,6 +122,7 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<QueryResult | null>(null);
|
||||
const [showTemplates, setShowTemplates] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
instanceApi.list().then(res => {
|
||||
@@ -107,8 +131,30 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const selectedInstance = useMemo(() => {
|
||||
return instances.find(inst => String(inst.id) === instanceId);
|
||||
}, [instances, instanceId]);
|
||||
|
||||
const dbType = (selectedInstance?.db_type || 'mysql') as DBType;
|
||||
const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const;
|
||||
|
||||
const templates = useMemo(() => getTemplatesForType(dbType), [dbType]);
|
||||
|
||||
const placeholderMap: Record<DBType, string> = {
|
||||
mysql: "-- MySQL 查询\n-- 仅支持 SELECT, SHOW, EXPLAIN, DESCRIBE\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
|
||||
postgresql: "-- PostgreSQL 查询\n-- 仅支持 SELECT, EXPLAIN\n\nSELECT id, name, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;",
|
||||
redis: "-- Redis 命令\nGET user:1001\nHGETALL user:1001\nKEYS user:*",
|
||||
mongodb: "// MongoDB Shell\n// 支持 find, aggregate, count, distinct\n\ndb.users.find({ status: 'active' }).limit(100)",
|
||||
};
|
||||
|
||||
const handleApplyTemplate = (tpl: typeof queryTemplates[0]) => {
|
||||
const templateSQL = getTemplateSQL(tpl, dbType);
|
||||
setSql(templateSQL);
|
||||
setShowTemplates(false);
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!sql || !instanceId) { setError('请选择实例并输入 SQL'); return; }
|
||||
if (!sql || !instanceId) { setError('请选择实例并输入查询语句'); return; }
|
||||
setExecuting(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
@@ -126,6 +172,7 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
||||
sql,
|
||||
instance_id: Number(instanceId),
|
||||
database,
|
||||
db_type: dbType,
|
||||
status: 'success',
|
||||
execution_time_ms: execTime,
|
||||
result_preview: JSON.stringify(res.rows?.[0] || {}).substring(0, 100),
|
||||
@@ -139,6 +186,7 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
||||
sql,
|
||||
instance_id: Number(instanceId),
|
||||
database,
|
||||
db_type: dbType,
|
||||
status: 'failed',
|
||||
execution_time_ms: 0,
|
||||
result_preview: msg,
|
||||
@@ -158,59 +206,124 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
||||
if (typeof value === 'string' && value.length > 7) return value.substring(0, 3) + '****' + value.substring(value.length - 4);
|
||||
return '***';
|
||||
};
|
||||
|
||||
const sensitiveKeywords = ['password', 'pwd', 'secret', 'token', 'phone', 'mobile', 'email', 'id_card', 'card_no'];
|
||||
|
||||
return (
|
||||
<div className="space-y-5 max-w-5xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">新建查询</h1>
|
||||
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"><X className="w-6 h-6" /></button>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold">新建查询</h1>
|
||||
{selectedInstance && (
|
||||
<span className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-[var(--dg-bg-tertiary)]">
|
||||
<img src={dbTypeIcon[dbType]} alt="" className="w-4 h-4 object-contain" />
|
||||
{dbTypeLabel[dbType] || dbType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Instance + Database selector */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">目标实例 <span className="text-red-400">*</span></label>
|
||||
<select value={instanceId} onChange={e => setInstanceId(e.target.value)} className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
|
||||
<select
|
||||
value={instanceId}
|
||||
onChange={e => setInstanceId(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||
>
|
||||
<option value="">请选择实例</option>
|
||||
{instances.map(inst => (
|
||||
<option key={inst.id} value={inst.id}>{inst.name} ({inst.host}:{inst.port})</option>
|
||||
<option key={inst.id} value={inst.id}>
|
||||
{inst.name} ({inst.host}:{inst.port}) — {dbTypeLabel[inst.db_type] || inst.db_type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">目标数据库</label>
|
||||
<input value={database} onChange={e => setDatabase(e.target.value)} className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" placeholder="可选,默认数据库" />
|
||||
<input
|
||||
value={database}
|
||||
onChange={e => setDatabase(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
||||
placeholder="可选,默认数据库"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">SQL 语句 <span className="text-red-400">*</span></label>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
查询语句 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<button
|
||||
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)] rounded-lg transition-colors"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-blue-400" />
|
||||
快捷模板 ({templates.length})
|
||||
<ChevronDown className={`w-3.5 h-3.5 transition-transform ${showTemplates ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Templates dropdown */}
|
||||
{showTemplates && (
|
||||
<div className="mb-3 p-3 bg-[var(--dg-bg-tertiary)] rounded-lg max-h-64 overflow-y-auto">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{templates.map(tpl => (
|
||||
<button
|
||||
key={tpl.id}
|
||||
onClick={() => handleApplyTemplate(tpl)}
|
||||
className="text-left p-3 rounded-lg bg-[var(--dg-bg-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">{tpl.category}</span>
|
||||
<span className="text-sm font-medium">{tpl.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{tpl.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CodeMirror Editor */}
|
||||
<div className="dg-card overflow-hidden">
|
||||
<textarea
|
||||
className="w-full min-h-[200px] p-4 bg-[var(--dg-bg-primary)] text-sm font-mono text-[var(--dg-text-primary)] resize-y border-none outline-none placeholder:text-[var(--dg-text-muted)]"
|
||||
placeholder={"-- 仅支持 SELECT 查询\n-- DDL/DML 操作请通过工单系统提交\n\nSELECT id, name, email, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;"}
|
||||
<SqlEditor
|
||||
value={sql}
|
||||
onChange={e => setSql(e.target.value)}
|
||||
onKeyDown={e => { if (e.ctrlKey && e.key === 'Enter') handleExecute(); }}
|
||||
onChange={setSql}
|
||||
placeholder={placeholderMap[dbType]}
|
||||
language={editorLang}
|
||||
height="220px"
|
||||
onExecute={handleExecute}
|
||||
/>
|
||||
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
|
||||
<span>{sql.length} 字符</span>
|
||||
<span>Ctrl + Enter 执行查询</span>
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3.5 h-3.5 object-contain" />}
|
||||
{dbTypeLabel[dbType]} 模式
|
||||
</span>
|
||||
<span>{sql.length} 字符 · Ctrl+Enter 执行 · Ctrl+Shift+F 格式化</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security notice */}
|
||||
<div className="flex items-center gap-3 p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">查询结果中的敏感字段(密码、手机号等)将自动脱敏显示。仅支持 SELECT 查询,DML/DDL 请走工单审批。</span>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">
|
||||
查询结果中的敏感字段将自动脱敏。MySQL/PG 仅支持 SELECT 查询,Redis/MongoDB 支持只读命令。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
{/* 查询结果 */}
|
||||
{/* Query result */}
|
||||
{result && (
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]">
|
||||
@@ -264,11 +377,18 @@ function NewQueryPanel({ onCancel, onExecuted }: { onCancel: () => void; onExecu
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={onCancel} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)]">取消</button>
|
||||
<button onClick={handleExecute} disabled={executing} className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 disabled:opacity-50">
|
||||
{executing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />} {executing ? '执行中...' : '执行查询'}
|
||||
<button onClick={onCancel} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)]">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={executing}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{executing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
{executing ? '执行中...' : '执行查询'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Search, Clock, Database, AlertTriangle, ChevronDown, ChevronRight,
|
||||
BarChart3, Play, Loader2, RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||||
} from 'recharts';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
import { slowLogApi } from '@/api/slowLog';
|
||||
|
||||
/* ───────── Types ───────── */
|
||||
|
||||
interface SlowLogItem {
|
||||
id: string;
|
||||
sql: string;
|
||||
start_time: string;
|
||||
query_time: number;
|
||||
lock_time: number;
|
||||
rows_sent: number;
|
||||
rows_examined: number;
|
||||
database: string;
|
||||
}
|
||||
|
||||
interface TopQuery {
|
||||
sql: string;
|
||||
count: number;
|
||||
avg_time_ms: number;
|
||||
max_time_ms: number;
|
||||
total_time_ms: number;
|
||||
}
|
||||
|
||||
interface InstanceOption {
|
||||
id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
db_type: string;
|
||||
}
|
||||
|
||||
/* ───────── Helpers ───────── */
|
||||
|
||||
function formatQueryTime(seconds: number): string {
|
||||
if (seconds >= 1) return `${seconds.toFixed(2)}s`;
|
||||
return `${(seconds * 1000).toFixed(0)}ms`;
|
||||
}
|
||||
|
||||
function queryTimeColor(seconds: number): string {
|
||||
if (seconds > 5) return 'text-red-400';
|
||||
if (seconds > 1) return 'text-orange-400';
|
||||
if (seconds > 0.5) return 'text-yellow-400';
|
||||
return 'text-[var(--dg-text-primary)]';
|
||||
}
|
||||
|
||||
function queryTimeBg(seconds: number): string {
|
||||
if (seconds > 5) return 'bg-red-500/10';
|
||||
if (seconds > 1) return 'bg-orange-500/10';
|
||||
if (seconds > 0.5) return 'bg-yellow-500/10';
|
||||
return '';
|
||||
}
|
||||
|
||||
function truncateSql(sql: string, max: number): string {
|
||||
const oneLine = sql.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
if (oneLine.length <= max) return oneLine;
|
||||
return oneLine.slice(0, max) + '…';
|
||||
}
|
||||
|
||||
/* ───────── Component ───────── */
|
||||
|
||||
export default function SlowQueryPage() {
|
||||
// Instance state
|
||||
const [instances, setInstances] = useState<InstanceOption[]>([]);
|
||||
const [selectedInstanceId, setSelectedInstanceId] = useState<number | null>(null);
|
||||
const [instancesLoading, setInstancesLoading] = useState(false);
|
||||
|
||||
// Tab state
|
||||
const [activeTab, setActiveTab] = useState<'logs' | 'top'>('logs');
|
||||
|
||||
// Slow logs state
|
||||
const [logs, setLogs] = useState<SlowLogItem[]>([]);
|
||||
const [logsTotal, setLogsTotal] = useState(0);
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
const [expandedSqlIds, setExpandedSqlIds] = useState<Set<string>>(new Set());
|
||||
const [expandedRowId, setExpandedRowId] = useState<string | null>(null);
|
||||
|
||||
// Top queries state
|
||||
const [topQueries, setTopQueries] = useState<TopQuery[]>([]);
|
||||
const [topLoading, setTopLoading] = useState(false);
|
||||
|
||||
// Action states
|
||||
const [collecting, setCollecting] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
||||
|
||||
/* ── Fetch instances ── */
|
||||
|
||||
const fetchInstances = useCallback(async () => {
|
||||
setInstancesLoading(true);
|
||||
try {
|
||||
const res = await instanceApi.list({});
|
||||
const data: InstanceOption[] = Array.isArray(res) ? res : [];
|
||||
setInstances(data);
|
||||
if (data.length > 0 && !selectedInstanceId) {
|
||||
setSelectedInstanceId(data[0].id);
|
||||
}
|
||||
} catch {
|
||||
// keep existing
|
||||
} finally {
|
||||
setInstancesLoading(false);
|
||||
}
|
||||
}, [selectedInstanceId]);
|
||||
|
||||
useEffect(() => { fetchInstances(); }, [fetchInstances]);
|
||||
|
||||
/* ── Fetch slow logs ── */
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
if (!selectedInstanceId) return;
|
||||
setLogsLoading(true);
|
||||
try {
|
||||
const res = await slowLogApi.list(selectedInstanceId);
|
||||
setLogs(res.logs || []);
|
||||
setLogsTotal(res.total || 0);
|
||||
} catch {
|
||||
setLogs([]);
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}, [selectedInstanceId]);
|
||||
|
||||
/* ── Fetch top queries ── */
|
||||
|
||||
const fetchTopQueries = useCallback(async () => {
|
||||
setTopLoading(true);
|
||||
try {
|
||||
const res = await slowLogApi.topQueries();
|
||||
setTopQueries(res.queries || []);
|
||||
} catch {
|
||||
setTopQueries([]);
|
||||
} finally {
|
||||
setTopLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* ── Auto-fetch on tab/instance change ── */
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'logs') fetchLogs();
|
||||
if (activeTab === 'top') fetchTopQueries();
|
||||
}, [activeTab, fetchLogs, fetchTopQueries]);
|
||||
|
||||
/* ── Handlers ── */
|
||||
|
||||
const handleCollect = async () => {
|
||||
if (!selectedInstanceId) return;
|
||||
setCollecting(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await slowLogApi.collect(selectedInstanceId, '', 100);
|
||||
setMessage({ text: `成功收集 ${res.collected} 条慢日志`, type: 'success' });
|
||||
fetchLogs();
|
||||
} catch {
|
||||
setMessage({ text: '收集慢日志失败', type: 'error' });
|
||||
} finally {
|
||||
setCollecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateReport = async () => {
|
||||
if (!selectedInstanceId) return;
|
||||
setGenerating(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await slowLogApi.report(selectedInstanceId);
|
||||
setMessage({ text: '报告已生成', type: 'success' });
|
||||
} catch {
|
||||
setMessage({ text: '生成报告失败', type: 'error' });
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSqlExpand = (id: string) => {
|
||||
setExpandedSqlIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleRowExpand = (id: string) => {
|
||||
setExpandedRowId(prev => (prev === id ? null : id));
|
||||
};
|
||||
|
||||
/* ── Chart data ── */
|
||||
|
||||
const chartData = topQueries.slice(0, 10).map((q, idx) => ({
|
||||
name: `#${idx + 1}`,
|
||||
avgTime: q.avg_time_ms,
|
||||
maxTime: q.max_time_ms,
|
||||
count: q.count,
|
||||
sql: truncateSql(q.sql, 50),
|
||||
}));
|
||||
|
||||
/* ── Render ── */
|
||||
|
||||
const selectedInstance = instances.find(i => i.id === selectedInstanceId);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Page Header */}
|
||||
<PageHeader
|
||||
title="慢查询分析"
|
||||
description="数据库慢查询监控与性能分析"
|
||||
/>
|
||||
|
||||
{/* Controls bar */}
|
||||
<Card className="dg-card">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Instance selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||
<select
|
||||
value={selectedInstanceId ?? ''}
|
||||
onChange={e => setSelectedInstanceId(Number(e.target.value) || null)}
|
||||
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-[200px]"
|
||||
>
|
||||
{instancesLoading ? (
|
||||
<option>加载中...</option>
|
||||
) : (
|
||||
instances.map(inst => (
|
||||
<option key={inst.id} value={inst.id}>{inst.name} ({inst.host})</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<button
|
||||
onClick={handleCollect}
|
||||
disabled={collecting || !selectedInstanceId}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{collecting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
收集慢日志
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleGenerateReport}
|
||||
disabled={generating || !selectedInstanceId}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] hover:border-[var(--dg-text-muted)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{generating ? <Loader2 className="w-4 h-4 animate-spin" /> : <BarChart3 className="w-4 h-4" />}
|
||||
生成报告
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { if (activeTab === 'logs') fetchLogs(); else fetchTopQueries(); }}
|
||||
className="flex items-center gap-2 px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-all"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{selectedInstance && (
|
||||
<span className="text-xs text-[var(--dg-text-muted)] ml-auto">
|
||||
已选实例: {selectedInstance.name} · {selectedInstance.host}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toast message */}
|
||||
{message && (
|
||||
<div className={`mt-3 px-4 py-2.5 rounded-lg text-sm flex items-center gap-2 ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-500/10 text-green-400 border border-green-500/20'
|
||||
: 'bg-red-500/10 text-red-400 border border-red-500/20'
|
||||
}`}>
|
||||
{message.type === 'success' ? <Search className="w-4 h-4" /> : <AlertTriangle className="w-4 h-4" />}
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 p-1 bg-[var(--dg-bg-tertiary)] rounded-lg w-fit">
|
||||
{[
|
||||
{ key: 'logs' as const, label: '慢日志列表', icon: Clock },
|
||||
{ key: 'top' as const, label: 'TOP查询', icon: BarChart3 },
|
||||
].map(tab => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={'flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-colors '
|
||||
+ (activeTab === tab.key ? 'bg-blue-600 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]')}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab 1: Slow log list */}
|
||||
{activeTab === 'logs' && (
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]">
|
||||
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-blue-400" />
|
||||
慢日志列表
|
||||
<span className="text-xs font-normal text-[var(--dg-text-muted)] ml-2">共 {logsTotal} 条</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{logsLoading ? (
|
||||
<div className="p-10 text-center text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2" />
|
||||
加载中...
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="p-10 text-center text-[var(--dg-text-muted)]">
|
||||
<Database className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">暂无慢日志数据</p>
|
||||
<p className="text-xs mt-1">请先选择实例并点击"收集慢日志"</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)]">
|
||||
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium w-8" />
|
||||
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">SQL</th>
|
||||
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">执行时间</th>
|
||||
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">锁等待</th>
|
||||
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">扫描行数</th>
|
||||
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">返回行数</th>
|
||||
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">数据库</th>
|
||||
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map(log => {
|
||||
const isExpanded = expandedRowId === log.id;
|
||||
const isSqlExpanded = expandedSqlIds.has(log.id);
|
||||
return (
|
||||
<tr key={log.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
{/* Expand toggle */}
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => toggleRowExpand(log.id)}
|
||||
className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
|
||||
>
|
||||
{isExpanded
|
||||
? <ChevronDown className="w-4 h-4" />
|
||||
: <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 max-w-md">
|
||||
<code className={`text-xs font-mono ${queryTimeColor(log.query_time)}`}>
|
||||
{isSqlExpanded ? log.sql.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim() : truncateSql(log.sql, 80)}
|
||||
</code>
|
||||
{log.sql.length > 80 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleSqlExpand(log.id); }}
|
||||
className="ml-2 text-xs text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
{isSqlExpanded ? '收起' : '展开'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${queryTimeBg(log.query_time)} ${queryTimeColor(log.query_time)}`}>
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatQueryTime(log.query_time)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
|
||||
{formatQueryTime(log.lock_time)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
|
||||
{log.rows_examined.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
|
||||
{log.rows_sent.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] text-xs rounded-md">
|
||||
{log.database}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--dg-text-muted)] text-xs whitespace-nowrap">
|
||||
{new Date(log.start_time).toLocaleString('zh-CN', {
|
||||
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EXPLAIN expanded section */}
|
||||
{expandedRowId && (() => {
|
||||
const log = logs.find(l => l.id === expandedRowId);
|
||||
if (!log) return null;
|
||||
return (
|
||||
<div className="border-t border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50 p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Search className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-sm font-semibold">EXPLAIN 分析</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">(需要数据源连接)</span>
|
||||
</div>
|
||||
|
||||
{/* Full SQL */}
|
||||
<div className="mb-4 p-3 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg">
|
||||
<code className="text-xs font-mono text-[var(--dg-text-secondary)] break-all whitespace-pre-wrap">
|
||||
{log.sql}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Mock EXPLAIN tree */}
|
||||
<div className="flex items-center gap-0 flex-wrap">
|
||||
{['Table Scan', 'Filter', 'Sort', 'Limit'].map((step, idx) => (
|
||||
<div key={step} className="flex items-center">
|
||||
<div className={`px-4 py-2 rounded-lg border text-sm font-medium ${
|
||||
idx === 0
|
||||
? 'bg-red-500/10 border-red-500/30 text-red-400'
|
||||
: idx === 1
|
||||
? 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400'
|
||||
: idx === 2
|
||||
? 'bg-blue-500/10 border-blue-500/30 text-blue-400'
|
||||
: 'bg-green-500/10 border-green-500/30 text-green-400'
|
||||
}`}>
|
||||
{step}
|
||||
</div>
|
||||
{idx < 3 && (
|
||||
<span className="mx-2 text-[var(--dg-text-muted)] text-lg">→</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Query metrics summary */}
|
||||
<div className="mt-4 grid grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: '执行时间', value: formatQueryTime(log.query_time), color: queryTimeColor(log.query_time) },
|
||||
{ label: '锁等待', value: formatQueryTime(log.lock_time), color: 'text-[var(--dg-text-secondary)]' },
|
||||
{ label: '扫描行数', value: log.rows_examined.toLocaleString(), color: 'text-[var(--dg-text-secondary)]' },
|
||||
{ label: '返回行数', value: log.rows_sent.toLocaleString(), color: 'text-[var(--dg-text-secondary)]' },
|
||||
].map(m => (
|
||||
<div key={m.label} className="p-3 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-1">{m.label}</div>
|
||||
<div className={`text-base font-semibold ${m.color}`}>{m.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tab 2: Top queries */}
|
||||
{activeTab === 'top' && (
|
||||
<div className="space-y-4">
|
||||
{/* Bar chart */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]">
|
||||
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-purple-400" />
|
||||
TOP 10 查询 — 平均执行时间
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-5">
|
||||
{topLoading ? (
|
||||
<div className="p-10 text-center text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2" />
|
||||
加载中...
|
||||
</div>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="p-10 text-center text-[var(--dg-text-muted)]">
|
||||
<BarChart3 className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">暂无 TOP 查询数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<BarChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" />
|
||||
<XAxis dataKey="name" tick={{ fill: 'var(--dg-text-muted)', fontSize: 12 }} />
|
||||
<YAxis
|
||||
tick={{ fill: 'var(--dg-text-muted)', fontSize: 12 }}
|
||||
label={{ value: 'ms', position: 'insideTopRight', fill: 'var(--dg-text-muted)', fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--dg-bg-secondary)',
|
||||
border: '1px solid var(--dg-border)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number, name: string) => [
|
||||
`${value.toFixed(1)} ms`,
|
||||
name === 'avgTime' ? '平均时间' : '最大时间',
|
||||
]}
|
||||
/>
|
||||
<Bar dataKey="avgTime" fill="#6366f1" radius={[4, 4, 0, 0]} name="avgTime" />
|
||||
<Bar dataKey="maxTime" fill="#a855f7" radius={[4, 4, 0, 0]} name="maxTime" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top queries table */}
|
||||
<Card className="dg-card">
|
||||
<CardHeader className="border-b border-[var(--dg-border)]">
|
||||
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-400" />
|
||||
查询详情
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{topLoading ? (
|
||||
<div className="p-10 text-center text-[var(--dg-text-muted)]">加载中...</div>
|
||||
) : topQueries.length === 0 ? (
|
||||
<div className="p-10 text-center text-[var(--dg-text-muted)]">暂无数据</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)]">
|
||||
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium w-10">#</th>
|
||||
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">SQL</th>
|
||||
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">执行次数</th>
|
||||
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">平均时间</th>
|
||||
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">最大时间</th>
|
||||
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">总时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{topQueries.map((q, idx) => (
|
||||
<tr key={idx} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
||||
<td className="px-4 py-3 text-[var(--dg-text-muted)] font-mono text-xs">{idx + 1}</td>
|
||||
<td className="px-4 py-3 max-w-md">
|
||||
<code className="text-xs font-mono text-[var(--dg-text-secondary)]">
|
||||
{truncateSql(q.sql, 100)}
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-0.5 bg-blue-500/10 text-blue-400 text-xs rounded-md font-medium">
|
||||
{q.count}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs font-medium ${queryTimeColor(q.avg_time_ms / 1000)}`}>
|
||||
{q.avg_time_ms.toFixed(1)} ms
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs font-medium ${queryTimeColor(q.max_time_ms / 1000)}`}>
|
||||
{q.max_time_ms.toFixed(1)} ms
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--dg-text-secondary)] text-xs">
|
||||
{q.total_time_ms >= 1000
|
||||
? `${(q.total_time_ms / 1000).toFixed(2)}s`
|
||||
: `${q.total_time_ms.toFixed(0)}ms`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user