sync: 同步 sql-platform-pro 最新代码 — 权限体系安全修复 v0.8.1

- P0: 实例访问控制全线接入权限组 (829测试)
- P1: permissions字段激活 + 操作级权限检查 (843测试)
- 前端: 删除数据权限页面, 权限组合并为唯一入口
- 新增: CodeMirror编辑器 + 查询模板 + 慢查询分析
- 新增: MongoDB管理页面 + Dashboard增强
- 修复: Pydantic V2迁移 + 前端TS错误修复
This commit is contained in:
logan committed 2026-05-23 14:41:06 +08:00
1 parent 6fcd9465cf
commit 4c9427e546
105 files changed
+23680 -471

No files matched your search

+241
View File
@@ -8,6 +8,16 @@
"name": "my-app",
"version": "0.0.0",
"dependencies": {
"@codemirror/autocomplete": "^6.20.2",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/language": "^6.12.3",
"@codemirror/lint": "^6.9.6",
"@codemirror/rectangular-selection": "^0.19.2",
"@codemirror/search": "^6.7.0",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -41,6 +51,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"date-fns": "^4.1.0",
"echarts": "^6.0.0",
"embla-carousel-react": "^8.6.0",
@@ -1863,6 +1874,173 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.2",
"resolved": "https://registry.npmmirror.com/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz",
"integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.3",
"resolved": "https://registry.npmmirror.com/@codemirror/commands/-/commands-6.10.3.tgz",
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-sql": {
"version": "6.10.0",
"resolved": "https://registry.npmmirror.com/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz",
"integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmmirror.com/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.6",
"resolved": "https://registry.npmmirror.com/@codemirror/lint/-/lint-6.9.6.tgz",
"integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/rangeset": {
"version": "0.19.9",
"resolved": "https://registry.npmmirror.com/@codemirror/rangeset/-/rangeset-0.19.9.tgz",
"integrity": "sha512-V8YUuOvK+ew87Xem+71nKcqu1SXd5QROMRLMS/ljT5/3MCxtgrRie1Cvild0G/Z2f1fpWxzX78V0U4jjXBorBQ==",
"deprecated": "As of 0.20.0, this package has been merged into @codemirror/state",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.19.0"
}
},
"node_modules/@codemirror/rangeset/node_modules/@codemirror/state": {
"version": "0.19.9",
"resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-0.19.9.tgz",
"integrity": "sha512-psOzDolKTZkx4CgUqhBQ8T8gBc0xN5z4gzed109aF6x7D7umpDRoimacI/O6d9UGuyl4eYuDCZmDFr2Rq7aGOw==",
"license": "MIT",
"dependencies": {
"@codemirror/text": "^0.19.0"
}
},
"node_modules/@codemirror/rectangular-selection": {
"version": "0.19.2",
"resolved": "https://registry.npmmirror.com/@codemirror/rectangular-selection/-/rectangular-selection-0.19.2.tgz",
"integrity": "sha512-AXK/p5eGwFJ9GJcLfntqN4dgY+XiIF7eHfXNQJX5HhQLSped2wJE6WuC1rMEaOlcpOqlb9mrNi/ZdUjSIj9mbA==",
"deprecated": "As of 0.20.0, this package has been merged into @codemirror/view",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^0.19.0",
"@codemirror/text": "^0.19.4",
"@codemirror/view": "^0.19.48"
}
},
"node_modules/@codemirror/rectangular-selection/node_modules/@codemirror/state": {
"version": "0.19.9",
"resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-0.19.9.tgz",
"integrity": "sha512-psOzDolKTZkx4CgUqhBQ8T8gBc0xN5z4gzed109aF6x7D7umpDRoimacI/O6d9UGuyl4eYuDCZmDFr2Rq7aGOw==",
"license": "MIT",
"dependencies": {
"@codemirror/text": "^0.19.0"
}
},
"node_modules/@codemirror/rectangular-selection/node_modules/@codemirror/view": {
"version": "0.19.48",
"resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-0.19.48.tgz",
"integrity": "sha512-0eg7D2Nz4S8/caetCTz61rK0tkHI17V/d15Jy0kLOT8dTLGGNJUponDnW28h2B6bERmPlVHKh8MJIr5OCp1nGw==",
"license": "MIT",
"dependencies": {
"@codemirror/rangeset": "^0.19.5",
"@codemirror/state": "^0.19.3",
"@codemirror/text": "^0.19.0",
"style-mod": "^4.0.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.0",
"resolved": "https://registry.npmmirror.com/@codemirror/search/-/search-6.7.0.tgz",
"integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmmirror.com/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/text": {
"version": "0.19.6",
"resolved": "https://registry.npmmirror.com/@codemirror/text/-/text-0.19.6.tgz",
"integrity": "sha512-T9jnREMIygx+TPC1bOuepz18maGq/92q2a+n4qTqObKwvNMg+8cMTslb8yxeEDEq7S3kpgGWxgO1UWbQRij0dA==",
"deprecated": "As of 0.20.0, this package has been merged into @codemirror/state",
"license": "MIT"
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmmirror.com/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.0",
"resolved": "https://registry.npmmirror.com/@codemirror/view/-/view-6.43.0.tgz",
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
@@ -2620,6 +2798,36 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmmirror.com/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmmirror.com/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmmirror.com/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -5555,6 +5763,21 @@
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -5638,6 +5861,12 @@
"url": "https://opencollective.com/core-js"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -8050,6 +8279,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmmirror.com/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -8564,6 +8799,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmmirror.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+11
View File
@@ -10,6 +10,16 @@
"preview": "vite preview"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.2",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/language": "^6.12.3",
"@codemirror/lint": "^6.9.6",
"@codemirror/rectangular-selection": "^0.19.2",
"@codemirror/search": "^6.7.0",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.0",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -43,6 +53,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"codemirror": "^6.0.2",
"date-fns": "^4.1.0",
"echarts": "^6.0.0",
"embla-carousel-react": "^8.6.0",
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" clip-rule="evenodd" fill="#439934" d="M88.038 42.812c1.605 4.643 2.761 9.383 3.141 14.296.472 6.095.256 12.147-1.029 18.142-.035.165-.109.32-.164.48-.403.001-.814-.049-1.208.012-3.329.523-6.655 1.065-9.981 1.604-3.438.557-6.881 1.092-10.313 1.687-1.216.21-2.721-.041-3.212 1.641-.014.046-.154.054-.235.08l.166-10.051-.169-24.252 1.602-.275c2.62-.429 5.24-.864 7.862-1.281 3.129-.497 6.261-.98 9.392-1.465 1.381-.215 2.764-.412 4.148-.618z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#45A538" d="M61.729 110.054c-1.69-1.453-3.439-2.842-5.059-4.37-8.717-8.222-15.093-17.899-18.233-29.566-.865-3.211-1.442-6.474-1.627-9.792-.13-2.322-.318-4.665-.154-6.975.437-6.144 1.325-12.229 3.127-18.147l.099-.138c.175.233.427.439.516.702 1.759 5.18 3.505 10.364 5.242 15.551 5.458 16.3 10.909 32.604 16.376 48.9.107.318.384.579.583.866l-.87 2.969z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#46A037" d="M88.038 42.812c-1.384.206-2.768.403-4.149.616-3.131.485-6.263.968-9.392 1.465-2.622.417-5.242.852-7.862 1.281l-1.602.275-.012-1.045c-.053-.859-.144-1.717-.154-2.576-.069-5.478-.112-10.956-.18-16.434-.042-3.429-.105-6.857-.175-10.285-.043-2.13-.089-4.261-.185-6.388-.052-1.143-.236-2.28-.311-3.423-.042-.657.016-1.319.029-1.979.817 1.583 1.616 3.178 2.456 4.749 1.327 2.484 3.441 4.314 5.344 6.311 7.523 7.892 12.864 17.068 16.193 27.433z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#409433" d="M65.036 80.753c.081-.026.222-.034.235-.08.491-1.682 1.996-1.431 3.212-1.641 3.432-.594 6.875-1.13 10.313-1.687 3.326-.539 6.652-1.081 9.981-1.604.394-.062.805-.011 1.208-.012-.622 2.22-1.112 4.488-1.901 6.647-.896 2.449-1.98 4.839-3.131 7.182a49.142 49.142 0 01-6.353 9.763c-1.919 2.308-4.058 4.441-6.202 6.548-1.185 1.165-2.582 2.114-3.882 3.161l-.337-.23-1.214-1.038-1.256-2.753a41.402 41.402 0 01-1.394-9.838l.023-.561.171-2.426c.057-.828.133-1.655.168-2.485.129-2.982.241-5.964.359-8.946z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#4FAA41" d="M65.036 80.753c-.118 2.982-.23 5.964-.357 8.947-.035.83-.111 1.657-.168 2.485l-.765.289c-1.699-5.002-3.399-9.951-5.062-14.913-2.75-8.209-5.467-16.431-8.213-24.642a4498.887 4498.887 0 00-6.7-19.867c-.105-.31-.407-.552-.617-.826l4.896-9.002c.168.292.39.565.496.879a6167.476 6167.476 0 016.768 20.118c2.916 8.73 5.814 17.467 8.728 26.198.116.349.308.671.491 1.062l.67-.78-.167 10.052z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#4AA73C" d="M43.155 32.227c.21.274.511.516.617.826a4498.887 4498.887 0 016.7 19.867c2.746 8.211 5.463 16.433 8.213 24.642 1.662 4.961 3.362 9.911 5.062 14.913l.765-.289-.171 2.426-.155.559c-.266 2.656-.49 5.318-.814 7.968-.163 1.328-.509 2.632-.772 3.947-.198-.287-.476-.548-.583-.866-5.467-16.297-10.918-32.6-16.376-48.9a3888.972 3888.972 0 00-5.242-15.551c-.089-.263-.34-.469-.516-.702l3.272-8.84z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#57AE47" d="M65.202 70.702l-.67.78c-.183-.391-.375-.714-.491-1.062-2.913-8.731-5.812-17.468-8.728-26.198a6167.476 6167.476 0 00-6.768-20.118c-.105-.314-.327-.588-.496-.879l6.055-7.965c.191.255.463.482.562.769 1.681 4.921 3.347 9.848 5.003 14.778 1.547 4.604 3.071 9.215 4.636 13.813.105.308.47.526.714.786l.012 1.045c.058 8.082.115 16.167.171 24.251z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#60B24F" d="M65.021 45.404c-.244-.26-.609-.478-.714-.786-1.565-4.598-3.089-9.209-4.636-13.813-1.656-4.93-3.322-9.856-5.003-14.778-.099-.287-.371-.514-.562-.769 1.969-1.928 3.877-3.925 5.925-5.764 1.821-1.634 3.285-3.386 3.352-5.968.003-.107.059-.214.145-.514l.519 1.306c-.013.661-.072 1.322-.029 1.979.075 1.143.259 2.28.311 3.423.096 2.127.142 4.258.185 6.388.069 3.428.132 6.856.175 10.285.067 5.478.111 10.956.18 16.434.008.861.098 1.718.152 2.577z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#A9AA88" d="M62.598 107.085c.263-1.315.609-2.62.772-3.947.325-2.649.548-5.312.814-7.968l.066-.01.066.011a41.402 41.402 0 001.394 9.838c-.176.232-.425.439-.518.701-.727 2.05-1.412 4.116-2.143 6.166-.1.28-.378.498-.574.744l-.747-2.566.87-2.969z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#B6B598" d="M62.476 112.621c.196-.246.475-.464.574-.744.731-2.05 1.417-4.115 2.143-6.166.093-.262.341-.469.518-.701l1.255 2.754c-.248.352-.59.669-.728 1.061l-2.404 7.059c-.099.283-.437.483-.663.722l-.695-3.985z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#C2C1A7" d="M63.171 116.605c.227-.238.564-.439.663-.722l2.404-7.059c.137-.391.48-.709.728-1.061l1.215 1.037c-.587.58-.913 1.25-.717 2.097l-.369 1.208c-.168.207-.411.387-.494.624-.839 2.403-1.64 4.819-2.485 7.222-.107.305-.404.544-.614.812-.109-1.387-.22-2.771-.331-4.158z"/><path fill-rule="evenodd" clip-rule="evenodd" fill="#CECDB7" d="M63.503 120.763c.209-.269.506-.508.614-.812.845-2.402 1.646-4.818 2.485-7.222.083-.236.325-.417.494-.624l-.509 5.545c-.136.157-.333.294-.398.477-.575 1.614-1.117 3.24-1.694 4.854-.119.333-.347.627-.525.938-.158-.207-.441-.407-.454-.623-.Line truncated

After

Width:  |  Height:  |  Size: 5.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="#00618A" d="M116.948 97.807c-6.863-.187-12.104.452-16.585 2.341-1.273.537-3.305.552-3.513 2.147.7.733.809 1.829 1.365 2.731 1.07 1.73 2.876 4.052 4.488 5.268 1.762 1.33 3.577 2.751 5.465 3.902 3.358 2.047 7.107 3.217 10.34 5.268 1.906 1.21 3.799 2.733 5.658 4.097.92.675 1.537 1.724 2.732 2.147v-.194c-.628-.8-.79-1.898-1.366-2.733l-2.537-2.537c-2.48-3.292-5.629-6.184-8.976-8.585-2.669-1.916-8.642-4.504-9.755-7.609l-.195-.195c1.892-.214 4.107-.898 5.854-1.367 2.934-.786 5.556-.583 8.585-1.365l4.097-1.171v-.78c-1.531-1.571-2.623-3.651-4.292-5.073-4.37-3.72-9.138-7.437-14.048-10.537-2.724-1.718-6.089-2.835-8.976-4.292-.971-.491-2.677-.746-3.318-1.562-1.517-1.932-2.342-4.382-3.511-6.633-2.449-4.717-4.854-9.868-7.024-14.831-1.48-3.384-2.447-6.72-4.293-9.756-8.86-14.567-18.396-23.358-33.169-32-3.144-1.838-6.929-2.563-10.929-3.513-2.145-.129-4.292-.26-6.438-.391-1.311-.546-2.673-2.149-3.902-2.927C17.811 4.565 5.257-2.16 1.633 6.682c-2.289 5.581 3.421 11.025 5.462 13.854 1.434 1.982 3.269 4.207 4.293 6.438.674 1.467.79 2.938 1.367 4.489 1.417 3.822 2.652 7.98 4.487 11.511.927 1.788 1.949 3.67 3.122 5.268.718.981 1.951 1.413 2.145 2.927-1.204 1.686-1.273 4.304-1.95 6.44-3.05 9.615-1.899 21.567 2.537 28.683 1.36 2.186 4.567 6.871 8.975 5.073 3.856-1.57 2.995-6.438 4.098-10.732.249-.973.096-1.689.585-2.341v.195l3.513 7.024c2.6 4.187 7.212 8.562 11.122 11.514 2.027 1.531 3.623 4.177 6.244 5.073v-.196h-.195c-.508-.791-1.303-1.119-1.951-1.755-1.527-1.497-3.225-3.358-4.487-5.073-3.556-4.827-6.698-10.11-9.561-15.609-1.368-2.627-2.557-5.523-3.709-8.196-.444-1.03-.438-2.589-1.364-3.122-1.263 1.958-3.122 3.542-4.098 5.854-1.561 3.696-1.762 8.204-2.341 12.878-.342.122-.19.038-.391.194-2.718-.655-3.672-3.452-4.683-5.853-2.554-6.07-3.029-15.842-.781-22.829.582-1.809 3.21-7.501 2.146-9.172-.508-1.666-2.184-2.63-3.121-3.903-1.161-1.574-2.319-3.646-3.124-5.464-2.09-4.731-3.066-10.044-5.267-14.828-1.053-2.287-2.832-4.602-4.293-6.634-1.617-2.253-3.429-3.912-4.683-6.635-.446-.968-1.051-2.518-.391-3.513.21-.671.508-.951 1.171-1.17 1.132-.873 4.284.29 5.462.779 3.129 1.3 5.741 2.538 8.392 4.294 1.271.844 2.559 2.475 4.097 2.927h1.756c2.747.631 5.824.195 8.391.975 4.536 1.378 8.601 3.523 12.292 5.854 11.246 7.102 20.442 17.21 26.732 29.269 1.012 1.942 1.45 3.794 2.341 5.854 1.798 4.153 4.063 8.426 5.852 12.488 1.786 4.052 3.526 8.141 6.05 11.513 1.327 1.772 6.451 2.723 8.781 3.708 1.632.689 4.307 1.409 5.854 2.34 2.953 1.782 5.815 3.903 8.586 5.855 1.383.975 5.64 3.116 5.852 4.879zM29.729 23.466c-1.431-.027-2.443.156-3.513.389v.195h.195c.683 1.402 1.888 2.306 2.731 3.513.65 1.367 1.301 2.732 1.952 4.097l.194-.193c1.209-.853 1.762-2.214 1.755-4.294-.484-.509-.555-1.147-.975-1.755-.556-.811-1.635-1.272-2.339-1.952z"/></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path d="M93.809 92.112c.785-6.533.55-7.492 5.416-6.433l1.235.108c3.742.17 8.637-.602 11.513-1.938 6.191-2.873 9.861-7.668 3.758-6.409-13.924 2.873-14.881-1.842-14.881-1.842 14.703-21.815 20.849-49.508 15.543-56.287-14.47-18.489-39.517-9.746-39.936-9.52l-.134.025c-2.751-.571-5.83-.912-9.289-.968-6.301-.104-11.082 1.652-14.709 4.402 0 0-44.683-18.409-42.604 23.151.442 8.841 12.672 66.898 27.26 49.362 5.332-6.412 10.484-11.834 10.484-11.834 2.558 1.699 5.622 2.567 8.834 2.255l.249-.212c-.078.796-.044 1.575.099 2.497-3.757 4.199-2.653 4.936-10.166 6.482-7.602 1.566-3.136 4.355-.221 5.084 3.535.884 11.712 2.136 17.238-5.598l-.22.882c1.474 1.18 1.375 8.477 1.583 13.69.209 5.214.558 10.079 1.621 12.948 1.063 2.868 2.317 10.256 12.191 8.14 8.252-1.764 14.561-4.309 15.136-27.985"/><path d="M75.458 125.256c-4.367 0-7.211-1.689-8.938-3.32-2.607-2.46-3.641-5.629-4.259-7.522l-.267-.79c-1.244-3.358-1.666-8.193-1.916-14.419-.038-.935-.064-1.898-.093-2.919-.021-.747-.047-1.684-.085-2.664a18.8 18.8 0 01-4.962 1.568c-3.079.526-6.389.356-9.84-.507-2.435-.609-4.965-1.871-6.407-3.82-4.203 3.681-8.212 3.182-10.396 2.453-3.853-1.285-7.301-4.896-10.542-11.037-2.309-4.375-4.542-10.075-6.638-16.943-3.65-11.96-5.969-24.557-6.175-28.693C4.292 23.698 7.777 14.44 15.296 9.129 27.157.751 45.128 5.678 51.68 7.915c4.402-2.653 9.581-3.944 15.433-3.851 3.143.051 6.136.327 8.916.823 2.9-.912 8.628-2.221 15.185-2.139 12.081.144 22.092 4.852 28.949 13.615 4.894 6.252 2.474 19.381.597 26.651-2.642 10.226-7.271 21.102-12.957 30.57 1.544.011 3.781-.174 6.961-.831 6.274-1.295 8.109 2.069 8.607 3.575 1.995 6.042-6.677 10.608-9.382 11.864-3.466 1.609-9.117 2.589-13.745 2.377l-.202-.013-1.216-.107-.12 1.014-.116.991c-.311 11.999-2.025 19.598-5.552 24.619-3.697 5.264-8.835 6.739-13.361 7.709-1.544.33-2.947.474-4.219.474zm-9.19-43.671c2.819 2.256 3.066 6.501 3.287 14.434.028.99.054 1.927.089 2.802.106 2.65.355 8.855 1.327 11.477.137.371.26.747.39 1.146 1.083 3.316 1.626 4.979 6.309 3.978 3.931-.843 5.952-1.599 7.534-3.851 2.299-3.274 3.585-9.86 3.821-19.575l4.783.116-4.75-.57.14-1.186c.455-3.91.783-6.734 3.396-8.602 2.097-1.498 4.486-1.353 6.389-1.01-2.091-1.58-2.669-3.433-2.823-4.193l-.399-1.965 1.121-1.663c6.457-9.58 11.781-21.354 14.609-32.304 2.906-11.251 2.02-17.226 1.134-18.356-11.729-14.987-32.068-8.799-34.192-8.097l-.359.194-1.8.335-.922-.191c-2.542-.528-5.366-.82-8.393-.869-4.756-.08-8.593 1.044-11.739 3.431l-2.183 1.655-2.533-1.043c-5.412-2.213-21.308-6.662-29.696-.721-4.656 3.298-6.777 9.76-6.305 19.207.156 3.119 2.275 14.926 5.771 26.377 4.831 15.825 9.221 21.082 11.054 21.693.32.108 1.15-.537 1.976-1.529a270.708 270.708 0 0110.694-12.07l2.77-2.915 3.349 2.225c1.35.897 2.839 1.406 4.368 1.502l7.987-6.812-1.157 11.808c-.026.265-.039.626.065 1.296l.348 2.238-1.51 1.688-.174.196 4.388 2.025 1.836-2.301z"/><path fill="#336791" d="M115.731 77.44c-13.925 2.873-14.882-1.842-14.882-1.842 14.703-21.816 20.849-49.51 15.545-56.287C101.924.823 76.875 9.566 76.457 9.793l-.135.024c-2.751-.571-5.83-.911-9.291-.967-6.301-.103-11.08 1.652-14.707 4.402 0 0-44.684-18.408-42.606 23.151.442 8.842 12.672 66.899 27.26 49.363 5.332-6.412 10.483-11.834 10.483-11.834 2.559 1.699 5.622 2.567 8.833 2.255l.25-.212c-.078.796-.042 1.575.1 2.497-3.758 4.199-2.654 4.936-10.167 6.482-7.602 1.566-3.136 4.355-.22 5.084 3.534.884 11.712 2.136 17.237-5.598l-.221.882c1.473 1.18 2.507 7.672 2.334 13.557-.174 5.885-.29 9.926.871 13.082 1.16 3.156 2.316 10.256 12.192 8.14 8.252-1.768 12.528-6.351 13.124-13.995.422-5.435 1.377-4.631 1.438-9.49l.767-2.3c.884-7.367.14-9.743 5.225-8.638l1.235.108c3.742.17 8.639-.602 11.514-1.938 6.19-2.871 9.861-7.667 3.758-6.408z"/><path fill="#fff" d="M75.957 122.307c-8.232 0-10.84-6.519-11.907-9.185-1.562-3.907-1.899-19.069-1.551-31.503a1.59 1.59 0 011.64-1.55 1.594 1.594 0 011.55 1.639c-.401 14.341.168 27.337 1.324 30.229 1.804 4.509 4.54 8.453 12.275 6.796 7.343-1.575 10.093-4.359 11.318-11.46.94-5.449 2.799-20.951 3.028-24.01a1.593 1.593 0 011.71-1.472 1.597 1.597 0 011.472 1.71c-.239 3.185-2.089 18.657-3.065 24.315-1.446 8.387-5.185 12.191-13.794 14.037-1.463.313-2.792.453-4 .454zM31.321 90.466a6.71 6.71 0 01-2.116-.35c-5.347-1.784-10.44-10.492-15.138-25.885-3.576-11.717-5.842-23.947-6.041-27.922-.589-11.784 2.445-20.121 9.02-24.778 13.007-9.216 34.888-.44 35.813-.062a1.596 1.596 0 01-1.207 2.955c-.211-.086-21.193-8.492-32.768-.285-5.622 3.986-8.203 11.392-7.672 22.011.167 3.349 2.284 15.285 5.906 27.149 4.194 13.742 8.967 22.413 13.096 23.79.648.216 2.62.873 5.439-2.517A245.272 245.272 0 0145.88 73.046a1.596 1.596 0 012.304 2.208c-.048.05-4.847 5.067-10.077 11.359-2.477 2.979-4.851 3.853-6.786 3.853zm69.429-13.445a1.596 1.596 0 01-1.322-2.487c14.863-22.055 20.08-48.704 15.612-54.414-5.624-7.186-13.565-10.939-23.604-11.156-7.433-.16-13.341 1.738-14.307 2.069l-.243.099c-.971.305-1.716-.227-1.997-.849a1.6 1.6 0 01.631-2.025c.046-.027.192-.089.429-.176l-.021.006.021-.007c1.641-.601 7.63Line truncated

After

Width:  |  Height:  |  Size: 9.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="#A41E11" d="M121.8 93.1c-6.7 3.5-41.4 17.7-48.8 21.6-7.4 3.9-11.5 3.8-17.3 1S13 98.1 6.3 94.9c-3.3-1.6-5-2.9-5-4.2V78s48-10.5 55.8-13.2c7.8-2.8 10.4-2.9 17-.5s46.1 9.5 52.6 11.9v12.5c0 1.3-1.5 2.7-4.9 4.4z"/><path fill="#D82C20" d="M121.8 80.5C115.1 84 80.4 98.2 73 102.1c-7.4 3.9-11.5 3.8-17.3 1-5.8-2.8-42.7-17.7-49.4-20.9C-.3 79-.5 76.8 6 74.3c6.5-2.6 43.2-17 51-19.7 7.8-2.8 10.4-2.9 17-.5s41.1 16.1 47.6 18.5c6.7 2.4 6.9 4.4.2 7.9z"/><path fill="#A41E11" d="M121.8 72.5C115.1 76 80.4 90.2 73 94.1c-7.4 3.8-11.5 3.8-17.3 1C49.9 92.3 13 77.4 6.3 74.2c-3.3-1.6-5-2.9-5-4.2V57.3s48-10.5 55.8-13.2c7.8-2.8 10.4-2.9 17-.5s46.1 9.5 52.6 11.9V68c0 1.3-1.5 2.7-4.9 4.5z"/><path fill="#D82C20" d="M121.8 59.8c-6.7 3.5-41.4 17.7-48.8 21.6-7.4 3.8-11.5 3.8-17.3 1C49.9 79.6 13 64.7 6.3 61.5s-6.8-5.4-.3-7.9c6.5-2.6 43.2-17 51-19.7 7.8-2.8 10.4-2.9 17-.5s41.1 16.1 47.6 18.5c6.7 2.4 6.9 4.4.2 7.9z"/><path fill="#A41E11" d="M121.8 51c-6.7 3.5-41.4 17.7-48.8 21.6-7.4 3.8-11.5 3.8-17.3 1C49.9 70.9 13 56 6.3 52.8c-3.3-1.6-5.1-2.9-5.1-4.2V35.9s48-10.5 55.8-13.2c7.8-2.8 10.4-2.9 17-.5s46.1 9.5 52.6 11.9v12.5c.1 1.3-1.4 2.6-4.8 4.4z"/><path fill="#D82C20" d="M121.8 38.3C115.1 41.8 80.4 56 73 59.9c-7.4 3.8-11.5 3.8-17.3 1S13 43.3 6.3 40.1s-6.8-5.4-.3-7.9c6.5-2.6 43.2-17 51-19.7 7.8-2.8 10.4-2.9 17-.5s41.1 16.1 47.6 18.5c6.7 2.4 6.9 4.4.2 7.8z"/><path fill="#fff" d="M80.4 26.1l-10.8 1.2-2.5 5.8-3.9-6.5-12.5-1.1 9.3-3.4-2.8-5.2 8.8 3.4 8.2-2.7L72 23zM66.5 54.5l-20.3-8.4 29.1-4.4z"/><ellipse fill="#fff" cx="38.4" cy="35.4" rx="15.5" ry="6"/><path fill="#7A0C00" d="M93.3 27.7l17.2 6.8-17.2 6.8z"/><path fill="#AD2115" d="M74.3 35.3l19-7.6v13.6l-1.9.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+5 -3
View File
@@ -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;
+24
View File
@@ -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');
},
};
+28 -2
View File
@@ -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);
}
);
+17
View File
@@ -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'),
};
+390
View File
@@ -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;
-1
View File
@@ -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'],
+5 -14
View File
@@ -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: '审核规则(启用/禁用、添加规则)' },
+173
View File
@@ -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)
);
}
+3 -3
View File
@@ -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>
);
}
}
+122 -1
View File
@@ -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)]">
-109
View File
@@ -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>
);
}
+9 -31
View File
@@ -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>
);
}
}
+386
View File
@@ -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>
);
}
+153 -33
View File
@@ -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>
);
}
}
+588
View File
@@ -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>
);
}