feat: improve datasource query audit workflows
This commit is contained in:
1 parent
74748e4914
commit
644981eef4
32 files changed
+780
-3061
No files matched your search
+1
-1
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
Generated
+4
-1677
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "my-app",
|
||||
"name": "opsxsql-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -82,7 +82,6 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"kimi-plugin-inspect-react": "^1.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
+59
-47
@@ -1,31 +1,41 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from '@/hooks/useAuth';
|
||||
import { ThemeProvider } from '@/hooks/useTheme';
|
||||
import Layout from '@/components/Layout';
|
||||
import LoginPage from '@/pages/LoginPage';
|
||||
import Dashboard from '@/pages/Dashboard';
|
||||
import InstancePage from '@/pages/InstancePage';
|
||||
import TicketsPage from '@/pages/TicketsPage';
|
||||
import QueriesPage from '@/pages/QueriesPage';
|
||||
import RolesPage from '@/pages/RolesPage';
|
||||
import UsersPage from '@/pages/UsersPage';
|
||||
import AuditRulesPage from '@/pages/AuditRulesPage';
|
||||
import WorkflowPage from '@/pages/WorkflowPage';
|
||||
import AutoTaskPage from '@/pages/AutoTaskPage';
|
||||
import SettingsPage from '@/pages/SettingsPage';
|
||||
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 { ToastContainer } from '@/components/ToastContainer';
|
||||
|
||||
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
|
||||
const Dashboard = lazy(() => import('@/pages/Dashboard'));
|
||||
const InstancePage = lazy(() => import('@/pages/InstancePage'));
|
||||
const TicketsPage = lazy(() => import('@/pages/TicketsPage'));
|
||||
const QueriesPage = lazy(() => import('@/pages/QueriesPage'));
|
||||
const RolesPage = lazy(() => import('@/pages/RolesPage'));
|
||||
const UsersPage = lazy(() => import('@/pages/UsersPage'));
|
||||
const AuditRulesPage = lazy(() => import('@/pages/AuditRulesPage'));
|
||||
const WorkflowPage = lazy(() => import('@/pages/WorkflowPage'));
|
||||
const AutoTaskPage = lazy(() => import('@/pages/AutoTaskPage'));
|
||||
const SettingsPage = lazy(() => import('@/pages/SettingsPage'));
|
||||
const AuditLogsPage = lazy(() => import('@/pages/AuditLogsPage'));
|
||||
const DataDictionaryPage = lazy(() => import('@/pages/DataDictionaryPage'));
|
||||
const InstanceDetailPage = lazy(() => import('@/pages/InstanceDetailPage'));
|
||||
const RedisDetailPage = lazy(() => import('@/pages/RedisDetailPage'));
|
||||
const MongoDBDetailPage = lazy(() => import('@/pages/MongoDBDetailPage'));
|
||||
const TicketCreatePage = lazy(() => import('@/pages/TicketCreatePage'));
|
||||
const TicketDetailPage = lazy(() => import('@/pages/TicketDetailPage'));
|
||||
const InstanceGroupPage = lazy(() => import('@/pages/InstanceGroupPage'));
|
||||
|
||||
function PageFallback() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-[var(--dg-text-muted)]">
|
||||
加载中...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteGuard() {
|
||||
const { isLoggedIn, hasPermission } = useAuth();
|
||||
const location = useLocation();
|
||||
const path = location.pathname.split('/')[1] || 'dashboard';
|
||||
|
||||
if (!isLoggedIn) {
|
||||
@@ -51,33 +61,35 @@ function LoginGuard() {
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginGuard />} />
|
||||
<Route element={<RouteGuard />}>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="instances" element={<InstancePage />} />
|
||||
<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="roles" element={<RolesPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="audit-rules" element={<AuditRulesPage />} />
|
||||
<Route path="audit-logs" element={<AuditLogsPage />} />
|
||||
<Route path="workflow" element={<WorkflowPage />} />
|
||||
<Route path="auto-task" element={<AutoTaskPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginGuard />} />
|
||||
<Route element={<RouteGuard />}>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="instances" element={<InstancePage />} />
|
||||
<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="roles" element={<RolesPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="audit-rules" element={<AuditRulesPage />} />
|
||||
<Route path="audit-logs" element={<AuditLogsPage />} />
|
||||
<Route path="workflow" element={<WorkflowPage />} />
|
||||
<Route path="auto-task" element={<AutoTaskPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,4 +106,4 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { X, Loader2 } from 'lucide-react';
|
||||
import { X, Loader2, Zap, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { instanceApi } from '@/api/instance';
|
||||
import { workflowApi } from '@/api/workflow';
|
||||
|
||||
@@ -21,6 +21,7 @@ interface InstanceData {
|
||||
excluded_databases?: string[] | null;
|
||||
mask_fields?: string | null;
|
||||
group_id?: number | null;
|
||||
cluster_mode?: boolean;
|
||||
}
|
||||
|
||||
interface WorkflowItem {
|
||||
@@ -49,6 +50,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
|
||||
const [database, setDatabase] = useState(instance.database || '');
|
||||
const [env, setEnv] = useState(instance.env || 'development');
|
||||
const [accessMode, setAccessMode] = useState(instance.access_mode || 'readwrite');
|
||||
const [clusterMode, setClusterMode] = useState(instance.cluster_mode === true);
|
||||
const [workflowId, setWorkflowId] = useState<number | null>(instance.workflow_id || null);
|
||||
const [workflows, setWorkflows] = useState<WorkflowItem[]>([]);
|
||||
// excluded_databases: comma-separated input
|
||||
@@ -58,6 +60,8 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
|
||||
// mask_fields: comma-separated field names
|
||||
const [maskFields, setMaskFields] = useState(instance.mask_fields || '');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string; cluster_mode?: boolean } | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
/* Sync when instance changes */
|
||||
@@ -70,11 +74,13 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
|
||||
setDatabase(instance.database || '');
|
||||
setEnv(instance.env || 'development');
|
||||
setAccessMode(instance.access_mode || 'readwrite');
|
||||
setClusterMode(instance.cluster_mode === true);
|
||||
setWorkflowId(instance.workflow_id || null);
|
||||
setExcludedDbs(
|
||||
Array.isArray(instance.excluded_databases) ? (instance.excluded_databases as string[]).join(', ') : (instance.excluded_databases || '')
|
||||
);
|
||||
setMaskFields(instance.mask_fields || '');
|
||||
setTestResult(null);
|
||||
setError('');
|
||||
}, [instance]);
|
||||
|
||||
@@ -105,6 +111,7 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
|
||||
database: database.trim() || undefined,
|
||||
env,
|
||||
access_mode: accessMode,
|
||||
cluster_mode: instance.db_type === 'redis' ? clusterMode : undefined,
|
||||
workflow_id: workflowId,
|
||||
excluded_databases: parsedExcluded,
|
||||
mask_fields: maskFields.trim() || undefined,
|
||||
@@ -129,7 +136,35 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [name, host, port, username, password, database, env, accessMode, workflowId, excludedDbs, maskFields, instance.id, onSaved]);
|
||||
}, [name, host, port, username, password, database, env, accessMode, clusterMode, workflowId, excludedDbs, maskFields, instance.id, instance.db_type, onSaved]);
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
setError('');
|
||||
try {
|
||||
const payload = {
|
||||
db_type: instance.db_type,
|
||||
host: host.trim(),
|
||||
port: Number(port),
|
||||
username: username.trim() || undefined,
|
||||
password: password || '',
|
||||
database: database.trim() || undefined,
|
||||
cluster_mode: instance.db_type === 'redis' ? clusterMode : undefined,
|
||||
};
|
||||
const res = mode === 'edit' && !password
|
||||
? await instanceApi.testConnection(instance.id)
|
||||
: await instanceApi.testNewConnection(payload);
|
||||
setTestResult(res as { success: boolean; message: string; version?: string; cluster_mode?: boolean });
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string; message?: string } } })?.response?.data?.detail
|
||||
|| (e as { response?: { data?: { message?: string } } })?.response?.data?.message
|
||||
|| '连接失败';
|
||||
setTestResult({ success: false, message: msg });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, [instance.db_type, instance.id, host, port, username, password, database, clusterMode, mode]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -144,7 +179,10 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4">
|
||||
<h2 className="text-base font-semibold">{mode === 'clone' ? '克隆数据源' : '编辑数据源'}</h2>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">{mode === 'clone' ? '克隆数据源' : '编辑数据源'}</h2>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1">{instance.db_type.toUpperCase()} · {instance.host}:{instance.port}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -186,9 +224,35 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>默认数据库</label>
|
||||
<input className={inputCls} value={database} onChange={e => setDatabase(e.target.value)} placeholder="可选" />
|
||||
<input className={inputCls} value={database} onChange={e => setDatabase(e.target.value)} placeholder={instance.db_type === 'redis' ? 'Redis 数据库编号,默认 0' : '可选'} />
|
||||
</div>
|
||||
|
||||
{instance.db_type === 'redis' && (
|
||||
<div>
|
||||
<label className={labelCls}>Redis 部署模式</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ value: false, label: '单节点', desc: '普通 Redis 或哨兵入口' },
|
||||
{ value: true, label: '集群', desc: 'Redis Cluster,按 slot 路由' },
|
||||
].map(item => (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
onClick={() => { setClusterMode(item.value); setTestResult(null); }}
|
||||
className={`text-left p-3 rounded-lg border transition-colors ${
|
||||
clusterMode === item.value
|
||||
? 'bg-blue-500/10 border-blue-500/40 text-blue-400'
|
||||
: 'bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium">{item.label}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">{item.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>环境</label>
|
||||
@@ -220,6 +284,13 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
|
||||
{workflows.length === 0 && <p className="text-xs text-[var(--dg-text-muted)] mt-1">暂无可用审批流程</p>}
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm ${testResult.success ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
|
||||
{testResult.success ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
|
||||
<span>{testResult.success ? `连接成功${testResult.version ? ` · ${testResult.version}` : ''}${instance.db_type === 'redis' ? ` · ${testResult.cluster_mode ? '集群' : '单节点'}` : ''}` : testResult.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 排除数据库 */}
|
||||
<div>
|
||||
<label className={labelCls}>排除数据库 <span className="font-normal text-[var(--dg-text-muted)]">(逗号分隔,如 sys, mysql)</span></label>
|
||||
@@ -245,6 +316,14 @@ export default function EditInstanceDialog({ instance, open, onClose, onSaved, m
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-5 py-4">
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={testing || !host || !port}
|
||||
className="mr-auto flex items-center gap-2 px-4 py-2 text-sm bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg hover:bg-[var(--dg-bg-hover)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{testing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Zap className="w-4 h-4" />}
|
||||
{testing ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors">取消</button>
|
||||
<button onClick={handleSubmit} disabled={submitting} className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 disabled:opacity-50 rounded-lg transition-colors">
|
||||
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { menuSections, menuItems } from '@/constants/menu';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
|
||||
import request from '@/api/request';
|
||||
|
||||
const iconMap: Record<string, React.ElementType> = {
|
||||
LayoutDashboard, Database, Server, Zap, Leaf,
|
||||
@@ -25,7 +26,7 @@ export default function Layout() {
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
import('@/api/request').then(m => m.default.get('/dashboard/stats'))
|
||||
request.get('/dashboard/stats')
|
||||
.then((data: any) => setPendingCount(data?.ticket_pending ?? 0))
|
||||
.catch(() => {});
|
||||
}, [location.pathname]);
|
||||
|
||||
@@ -321,6 +321,24 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
|
||||
if (isRedis && instanceId) fetchRedisKeys();
|
||||
}, [isRedis, instanceId, fetchRedisKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
const keyword = redisPattern.trim();
|
||||
if (!isRedis || redisKeys.length === 0 || !keyword || keyword === '*') return;
|
||||
|
||||
setRedisExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
redisKeys.forEach(item => {
|
||||
const parts = item.key.split(':');
|
||||
let prefix = '';
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
prefix += (i === 0 ? '' : ':') + parts[i];
|
||||
next.add(prefix);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, [isRedis, redisKeys, redisPattern]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Fetch Redis key detail */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-lg border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -352,11 +352,11 @@
|
||||
|
||||
@layer utilities {
|
||||
.dg-card {
|
||||
@apply bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl;
|
||||
@apply bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg;
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
.dg-card-hover {
|
||||
@apply transition-all duration-300 hover:-translate-y-0.5;
|
||||
@apply transition-colors duration-200;
|
||||
}
|
||||
.dg-card-hover:hover {
|
||||
border-color: var(--dg-card-hover-border);
|
||||
@@ -374,16 +374,13 @@
|
||||
color: var(--dg-text-secondary);
|
||||
}
|
||||
.dg-gradient-text {
|
||||
background: linear-gradient(to right, var(--dg-gradient-text-start), var(--dg-gradient-text-end));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
color: var(--dg-text-primary);
|
||||
}
|
||||
.dg-gradient-bg {
|
||||
background: linear-gradient(to right, var(--dg-gradient-start), var(--dg-gradient-end));
|
||||
background: var(--dg-blue);
|
||||
}
|
||||
.dg-gradient-br {
|
||||
background: linear-gradient(to bottom right, var(--dg-gradient-start), var(--dg-gradient-end));
|
||||
background: var(--dg-blue);
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
|
||||
@@ -125,7 +125,7 @@ export const queryTemplates: QueryTemplate[] = [
|
||||
category: '性能分析',
|
||||
sql: {
|
||||
mysql: 'EXPLAIN SELECT ...',
|
||||
postgresql: 'EXPLAIN ANALYZE SELECT ...',
|
||||
postgresql: 'EXPLAIN SELECT ...',
|
||||
mongodb: 'db.{collection}.find({...}).explain()',
|
||||
},
|
||||
supportedTypes: ['mysql', 'postgresql', 'mongodb'],
|
||||
|
||||
@@ -234,6 +234,13 @@ export default function AuditRulesPage() {
|
||||
return groups;
|
||||
}, [filteredRules]);
|
||||
|
||||
const ruleStats = useMemo(() => ({
|
||||
total: rules.length,
|
||||
enabled: rules.filter(rule => rule.is_enabled).length,
|
||||
error: rules.filter(rule => rule.severity === 'error').length,
|
||||
warning: rules.filter(rule => rule.severity === 'warning').length,
|
||||
}), [rules]);
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* Render helpers */
|
||||
/* ---------------------------------------------------------------- */
|
||||
@@ -441,6 +448,25 @@ export default function AuditRulesPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div className="dg-card p-4">
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">规则总数</div>
|
||||
<div className="text-2xl font-semibold mt-1">{ruleStats.total}</div>
|
||||
</div>
|
||||
<div className="dg-card p-4">
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">已启用</div>
|
||||
<div className="text-2xl font-semibold mt-1 text-emerald-400">{ruleStats.enabled}</div>
|
||||
</div>
|
||||
<div className="dg-card p-4">
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">错误级别</div>
|
||||
<div className="text-2xl font-semibold mt-1 text-red-400">{ruleStats.error}</div>
|
||||
</div>
|
||||
<div className="dg-card p-4">
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">警告级别</div>
|
||||
<div className="text-2xl font-semibold mt-1 text-amber-400">{ruleStats.warning}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DB Type Tabs */}
|
||||
<div className="dg-card p-1.5 flex gap-1 flex-wrap">
|
||||
{DB_TABS.map((tab) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/constants';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { dashboardApi } from '@/api/dashboard';
|
||||
import request from '@/api/request';
|
||||
import type { DashboardStats } from '@/types';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -83,7 +84,7 @@ export default function Dashboard() {
|
||||
const [queryStats, setQueryStats] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
import('@/api/request').then(m => m.default.get('/announcements')).then(res => {
|
||||
request.get('/announcements').then(res => {
|
||||
const list = Array.isArray(res) ? res : [];
|
||||
setAnnouncements(list.map((a: any) => ({
|
||||
id: String(a.id),
|
||||
@@ -114,22 +115,20 @@ export default function Dashboard() {
|
||||
|
||||
/* ---- Instances & Tickets ---- */
|
||||
useEffect(() => {
|
||||
import('@/api/request').then(m => {
|
||||
m.default.get('/instances').then((res: any) => {
|
||||
const list = Array.isArray(res) ? res : (res?.items || res?.data || []);
|
||||
setDbInstances(list.map((i: any) => ({
|
||||
id: i.id, name: i.name, dbType: i.db_type || i.dbType,
|
||||
status: i.status || 'online',
|
||||
healthScore: i.health_score ?? i.healthScore ?? 100,
|
||||
host: i.host || '', port: i.port || '',
|
||||
qps: i.qps ?? 0, connections: i.connections ?? 0,
|
||||
})));
|
||||
}).catch(() => setDbInstances([]));
|
||||
m.default.get('/dashboard/my-tickets').then((res: any) => {
|
||||
const list = Array.isArray(res) ? res : [];
|
||||
setTickets(list);
|
||||
}).catch(() => setTickets([]));
|
||||
});
|
||||
request.get('/instances').then((res: any) => {
|
||||
const list = Array.isArray(res) ? res : (res?.items || res?.data || []);
|
||||
setDbInstances(list.map((i: any) => ({
|
||||
id: i.id, name: i.name, dbType: i.db_type || i.dbType,
|
||||
status: i.status || 'online',
|
||||
healthScore: i.health_score ?? i.healthScore ?? 100,
|
||||
host: i.host || '', port: i.port || '',
|
||||
qps: i.qps ?? 0, connections: i.connections ?? 0,
|
||||
})));
|
||||
}).catch(() => setDbInstances([]));
|
||||
request.get('/dashboard/my-tickets').then((res: any) => {
|
||||
const list = Array.isArray(res) ? res : [];
|
||||
setTickets(list);
|
||||
}).catch(() => setTickets([]));
|
||||
}, []);
|
||||
|
||||
/* ---- Announcements ---- */
|
||||
@@ -245,21 +244,20 @@ export default function Dashboard() {
|
||||
)}
|
||||
|
||||
{/* ---- Stats Cards ---- */}
|
||||
<div className="grid grid-cols-3 gap-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => <StatCardSkeleton key={i} />)
|
||||
) : (
|
||||
statsCards.map((s, i) => (
|
||||
<Card key={i} className="dg-card dg-card-hover relative overflow-hidden">
|
||||
<div className={`absolute top-0 right-0 w-20 h-20 ${s.bg} rounded-bl-[40px] opacity-30`} />
|
||||
<CardContent className="p-5 relative z-10">
|
||||
<Card key={i} className="dg-card dg-card-hover overflow-hidden">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-[var(--dg-text-secondary)] font-medium">{s.title}</span>
|
||||
<div className={`w-9 h-9 rounded-lg ${s.bg} flex items-center justify-center`}>
|
||||
<s.icon className={`w-4.5 h-4.5 ${s.color}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-4xl font-bold text-[var(--dg-text-primary)] tracking-tight">{s.value}</div>
|
||||
<div className="text-3xl font-semibold text-[var(--dg-text-primary)]">{s.value}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-2">{s.subtitle}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
XCircle, Eye, EyeOff, ChevronLeft, Gauge, Wifi,
|
||||
WifiOff, AlertTriangle, CheckCircle, Signal,
|
||||
LayoutGrid, ListFilter, X, Loader2, BarChart3, Users, Copy,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useActionPermission } from '@/hooks/useAuth';
|
||||
@@ -141,6 +142,7 @@ export default function InstancePage() {
|
||||
const [instances, setInstances] = useState<InstanceItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showQuickImport, setShowQuickImport] = useState(false);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [editingInstance, setEditingInstance] = useState<InstanceItem | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
@@ -316,6 +318,18 @@ export default function InstancePage() {
|
||||
action={canAdd ? { label: '添加实例', icon: Plus, onClick: () => setShowCreate(true), permission: 'instance.add' } : undefined}
|
||||
/>
|
||||
|
||||
{canAdd && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowQuickImport(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
快速导入
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type Tabs */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-1 p-1 bg-[var(--dg-bg-tertiary)] rounded-lg w-fit">
|
||||
@@ -717,10 +731,161 @@ export default function InstancePage() {
|
||||
mode="clone"
|
||||
/>
|
||||
)}
|
||||
{showQuickImport && (
|
||||
<QuickImportDialog
|
||||
onClose={() => setShowQuickImport(false)}
|
||||
onImported={() => { setShowQuickImport(false); fetchInstances(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Quick Import Dialog */
|
||||
/* ------------------------------------------------------------------ */
|
||||
type ImportRow = {
|
||||
name: string;
|
||||
db_type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
database?: string;
|
||||
env?: string;
|
||||
access_mode?: string;
|
||||
cluster_mode?: boolean;
|
||||
};
|
||||
|
||||
const IMPORT_TEMPLATE = `name,db_type,host,port,username,password,database,env,access_mode,cluster_mode
|
||||
prod-mysql,mysql,10.0.0.10,3306,root,secret,app,production,readwrite,false
|
||||
prod-pg,postgres,10.0.0.11,5432,postgres,secret,app,production,read,false
|
||||
prod-redis,redis,10.0.0.12,6379,,secret,,production,read,true
|
||||
prod-mongo,mongo,10.0.0.13,27017,root,secret,admin,production,readwrite,false`;
|
||||
|
||||
function parseQuickImport(text: string): ImportRow[] {
|
||||
const lines = text.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
||||
if (lines.length === 0) return [];
|
||||
const hasHeader = lines[0].toLowerCase().startsWith('name,');
|
||||
const dataLines = hasHeader ? lines.slice(1) : lines;
|
||||
return dataLines.map(line => {
|
||||
const parts = line.split(',').map(item => item.trim());
|
||||
const [name, dbType, host, port, username, password, database, env, accessMode, clusterMode] = parts;
|
||||
return {
|
||||
name,
|
||||
db_type: dbType,
|
||||
host,
|
||||
port: Number(port || DB_DEFAULT_PORTS[dbType] || 3306),
|
||||
username: username || undefined,
|
||||
password: password || undefined,
|
||||
database: database || undefined,
|
||||
env: env || 'development',
|
||||
access_mode: accessMode || 'readwrite',
|
||||
cluster_mode: clusterMode === 'true' || clusterMode === '1' || clusterMode === 'yes',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function QuickImportDialog({ onClose, onImported }: { onClose: () => void; onImported: () => void }) {
|
||||
const [text, setText] = useState(IMPORT_TEMPLATE);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [result, setResult] = useState<{ success: number; failed: string[] } | null>(null);
|
||||
const rows = useMemo(() => parseQuickImport(text), [text]);
|
||||
const validRows = rows.filter(row => row.name && row.db_type && row.host && row.port);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
setSubmitting(true);
|
||||
const failed: string[] = [];
|
||||
let success = 0;
|
||||
for (const row of validRows) {
|
||||
try {
|
||||
await instanceApi.create({
|
||||
...row,
|
||||
username: row.db_type === 'redis' ? row.username : (row.username || 'default'),
|
||||
password: row.password || '',
|
||||
cluster_mode: row.db_type === 'redis' ? row.cluster_mode : undefined,
|
||||
});
|
||||
success += 1;
|
||||
} catch (e: unknown) {
|
||||
const detail = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '创建失败';
|
||||
failed.push(`${row.name || row.host}: ${detail}`);
|
||||
}
|
||||
}
|
||||
setResult({ success, failed });
|
||||
setSubmitting(false);
|
||||
if (success > 0 && failed.length === 0) onImported();
|
||||
}, [validRows, onImported]);
|
||||
|
||||
return (
|
||||
<ModalOverlay open={true} onClose={onClose} width="max-w-4xl">
|
||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg shadow-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--dg-border)]">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">快速导入数据源</h2>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1">支持 MySQL、PostgreSQL、Redis、MongoDB。每行一个实例,逗号分隔。</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
className="w-full min-h-[220px] px-4 py-3 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-xs font-mono focus:border-blue-400 focus:outline-none resize-y"
|
||||
/>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
||||
<div className="text-sm font-medium mb-2">字段说明</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] leading-6">
|
||||
name, db_type, host, port, username, password, database, env, access_mode, cluster_mode
|
||||
<br />
|
||||
db_type 使用 mysql / postgres / redis / mongo;Redis 集群将 cluster_mode 写 true。
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
||||
<div className="text-sm font-medium mb-2">预览</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">
|
||||
共解析 {rows.length} 行,可导入 {validRows.length} 行。
|
||||
</div>
|
||||
<div className="mt-2 max-h-24 overflow-y-auto space-y-1">
|
||||
{validRows.slice(0, 5).map(row => (
|
||||
<div key={`${row.name}-${row.host}`} className="text-xs">
|
||||
{row.name} · {row.db_type} · {row.host}:{row.port}{row.db_type === 'redis' && row.cluster_mode ? ' · 集群' : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{result && (
|
||||
<div className={`p-3 rounded-lg text-sm ${result.failed.length === 0 ? 'bg-green-500/10 text-green-400' : 'bg-yellow-500/10 text-yellow-400'}`}>
|
||||
成功 {result.success} 个,失败 {result.failed.length} 个
|
||||
{result.failed.length > 0 && (
|
||||
<div className="mt-2 text-xs space-y-1">
|
||||
{result.failed.map(item => <div key={item}>{item}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg hover:bg-[var(--dg-bg-hover)]">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={submitting || validRows.length === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg"
|
||||
>
|
||||
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
导入 {validRows.length} 个
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* InstanceDetail */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -1012,6 +1177,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
|
||||
const [showPwd, setShowPwd] = useState(false);
|
||||
const [database, setDatabase] = useState('');
|
||||
const [redisDb, setRedisDb] = useState('0');
|
||||
const [redisClusterMode, setRedisClusterMode] = useState(false);
|
||||
const [env, setEnv] = useState('development');
|
||||
const [customEnv, setCustomEnv] = useState('');
|
||||
const [accessMode, setAccessMode] = useState<'readwrite' | 'read' | 'write'>('readwrite');
|
||||
@@ -1020,7 +1186,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string; cluster_mode?: boolean } | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{msg: string; onConfirm: () => void} | null>(null);
|
||||
|
||||
const isRedis = dbType === 'redis';
|
||||
@@ -1033,7 +1199,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
|
||||
setDbType(t);
|
||||
setPort(String(DB_DEFAULT_PORTS[t] || 3306));
|
||||
setTestResult(null);
|
||||
if (t === 'redis') { setDatabase(''); }
|
||||
if (t === 'redis') { setDatabase(''); } else { setRedisClusterMode(false); }
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
@@ -1047,6 +1213,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
|
||||
username: isRedis ? (username || undefined) : (username || 'default'),
|
||||
password: password || 'default',
|
||||
database: database || undefined,
|
||||
cluster_mode: isRedis ? redisClusterMode : undefined,
|
||||
env: (finalEnv || 'development') as string,
|
||||
access_mode: accessMode,
|
||||
excluded_databases: excludedDbs ? excludedDbs.split(',').map(s => s.trim()).filter(Boolean) : undefined,
|
||||
@@ -1062,7 +1229,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [name, host, port, dbType, username, password, database, finalEnv, accessMode, excludedDbs, maskFields, needUsername, onCreated]);
|
||||
}, [name, host, port, dbType, username, password, database, redisClusterMode, finalEnv, accessMode, excludedDbs, maskFields, needUsername, isRedis, onCreated]);
|
||||
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
if (!host || !port) { setError('请填写地址和端口'); return; }
|
||||
@@ -1074,16 +1241,17 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
|
||||
username: isRedis ? (username || undefined) : (username || 'default'),
|
||||
password: password || 'default',
|
||||
database: database || undefined,
|
||||
cluster_mode: isRedis ? redisClusterMode : undefined,
|
||||
env: (finalEnv || 'development') as string, access_mode: accessMode,
|
||||
});
|
||||
setTestResult(res as { success: boolean; message: string; version?: string });
|
||||
setTestResult(res as { success: boolean; message: string; version?: string; cluster_mode?: boolean });
|
||||
} catch (e: unknown) {
|
||||
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '连接失败';
|
||||
setTestResult({ success: false, message: msg });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, [host, port, dbType, username, password, database, finalEnv, accessMode, needUsername, name]);
|
||||
}, [host, port, dbType, username, password, database, redisClusterMode, finalEnv, accessMode, needUsername, name, isRedis]);
|
||||
|
||||
const validateStep = useCallback((step: number): boolean => {
|
||||
setError('');
|
||||
@@ -1314,6 +1482,31 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isRedis && (
|
||||
<div>
|
||||
<label className={labelCls}>Redis 部署模式</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ value: false, label: '单节点', desc: '普通 Redis 或哨兵入口,查询只访问当前实例' },
|
||||
{ value: true, label: '集群', desc: 'Redis Cluster,按 slot 路由并扫描所有 master' },
|
||||
].map(mode => (
|
||||
<button
|
||||
key={mode.label}
|
||||
type="button"
|
||||
onClick={() => { setRedisClusterMode(mode.value); setTestResult(null); }}
|
||||
className={`text-left p-3 rounded-lg border transition-colors ${
|
||||
redisClusterMode === mode.value
|
||||
? 'bg-blue-500/10 border-blue-500/40 text-blue-400'
|
||||
: 'bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium">{mode.label}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">{mode.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Connection Mode */}
|
||||
<div>
|
||||
<label className={labelCls}>连接模式</label>
|
||||
@@ -1342,7 +1535,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: {
|
||||
{testResult && (
|
||||
<div className={`flex items-center gap-3 p-3 rounded-lg text-sm ${testResult.success ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
|
||||
{testResult.success
|
||||
? <><CheckCircle className="w-4 h-4 flex-shrink-0" /> 连接成功{testResult.version ? ` · ${testResult.version}` : ''}</>
|
||||
? <><CheckCircle className="w-4 h-4 flex-shrink-0" /> 连接成功{testResult.version ? ` · ${testResult.version}` : ''}{isRedis ? ` · ${testResult.cluster_mode ? '集群' : '单节点'}` : ''}</>
|
||||
: <><XCircle className="w-4 h-4 flex-shrink-0" /> {testResult.message}</>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -152,6 +152,10 @@ export default function PlatformConfigPage() {
|
||||
// 按 GROUP_ORDER 排序,过滤隐藏分组
|
||||
const visibleGroups = GROUP_ORDER.filter(g => settings[g]);
|
||||
const hasChanges = Object.keys(changed).length > 0;
|
||||
const totalItems = visibleGroups.reduce((sum, group) => sum + (settings[group]?.length || 0), 0);
|
||||
const enabledBooleanItems = visibleGroups.reduce((sum, group) => {
|
||||
return sum + (settings[group] || []).filter(item => item.value_type === 'boolean' && displayValue(item) === 'true').length;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -166,13 +170,28 @@ export default function PlatformConfigPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="dg-card p-4">
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">配置项</div>
|
||||
<div className="text-2xl font-semibold mt-1">{totalItems}</div>
|
||||
</div>
|
||||
<div className="dg-card p-4">
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">已启用策略</div>
|
||||
<div className="text-2xl font-semibold mt-1 text-emerald-400">{enabledBooleanItems}</div>
|
||||
</div>
|
||||
<div className="dg-card p-4">
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">待保存修改</div>
|
||||
<div className={`text-2xl font-semibold mt-1 ${hasChanges ? 'text-amber-400' : ''}`}>{Object.keys(changed).length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 各分组 */}
|
||||
{visibleGroups.map(group => {
|
||||
const meta = GROUP_META[group] || { label: group, icon: '⚙️', desc: '' };
|
||||
const items = (settings[group] || []).filter((it: SettingItem) => !HIDDEN_GROUPS.has(it.group));
|
||||
|
||||
return (
|
||||
<div key={group} className="bg-[var(--dg-bg-secondary)] rounded-xl overflow-hidden">
|
||||
<div key={group} className="dg-card overflow-hidden">
|
||||
{/* 分组头 */}
|
||||
<div className="px-5 py-3 flex items-center gap-3">
|
||||
<span className="text-lg">{meta.icon}</span>
|
||||
|
||||
@@ -57,13 +57,15 @@ type SortState = { column: string | null; direction: SortDirection };
|
||||
/* Constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const dbTypeLabel: Record<string, string> = {
|
||||
mysql: 'MySQL', postgresql: 'PostgreSQL', redis: 'Redis', mongodb: 'MongoDB',
|
||||
mysql: 'MySQL', postgres: 'PostgreSQL', postgresql: 'PostgreSQL', redis: 'Redis', mongo: 'MongoDB', mongodb: 'MongoDB',
|
||||
};
|
||||
|
||||
const dbTypeIcon: Record<string, string> = {
|
||||
mysql: '/db-logos/mysql.svg',
|
||||
postgres: '/db-logos/postgres.svg',
|
||||
postgresql: '/db-logos/postgres.svg',
|
||||
redis: '/db-logos/redis.svg',
|
||||
mongo: '/db-logos/mongo.svg',
|
||||
mongodb: '/db-logos/mongo.svg',
|
||||
};
|
||||
|
||||
@@ -77,6 +79,20 @@ const placeholderMap: Record<DBType, string> = {
|
||||
mongodb: "// MongoDB Shell\n// 支持 find, aggregate, count, distinct\n\ndb.users.find({ status: 'active' }).limit(100)",
|
||||
};
|
||||
|
||||
function normalizeDbType(dbType?: string): DBType {
|
||||
if (dbType === 'postgres' || dbType === 'pg') return 'postgresql';
|
||||
if (dbType === 'mongo') return 'mongodb';
|
||||
if (dbType === 'redis') return 'redis';
|
||||
return 'mysql';
|
||||
}
|
||||
|
||||
const queryPolicyText: Record<DBType, string> = {
|
||||
mysql: '查询入口仅允许 SELECT / SHOW / DESCRIBE / EXPLAIN;禁止 UPDATE/DELETE/DDL、多语句、SELECT FOR UPDATE。未写 LIMIT 时按平台配置自动限制。',
|
||||
postgresql: '查询入口仅允许 SELECT / EXPLAIN;禁止 EXPLAIN ANALYZE、写入 CTE、多语句和锁定读。未写 LIMIT 时按平台配置自动限制。',
|
||||
redis: 'Redis 查询入口仅允许只读命令;写命令请提交变更工单。集群实例会按 slot 路由并扫描所有 master。',
|
||||
mongodb: 'MongoDB 查询入口用于 find / aggregate / count / distinct 等只读操作;写操作请提交变更工单。',
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -184,7 +200,7 @@ export default function QueriesPage() {
|
||||
}, [instanceId, instances]);
|
||||
|
||||
const selectedInstance = useMemo(() => instances.find(inst => String(inst.id) === instanceId), [instances, instanceId]);
|
||||
const dbType = (selectedInstance?.db_type || 'mysql') as DBType;
|
||||
const dbType = normalizeDbType(selectedInstance?.db_type);
|
||||
const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -505,6 +521,10 @@ export default function QueriesPage() {
|
||||
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Shift+F 格式化</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-3 py-2 border-t border-[var(--dg-border)] bg-[var(--dg-bg-primary)] text-[11px] text-[var(--dg-text-muted)] flex items-start gap-2">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 mt-0.5 shrink-0" />
|
||||
<span>{queryPolicyText[dbType]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,6 +5,18 @@ import { defineConfig } from "vite"
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) return;
|
||||
if (id.includes('react') || id.includes('react-router')) return 'vendor-react';
|
||||
if (id.includes('@codemirror')) return 'vendor-editor';
|
||||
if (id.includes('@radix-ui')) return 'vendor-ui';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
|
||||
Reference in new issue
Block a user