feat: improve datasource query audit workflows

This commit is contained in:
Logan committed 2026-07-06 23:01:04 +08:00
1 parent 74748e4914
commit 644981eef4
32 files changed
+780 -3061

No files matched your search

+1 -1
View File
@@ -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" />
+4 -1677
View File
File diff suppressed because it is too large. Load diff
+2 -3
View File
@@ -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
View File
@@ -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;
+83 -4
View File
@@ -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" />}
+2 -1
View File
@@ -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]);
+18
View File
@@ -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 */
/* ---------------------------------------------------------------- */
+1 -1
View File
@@ -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}
+5 -8
View File
@@ -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;
+1 -1
View File
@@ -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'],
+26
View File
@@ -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) => {
+20 -22
View File
@@ -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>
+199 -6
View File
@@ -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>
+20 -1
View File
@@ -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>
+22 -2
View File
@@ -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>
+12
View File
@@ -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: {