@libredb/studio 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (572) hide show
  1. package/.claude/settings.local.json +127 -0
  2. package/.cursorrules +426 -0
  3. package/.devin/wiki.json +143 -0
  4. package/.dockerignore +80 -0
  5. package/.env.example +159 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.md +49 -0
  7. package/.github/ISSUE_TEMPLATE/feature_request.md +29 -0
  8. package/.github/PULL_REQUEST_TEMPLATE.md +57 -0
  9. package/.github/workflows/ci.yml +185 -0
  10. package/.github/workflows/codeql.yml +57 -0
  11. package/.github/workflows/docker-build-push.yml +118 -0
  12. package/.github/workflows/helm-release.yml +113 -0
  13. package/CLAUDE.md +265 -0
  14. package/CODE_OF_CONDUCT.md +124 -0
  15. package/CONTRIBUTING.md +154 -0
  16. package/Dockerfile +73 -0
  17. package/LICENSE +21 -0
  18. package/README.md +614 -0
  19. package/SECURITY.md +107 -0
  20. package/artifacthub-repo.yml +4 -0
  21. package/bun.lock +1714 -0
  22. package/bunfig.toml +3 -0
  23. package/charts/libredb-studio/.helmignore +11 -0
  24. package/charts/libredb-studio/Chart.lock +6 -0
  25. package/charts/libredb-studio/Chart.yaml +50 -0
  26. package/charts/libredb-studio/README.md +206 -0
  27. package/charts/libredb-studio/templates/NOTES.txt +59 -0
  28. package/charts/libredb-studio/templates/_helpers.tpl +135 -0
  29. package/charts/libredb-studio/templates/configmap.yaml +37 -0
  30. package/charts/libredb-studio/templates/deployment.yaml +184 -0
  31. package/charts/libredb-studio/templates/hpa.yaml +32 -0
  32. package/charts/libredb-studio/templates/ingress.yaml +41 -0
  33. package/charts/libredb-studio/templates/networkpolicy.yaml +50 -0
  34. package/charts/libredb-studio/templates/pdb.yaml +18 -0
  35. package/charts/libredb-studio/templates/pvc.yaml +23 -0
  36. package/charts/libredb-studio/templates/secret.yaml +30 -0
  37. package/charts/libredb-studio/templates/seed-configmap.yaml +11 -0
  38. package/charts/libredb-studio/templates/service.yaml +22 -0
  39. package/charts/libredb-studio/templates/serviceaccount.yaml +13 -0
  40. package/charts/libredb-studio/values.schema.json +246 -0
  41. package/charts/libredb-studio/values.yaml +286 -0
  42. package/components.json +22 -0
  43. package/conductor/code_styleguides/typescript.md +43 -0
  44. package/conductor/product-guidelines.md +43 -0
  45. package/conductor/product.md +3 -0
  46. package/conductor/setup_state.json +1 -0
  47. package/conductor/tech-stack.md +39 -0
  48. package/conductor/tracks/enhance_postgres_monitoring_20251227/metadata.json +8 -0
  49. package/conductor/tracks/enhance_postgres_monitoring_20251227/plan.md +44 -0
  50. package/conductor/tracks/enhance_postgres_monitoring_20251227/spec.md +31 -0
  51. package/conductor/tracks.md +8 -0
  52. package/conductor/workflow.md +333 -0
  53. package/database-compose.yml +55 -0
  54. package/docker/postgres-init/01-extensions.sql +10 -0
  55. package/docker/postgres-init/02-sample-data.sql +585 -0
  56. package/docker/postgres.yml +68 -0
  57. package/docker-compose.yml +38 -0
  58. package/docs/AI_PLAN.md +74 -0
  59. package/docs/API_DOCS.md +875 -0
  60. package/docs/ARCHITECTURE.md +218 -0
  61. package/docs/DATABASE_PROVIDERS.md +358 -0
  62. package/docs/FEATURES.md +116 -0
  63. package/docs/HELM_CHART.md +252 -0
  64. package/docs/LOGIN_PAGE.md +178 -0
  65. package/docs/MONACO_EDITOR_PERFORMANCE.md +315 -0
  66. package/docs/OIDC_ARCH.md +681 -0
  67. package/docs/OIDC_SETUP.md +322 -0
  68. package/docs/POSTGRES_METRICS.md +516 -0
  69. package/docs/QUERY_OPTIMIZATION.md +370 -0
  70. package/docs/SEED_CONNECTIONS.md +468 -0
  71. package/docs/SQL_ALIAS_COMPLETION.md +190 -0
  72. package/docs/STORAGE_ARCHITECTURE.md +565 -0
  73. package/docs/STORAGE_QUICK_SETUP.md +419 -0
  74. package/docs/TECHNICAL_PLAN.md +36 -0
  75. package/docs/THEMING.md +345 -0
  76. package/docs/adding-a-new-database-provider.md +642 -0
  77. package/docs/backlogs/000-PLATFORM_DATA_SYNC_DATABASE.md +360 -0
  78. package/docs/backlogs/001-INLINE_DATA_EDITING.md +118 -0
  79. package/docs/backlogs/002-DATA_IMPORT.md +215 -0
  80. package/docs/backlogs/003-QUERY_TIME_MACHINE.md +183 -0
  81. package/docs/backlogs/004-AI_DATA_STORYTELLER.md +292 -0
  82. package/docs/backlogs/005-QUERY_PLAYGROUND.md +352 -0
  83. package/docs/backlogs/006-DATA_MASKING.md +418 -0
  84. package/docs/enterprise-features.md +718 -0
  85. package/docs/kubernetes-helm-chart-artifacthub-plan.md +803 -0
  86. package/docs/medium-koyeb-article-en.md +215 -0
  87. package/docs/plans/test-plans.md +445 -0
  88. package/docs/releases/RELEASE.V0.3.0.md +22 -0
  89. package/docs/releases/RELEASE.V0.4.0.md +154 -0
  90. package/docs/releases/RELEASE.V0.5.0.md +252 -0
  91. package/docs/releases/RELEASE_v0.5.6.md +145 -0
  92. package/docs/releases/RELEASE_v0.6.1.md +303 -0
  93. package/docs/releases/RELEASE_v0.6.7.md +292 -0
  94. package/docs/releases/RELEASE_v0.7.0.md +332 -0
  95. package/docs/releases/RELEASE_v0.8.0.md +521 -0
  96. package/docs/sampledb/titanic.sql +1379 -0
  97. package/docs/superpowers/plans/2026-03-25-seed-connections.md +1362 -0
  98. package/docs/superpowers/specs/2026-03-25-seed-connections-design.md +590 -0
  99. package/e2e/admin-dashboard.spec.ts +64 -0
  100. package/e2e/connection-management.spec.ts +58 -0
  101. package/e2e/export.spec.ts +34 -0
  102. package/e2e/login.spec.ts +85 -0
  103. package/e2e/query-execution.spec.ts +35 -0
  104. package/e2e/tab-management.spec.ts +64 -0
  105. package/eslint.config.mjs +28 -0
  106. package/fly.toml +43 -0
  107. package/next.config.ts +32 -0
  108. package/package.json +130 -0
  109. package/playwright.config.ts +34 -0
  110. package/postcss.config.mjs +7 -0
  111. package/public/favicon-32x32.png +0 -0
  112. package/public/favicon.ico +0 -0
  113. package/public/file.svg +1 -0
  114. package/public/globe.svg +1 -0
  115. package/public/logo.svg +32 -0
  116. package/public/next.svg +1 -0
  117. package/public/screenshots/code-generator.png +0 -0
  118. package/public/screenshots/connection-modal.png +0 -0
  119. package/public/screenshots/data-profiler.png +0 -0
  120. package/public/screenshots/erd-diagram.png +0 -0
  121. package/public/screenshots/hero-editor.png +0 -0
  122. package/public/screenshots/nl2sql.png +0 -0
  123. package/public/vercel.svg +1 -0
  124. package/public/window.svg +1 -0
  125. package/render.yaml +58 -0
  126. package/scripts/merge-lcov.mjs +239 -0
  127. package/sonar-project.properties +16 -0
  128. package/src/app/admin/error.tsx +46 -0
  129. package/src/app/admin/page.tsx +10 -0
  130. package/src/app/api/admin/audit/route.ts +52 -0
  131. package/src/app/api/admin/fleet-health/route.ts +81 -0
  132. package/src/app/api/ai/autopilot/route.ts +105 -0
  133. package/src/app/api/ai/chat/route.ts +132 -0
  134. package/src/app/api/ai/describe-schema/route.ts +52 -0
  135. package/src/app/api/ai/explain/route.ts +86 -0
  136. package/src/app/api/ai/impact/route.ts +97 -0
  137. package/src/app/api/ai/index-advisor/route.ts +98 -0
  138. package/src/app/api/ai/nl2sql/route.ts +87 -0
  139. package/src/app/api/ai/query-safety/route.ts +87 -0
  140. package/src/app/api/auth/login/route.ts +62 -0
  141. package/src/app/api/auth/logout/route.ts +25 -0
  142. package/src/app/api/auth/me/route.ts +10 -0
  143. package/src/app/api/auth/oidc/callback/route.ts +82 -0
  144. package/src/app/api/auth/oidc/login/route.ts +43 -0
  145. package/src/app/api/connections/managed/route.ts +35 -0
  146. package/src/app/api/db/cancel/route.ts +42 -0
  147. package/src/app/api/db/disconnect/route.ts +28 -0
  148. package/src/app/api/db/health/route.ts +49 -0
  149. package/src/app/api/db/maintenance/route.ts +72 -0
  150. package/src/app/api/db/monitoring/route.ts +62 -0
  151. package/src/app/api/db/multi-query/route.ts +116 -0
  152. package/src/app/api/db/pool-stats/route.ts +37 -0
  153. package/src/app/api/db/profile/route.ts +144 -0
  154. package/src/app/api/db/provider-meta/route.ts +49 -0
  155. package/src/app/api/db/query/route.ts +50 -0
  156. package/src/app/api/db/schema/route.ts +47 -0
  157. package/src/app/api/db/schema-snapshot/route.ts +42 -0
  158. package/src/app/api/db/test-connection/route.ts +55 -0
  159. package/src/app/api/db/transaction/route.ts +111 -0
  160. package/src/app/api/storage/[collection]/route.ts +67 -0
  161. package/src/app/api/storage/config/route.ts +17 -0
  162. package/src/app/api/storage/migrate/route.ts +45 -0
  163. package/src/app/api/storage/route.ts +32 -0
  164. package/src/app/error.tsx +49 -0
  165. package/src/app/global-error.tsx +55 -0
  166. package/src/app/globals.css +146 -0
  167. package/src/app/icon.svg +42 -0
  168. package/src/app/layout.tsx +34 -0
  169. package/src/app/login/login-form.tsx +301 -0
  170. package/src/app/login/page.tsx +11 -0
  171. package/src/app/monitoring/page.tsx +8 -0
  172. package/src/app/not-found.tsx +29 -0
  173. package/src/app/page.tsx +5 -0
  174. package/src/components/AIAutopilotPanel.tsx +238 -0
  175. package/src/components/CodeGenerator.tsx +271 -0
  176. package/src/components/CommandPalette.tsx +227 -0
  177. package/src/components/ConnectionModal.tsx +759 -0
  178. package/src/components/CreateTableModal.tsx +281 -0
  179. package/src/components/DataCharts.tsx +962 -0
  180. package/src/components/DataImportModal.tsx +582 -0
  181. package/src/components/DataProfiler.tsx +335 -0
  182. package/src/components/DatabaseDocs.tsx +251 -0
  183. package/src/components/MaskingSettings.tsx +414 -0
  184. package/src/components/MobileNav.tsx +50 -0
  185. package/src/components/NL2SQLPanel.tsx +281 -0
  186. package/src/components/PivotTable.tsx +257 -0
  187. package/src/components/QueryEditor.tsx +760 -0
  188. package/src/components/QueryHistory.tsx +344 -0
  189. package/src/components/QuerySafetyDialog.tsx +290 -0
  190. package/src/components/ResultsGrid.tsx +644 -0
  191. package/src/components/SaveQueryModal.tsx +104 -0
  192. package/src/components/SavedQueries.tsx +128 -0
  193. package/src/components/SchemaDiagram.tsx +473 -0
  194. package/src/components/SchemaDiff.tsx +473 -0
  195. package/src/components/SnapshotTimeline.tsx +116 -0
  196. package/src/components/Studio.tsx +639 -0
  197. package/src/components/TestDataGenerator.tsx +261 -0
  198. package/src/components/VisualExplain.tsx +820 -0
  199. package/src/components/admin/AdminDashboard.tsx +163 -0
  200. package/src/components/admin/tabs/AuditTab.tsx +531 -0
  201. package/src/components/admin/tabs/MonitoringEmbed.tsx +11 -0
  202. package/src/components/admin/tabs/OperationsTab.tsx +646 -0
  203. package/src/components/admin/tabs/OverviewTab.tsx +1328 -0
  204. package/src/components/admin/tabs/SecurityTab.tsx +284 -0
  205. package/src/components/community-section.tsx +92 -0
  206. package/src/components/icons/db-icons.tsx +84 -0
  207. package/src/components/libredb-logo.tsx +61 -0
  208. package/src/components/monitoring/MonitoringDashboard.tsx +345 -0
  209. package/src/components/monitoring/tabs/MetricChart.tsx +82 -0
  210. package/src/components/monitoring/tabs/OverviewTab.tsx +263 -0
  211. package/src/components/monitoring/tabs/PerformanceTab.tsx +254 -0
  212. package/src/components/monitoring/tabs/PoolTab.tsx +174 -0
  213. package/src/components/monitoring/tabs/QueriesTab.tsx +287 -0
  214. package/src/components/monitoring/tabs/SessionsTab.tsx +316 -0
  215. package/src/components/monitoring/tabs/StorageTab.tsx +335 -0
  216. package/src/components/monitoring/tabs/TablesTab.tsx +300 -0
  217. package/src/components/results-grid/ResultCard.tsx +111 -0
  218. package/src/components/results-grid/RowDetailSheet.tsx +178 -0
  219. package/src/components/results-grid/StatsBar.tsx +201 -0
  220. package/src/components/results-grid/index.ts +1 -0
  221. package/src/components/results-grid/utils.ts +23 -0
  222. package/src/components/schema-explorer/ColumnList.tsx +53 -0
  223. package/src/components/schema-explorer/SchemaExplorer.tsx +182 -0
  224. package/src/components/schema-explorer/TableItem.tsx +210 -0
  225. package/src/components/schema-explorer/index.ts +1 -0
  226. package/src/components/sidebar/ConnectionItem.tsx +105 -0
  227. package/src/components/sidebar/ConnectionsList.tsx +62 -0
  228. package/src/components/sidebar/Sidebar.tsx +130 -0
  229. package/src/components/sidebar/index.ts +2 -0
  230. package/src/components/studio/BottomPanel.tsx +286 -0
  231. package/src/components/studio/QueryToolbar.tsx +180 -0
  232. package/src/components/studio/StudioDesktopHeader.tsx +114 -0
  233. package/src/components/studio/StudioMobileHeader.tsx +340 -0
  234. package/src/components/studio/StudioTabBar.tsx +82 -0
  235. package/src/components/studio/index.ts +5 -0
  236. package/src/components/ui/accordion.tsx +66 -0
  237. package/src/components/ui/alert-dialog.tsx +157 -0
  238. package/src/components/ui/alert.tsx +66 -0
  239. package/src/components/ui/aspect-ratio.tsx +11 -0
  240. package/src/components/ui/avatar.tsx +53 -0
  241. package/src/components/ui/badge.tsx +46 -0
  242. package/src/components/ui/breadcrumb.tsx +109 -0
  243. package/src/components/ui/button-group.tsx +83 -0
  244. package/src/components/ui/button.tsx +60 -0
  245. package/src/components/ui/calendar.tsx +216 -0
  246. package/src/components/ui/card.tsx +92 -0
  247. package/src/components/ui/carousel.tsx +241 -0
  248. package/src/components/ui/chart.tsx +357 -0
  249. package/src/components/ui/checkbox.tsx +32 -0
  250. package/src/components/ui/collapsible.tsx +33 -0
  251. package/src/components/ui/command.tsx +184 -0
  252. package/src/components/ui/context-menu.tsx +252 -0
  253. package/src/components/ui/dialog.tsx +143 -0
  254. package/src/components/ui/drawer.tsx +135 -0
  255. package/src/components/ui/dropdown-menu.tsx +257 -0
  256. package/src/components/ui/empty.tsx +104 -0
  257. package/src/components/ui/field.tsx +248 -0
  258. package/src/components/ui/form.tsx +167 -0
  259. package/src/components/ui/hover-card.tsx +44 -0
  260. package/src/components/ui/input-group.tsx +170 -0
  261. package/src/components/ui/input-otp.tsx +77 -0
  262. package/src/components/ui/input.tsx +21 -0
  263. package/src/components/ui/item.tsx +193 -0
  264. package/src/components/ui/kbd.tsx +28 -0
  265. package/src/components/ui/label.tsx +24 -0
  266. package/src/components/ui/menubar.tsx +276 -0
  267. package/src/components/ui/navigation-menu.tsx +168 -0
  268. package/src/components/ui/pagination.tsx +127 -0
  269. package/src/components/ui/popover.tsx +48 -0
  270. package/src/components/ui/progress.tsx +31 -0
  271. package/src/components/ui/radio-group.tsx +45 -0
  272. package/src/components/ui/resizable.tsx +56 -0
  273. package/src/components/ui/scroll-area.tsx +58 -0
  274. package/src/components/ui/select.tsx +187 -0
  275. package/src/components/ui/separator.tsx +28 -0
  276. package/src/components/ui/sheet.tsx +139 -0
  277. package/src/components/ui/sidebar.tsx +726 -0
  278. package/src/components/ui/skeleton.tsx +13 -0
  279. package/src/components/ui/slider.tsx +63 -0
  280. package/src/components/ui/sonner.tsx +40 -0
  281. package/src/components/ui/spinner.tsx +16 -0
  282. package/src/components/ui/switch.tsx +31 -0
  283. package/src/components/ui/table.tsx +116 -0
  284. package/src/components/ui/tabs.tsx +66 -0
  285. package/src/components/ui/textarea.tsx +18 -0
  286. package/src/components/ui/toggle-group.tsx +83 -0
  287. package/src/components/ui/toggle.tsx +47 -0
  288. package/src/components/ui/tooltip.tsx +61 -0
  289. package/src/exports/components.ts +15 -0
  290. package/src/exports/index.ts +4 -0
  291. package/src/exports/providers.ts +4 -0
  292. package/src/exports/types.ts +26 -0
  293. package/src/hooks/use-ai-chat.ts +182 -0
  294. package/src/hooks/use-all-connections.ts +66 -0
  295. package/src/hooks/use-api-call.ts +71 -0
  296. package/src/hooks/use-auth.ts +51 -0
  297. package/src/hooks/use-connection-form.ts +349 -0
  298. package/src/hooks/use-connection-manager.ts +169 -0
  299. package/src/hooks/use-connection-payload.ts +15 -0
  300. package/src/hooks/use-inline-editing.ts +109 -0
  301. package/src/hooks/use-mobile.ts +20 -0
  302. package/src/hooks/use-monitoring-data.ts +270 -0
  303. package/src/hooks/use-provider-metadata.ts +62 -0
  304. package/src/hooks/use-query-execution.ts +478 -0
  305. package/src/hooks/use-storage-sync.ts +259 -0
  306. package/src/hooks/use-tab-manager.ts +231 -0
  307. package/src/hooks/use-toast.ts +20 -0
  308. package/src/hooks/use-transaction-control.ts +64 -0
  309. package/src/lib/api/error-codes.ts +30 -0
  310. package/src/lib/api/errors.ts +236 -0
  311. package/src/lib/api/with-error-handler.ts +41 -0
  312. package/src/lib/audit.ts +105 -0
  313. package/src/lib/auth.ts +87 -0
  314. package/src/lib/connection-string-parser.ts +172 -0
  315. package/src/lib/data-masking.ts +385 -0
  316. package/src/lib/db/base-provider.ts +325 -0
  317. package/src/lib/db/errors.ts +317 -0
  318. package/src/lib/db/factory.ts +324 -0
  319. package/src/lib/db/index.ts +123 -0
  320. package/src/lib/db/providers/document/index.ts +6 -0
  321. package/src/lib/db/providers/document/mongodb.ts +992 -0
  322. package/src/lib/db/providers/keyvalue/redis.ts +554 -0
  323. package/src/lib/db/providers/sql/index.ts +11 -0
  324. package/src/lib/db/providers/sql/mssql.ts +1065 -0
  325. package/src/lib/db/providers/sql/mysql.ts +978 -0
  326. package/src/lib/db/providers/sql/oracle.ts +1044 -0
  327. package/src/lib/db/providers/sql/postgres.ts +1179 -0
  328. package/src/lib/db/providers/sql/sql-base.ts +174 -0
  329. package/src/lib/db/providers/sql/sqlite.ts +721 -0
  330. package/src/lib/db/types.ts +437 -0
  331. package/src/lib/db/utils/pool-manager.ts +287 -0
  332. package/src/lib/db/utils/query-limiter.ts +239 -0
  333. package/src/lib/db-ui-config.ts +86 -0
  334. package/src/lib/editor/mongodb-completions.ts +172 -0
  335. package/src/lib/editor/sql-completions.ts +280 -0
  336. package/src/lib/llm/base-provider.ts +117 -0
  337. package/src/lib/llm/factory.ts +102 -0
  338. package/src/lib/llm/index.ts +90 -0
  339. package/src/lib/llm/providers/custom.ts +181 -0
  340. package/src/lib/llm/providers/gemini.ts +126 -0
  341. package/src/lib/llm/providers/ollama.ts +154 -0
  342. package/src/lib/llm/providers/openai.ts +146 -0
  343. package/src/lib/llm/types.ts +173 -0
  344. package/src/lib/llm/utils/config.ts +187 -0
  345. package/src/lib/llm/utils/retry.ts +119 -0
  346. package/src/lib/llm/utils/streaming.ts +202 -0
  347. package/src/lib/logger.ts +127 -0
  348. package/src/lib/monitoring-thresholds.ts +44 -0
  349. package/src/lib/oidc.ts +262 -0
  350. package/src/lib/query-generators.ts +61 -0
  351. package/src/lib/schema-diff/diff-engine.ts +273 -0
  352. package/src/lib/schema-diff/migration-generator.ts +208 -0
  353. package/src/lib/schema-diff/types.ts +55 -0
  354. package/src/lib/seed/config-loader.ts +79 -0
  355. package/src/lib/seed/connection-filter.ts +49 -0
  356. package/src/lib/seed/credential-resolver.ts +62 -0
  357. package/src/lib/seed/index.ts +40 -0
  358. package/src/lib/seed/resolve-connection.ts +57 -0
  359. package/src/lib/seed/types.ts +69 -0
  360. package/src/lib/sql/alias-extractor.ts +267 -0
  361. package/src/lib/sql/index.ts +8 -0
  362. package/src/lib/sql/statement-splitter.ts +167 -0
  363. package/src/lib/sql/types.ts +40 -0
  364. package/src/lib/ssh/tunnel.ts +142 -0
  365. package/src/lib/storage/factory.ts +84 -0
  366. package/src/lib/storage/index.ts +14 -0
  367. package/src/lib/storage/local-storage.ts +99 -0
  368. package/src/lib/storage/providers/postgres.ts +225 -0
  369. package/src/lib/storage/providers/sqlite.ts +153 -0
  370. package/src/lib/storage/storage-facade.ts +272 -0
  371. package/src/lib/storage/types.ts +75 -0
  372. package/src/lib/time-series-buffer.ts +58 -0
  373. package/src/lib/types.ts +173 -0
  374. package/src/lib/utils.ts +6 -0
  375. package/src/proxy.ts +104 -0
  376. package/src/types/db-drivers.d.ts +23 -0
  377. package/src/types/html2canvas.d.ts +9 -0
  378. package/tests/api/admin/audit.test.ts +178 -0
  379. package/tests/api/admin/fleet-health.test.ts +183 -0
  380. package/tests/api/ai/autopilot.test.ts +174 -0
  381. package/tests/api/ai/chat.test.ts +250 -0
  382. package/tests/api/ai/describe-schema.test.ts +266 -0
  383. package/tests/api/ai/explain.test.ts +199 -0
  384. package/tests/api/ai/impact.test.ts +168 -0
  385. package/tests/api/ai/index-advisor.test.ts +171 -0
  386. package/tests/api/ai/nl2sql.test.ts +202 -0
  387. package/tests/api/ai/query-safety.test.ts +196 -0
  388. package/tests/api/auth/login.test.ts +170 -0
  389. package/tests/api/auth/logout.test.ts +140 -0
  390. package/tests/api/auth/me.test.ts +73 -0
  391. package/tests/api/auth/oidc-callback.test.ts +215 -0
  392. package/tests/api/auth/oidc-login.test.ts +127 -0
  393. package/tests/api/db/cancel.test.ts +198 -0
  394. package/tests/api/db/disconnect.test.ts +124 -0
  395. package/tests/api/db/health.test.ts +222 -0
  396. package/tests/api/db/maintenance.test.ts +263 -0
  397. package/tests/api/db/monitoring.test.ts +221 -0
  398. package/tests/api/db/multi-query.test.ts +316 -0
  399. package/tests/api/db/pool-stats.test.ts +135 -0
  400. package/tests/api/db/profile.test.ts +330 -0
  401. package/tests/api/db/provider-meta.test.ts +193 -0
  402. package/tests/api/db/query.test.ts +314 -0
  403. package/tests/api/db/schema-snapshot.test.ts +170 -0
  404. package/tests/api/db/schema.test.ts +191 -0
  405. package/tests/api/db/test-connection.test.ts +185 -0
  406. package/tests/api/db/transaction.test.ts +314 -0
  407. package/tests/api/proxy.test.ts +191 -0
  408. package/tests/api/seed/managed-route.test.ts +113 -0
  409. package/tests/api/storage/config.test.ts +42 -0
  410. package/tests/api/storage/storage-routes.test.ts +309 -0
  411. package/tests/components/AIAutopilotPanel.test.tsx +756 -0
  412. package/tests/components/AdminPage.test.tsx +33 -0
  413. package/tests/components/CodeGenerator.test.tsx +182 -0
  414. package/tests/components/CommandPalette.test.tsx +428 -0
  415. package/tests/components/CommunitySection.test.tsx +91 -0
  416. package/tests/components/ConnectionModal.mobile.test.tsx +284 -0
  417. package/tests/components/ConnectionModal.test.tsx +570 -0
  418. package/tests/components/CreateTableModal.test.tsx +383 -0
  419. package/tests/components/DataCharts.test.tsx +739 -0
  420. package/tests/components/DataImportModal.test.tsx +751 -0
  421. package/tests/components/DataProfiler.test.tsx +589 -0
  422. package/tests/components/DatabaseDocs.test.tsx +353 -0
  423. package/tests/components/LoginPage.test.tsx +163 -0
  424. package/tests/components/LoginPageOIDC.test.tsx +92 -0
  425. package/tests/components/MaskingSettings.test.tsx +498 -0
  426. package/tests/components/MobileNav.test.tsx +30 -0
  427. package/tests/components/MonitoringPage.test.tsx +32 -0
  428. package/tests/components/NL2SQLPanel.test.tsx +621 -0
  429. package/tests/components/Page.test.tsx +33 -0
  430. package/tests/components/PivotTable.test.tsx +350 -0
  431. package/tests/components/QueryEditor.test.tsx +1730 -0
  432. package/tests/components/QueryHistory.test.tsx +572 -0
  433. package/tests/components/QuerySafetyDialog.test.tsx +586 -0
  434. package/tests/components/ResultsGrid.test.tsx +804 -0
  435. package/tests/components/RootLayout.test.tsx +83 -0
  436. package/tests/components/SaveQueryModal.test.tsx +25 -0
  437. package/tests/components/SavedQueries.test.tsx +43 -0
  438. package/tests/components/SchemaDiagram.test.tsx +1034 -0
  439. package/tests/components/SchemaDiff.test.tsx +906 -0
  440. package/tests/components/SnapshotTimeline.test.tsx +174 -0
  441. package/tests/components/Studio.test.tsx +1030 -0
  442. package/tests/components/TestDataGenerator.test.tsx +291 -0
  443. package/tests/components/VisualExplain.test.tsx +704 -0
  444. package/tests/components/admin/AdminDashboard.test.tsx +205 -0
  445. package/tests/components/admin/AuditTab.test.tsx +220 -0
  446. package/tests/components/admin/MonitoringEmbed.test.tsx +58 -0
  447. package/tests/components/admin/OperationsTab.test.tsx +975 -0
  448. package/tests/components/admin/OverviewTab.test.tsx +254 -0
  449. package/tests/components/admin/SecurityTab.test.tsx +467 -0
  450. package/tests/components/monitoring/MetricChart.test.tsx +111 -0
  451. package/tests/components/monitoring/MonitoringDashboard.test.tsx +259 -0
  452. package/tests/components/monitoring/OverviewTab.test.tsx +78 -0
  453. package/tests/components/monitoring/PerformanceTab.test.tsx +87 -0
  454. package/tests/components/monitoring/PoolTab.test.tsx +42 -0
  455. package/tests/components/monitoring/QueriesTab.test.tsx +80 -0
  456. package/tests/components/monitoring/SessionsTab.test.tsx +154 -0
  457. package/tests/components/monitoring/StorageTab.test.tsx +127 -0
  458. package/tests/components/monitoring/TablesTab.test.tsx +153 -0
  459. package/tests/components/results-grid/ResultCard.test.tsx +105 -0
  460. package/tests/components/results-grid/RowDetailSheet.test.tsx +308 -0
  461. package/tests/components/results-grid/StatsBar.test.tsx +162 -0
  462. package/tests/components/schema-explorer/ColumnList.test.tsx +151 -0
  463. package/tests/components/schema-explorer/SchemaExplorer.test.tsx +461 -0
  464. package/tests/components/schema-explorer/TableItem.test.tsx +415 -0
  465. package/tests/components/sidebar/ConnectionItem.test.tsx +201 -0
  466. package/tests/components/sidebar/ConnectionsList.test.tsx +176 -0
  467. package/tests/components/sidebar/Sidebar.test.tsx +187 -0
  468. package/tests/components/studio/BottomPanel.test.tsx +383 -0
  469. package/tests/components/studio/QueryToolbar.test.tsx +321 -0
  470. package/tests/components/studio/StudioDesktopHeader.test.tsx +377 -0
  471. package/tests/components/studio/StudioMobileHeader.test.tsx +198 -0
  472. package/tests/components/studio/StudioTabBar.test.tsx +331 -0
  473. package/tests/fixtures/connections.ts +96 -0
  474. package/tests/fixtures/masking-configs.ts +86 -0
  475. package/tests/fixtures/query-results.ts +71 -0
  476. package/tests/fixtures/schemas.ts +64 -0
  477. package/tests/fixtures/seed-connections/invalid-config.yaml +7 -0
  478. package/tests/fixtures/seed-connections/minimal-config.yaml +8 -0
  479. package/tests/fixtures/seed-connections/mixed-credentials.yaml +23 -0
  480. package/tests/fixtures/seed-connections/multi-role-config.yaml +30 -0
  481. package/tests/fixtures/seed-connections/valid-config.json +15 -0
  482. package/tests/fixtures/seed-connections/valid-config.yaml +51 -0
  483. package/tests/helpers/mock-fetch.ts +59 -0
  484. package/tests/helpers/mock-monaco.ts +112 -0
  485. package/tests/helpers/mock-navigation.ts +28 -0
  486. package/tests/helpers/mock-next.ts +80 -0
  487. package/tests/helpers/mock-provider.ts +133 -0
  488. package/tests/helpers/mock-sonner.ts +29 -0
  489. package/tests/helpers/render-with-providers.tsx +19 -0
  490. package/tests/hooks/use-ai-chat.test.ts +600 -0
  491. package/tests/hooks/use-auth.test.ts +371 -0
  492. package/tests/hooks/use-connection-form.test.ts +743 -0
  493. package/tests/hooks/use-connection-manager.test.ts +466 -0
  494. package/tests/hooks/use-inline-editing.test.ts +321 -0
  495. package/tests/hooks/use-mobile.test.ts +177 -0
  496. package/tests/hooks/use-monitoring-data.test.ts +819 -0
  497. package/tests/hooks/use-provider-metadata.test.ts +228 -0
  498. package/tests/hooks/use-query-execution.test.ts +1212 -0
  499. package/tests/hooks/use-tab-manager.test.ts +756 -0
  500. package/tests/hooks/use-toast.test.ts +74 -0
  501. package/tests/hooks/use-transaction-control.test.ts +211 -0
  502. package/tests/integration/db/mongodb-provider.test.ts +698 -0
  503. package/tests/integration/db/mssql-provider.test.ts +840 -0
  504. package/tests/integration/db/mysql-provider.test.ts +872 -0
  505. package/tests/integration/db/oracle-provider.test.ts +843 -0
  506. package/tests/integration/db/postgres-provider.test.ts +1382 -0
  507. package/tests/integration/db/redis-provider.test.ts +526 -0
  508. package/tests/integration/db/sqlite-provider.test.ts +480 -0
  509. package/tests/integration/seed/seed-pipeline.test.ts +102 -0
  510. package/tests/isolated/factory-singleton.test.ts +150 -0
  511. package/tests/isolated/use-storage-sync.test.ts +389 -0
  512. package/tests/run-components.sh +196 -0
  513. package/tests/setup-dom.ts +58 -0
  514. package/tests/setup.ts +40 -0
  515. package/tests/unit/api-errors.test.ts +210 -0
  516. package/tests/unit/code-generator-functions.test.ts +271 -0
  517. package/tests/unit/components/column-list.test.tsx +190 -0
  518. package/tests/unit/components/data-import-modal.test.tsx +441 -0
  519. package/tests/unit/components/studio-mobile-header.test.tsx +327 -0
  520. package/tests/unit/data-charts-functions.test.ts +496 -0
  521. package/tests/unit/data-import-functions.test.ts +320 -0
  522. package/tests/unit/data-import-utils.test.ts +125 -0
  523. package/tests/unit/db/base-provider.test.ts +517 -0
  524. package/tests/unit/db/errors.test.ts +403 -0
  525. package/tests/unit/db/factory.test.ts +436 -0
  526. package/tests/unit/db/pool-manager.test.ts +440 -0
  527. package/tests/unit/db/query-limiter.test.ts +387 -0
  528. package/tests/unit/db/sql-base.test.ts +438 -0
  529. package/tests/unit/lib/api/error-codes.test.ts +39 -0
  530. package/tests/unit/lib/audit.test.ts +326 -0
  531. package/tests/unit/lib/auth.test.ts +146 -0
  532. package/tests/unit/lib/connection-string-parser.test.ts +424 -0
  533. package/tests/unit/lib/data-masking.test.ts +583 -0
  534. package/tests/unit/lib/db-icons.test.tsx +41 -0
  535. package/tests/unit/lib/monitoring-thresholds.test.ts +133 -0
  536. package/tests/unit/lib/oidc.test.ts +509 -0
  537. package/tests/unit/lib/query-generators.test.ts +127 -0
  538. package/tests/unit/lib/storage/factory.test.ts +71 -0
  539. package/tests/unit/lib/storage/local-storage.test.ts +114 -0
  540. package/tests/unit/lib/storage/providers/postgres.test.ts +312 -0
  541. package/tests/unit/lib/storage/providers/sqlite.test.ts +232 -0
  542. package/tests/unit/lib/storage/storage-facade-extended.test.ts +331 -0
  543. package/tests/unit/lib/storage/storage-facade.test.ts +184 -0
  544. package/tests/unit/lib/storage.test.ts +317 -0
  545. package/tests/unit/lib/time-series-buffer.test.ts +212 -0
  546. package/tests/unit/lib/utils.test.ts +24 -0
  547. package/tests/unit/llm/base-provider.test.ts +238 -0
  548. package/tests/unit/llm/config.test.ts +262 -0
  549. package/tests/unit/llm/custom-provider.test.ts +281 -0
  550. package/tests/unit/llm/gemini-provider.test.ts +248 -0
  551. package/tests/unit/llm/llm-factory.test.ts +155 -0
  552. package/tests/unit/llm/ollama-provider.test.ts +288 -0
  553. package/tests/unit/llm/openai-provider.test.ts +324 -0
  554. package/tests/unit/llm/retry.test.ts +180 -0
  555. package/tests/unit/llm/streaming.test.ts +355 -0
  556. package/tests/unit/logger.test.ts +198 -0
  557. package/tests/unit/mongodb-completions.test.ts +516 -0
  558. package/tests/unit/pivot-table-functions.test.ts +76 -0
  559. package/tests/unit/query-cancelled-error.test.ts +81 -0
  560. package/tests/unit/schema-diff/diff-engine.test.ts +367 -0
  561. package/tests/unit/schema-diff/migration-generator.test.ts +513 -0
  562. package/tests/unit/seed/config-loader.test.ts +73 -0
  563. package/tests/unit/seed/connection-filter.test.ts +91 -0
  564. package/tests/unit/seed/credential-resolver.test.ts +85 -0
  565. package/tests/unit/seed/index.test.ts +72 -0
  566. package/tests/unit/seed/resolve-connection.test.ts +74 -0
  567. package/tests/unit/seed/types.test.ts +129 -0
  568. package/tests/unit/sql/alias-extractor.test.ts +444 -0
  569. package/tests/unit/sql/statement-splitter.test.ts +348 -0
  570. package/tests/unit/sql-completions.test.ts +463 -0
  571. package/tests/unit/ssh-tunnel.test.ts +465 -0
  572. package/tsconfig.json +42 -0
@@ -0,0 +1,975 @@
1
+ import '../../setup-dom';
2
+ import '../../helpers/mock-sonner';
3
+ import '../../helpers/mock-navigation';
4
+
5
+ import { mock } from 'bun:test';
6
+ import { setupRechartssMock, setupFramerMotionMock } from '../../helpers/mock-monaco';
7
+
8
+ setupRechartssMock();
9
+ setupFramerMotionMock();
10
+
11
+ // ---- Trackable mock functions ----
12
+ const mockRefresh = mock(() => {});
13
+ const mockKillSession = mock(() => true);
14
+ const mockRunMaintenance = mock(() => true);
15
+
16
+ // ---- Override objects ----
17
+ let monitoringOverride: Record<string, unknown> = {};
18
+ let mockConnectionsList: Record<string, unknown>[] = [
19
+ {
20
+ id: 'c1',
21
+ name: 'PG Dev',
22
+ type: 'postgres',
23
+ host: 'localhost',
24
+ port: 5432,
25
+ database: 'dev',
26
+ createdAt: new Date(),
27
+ },
28
+ ];
29
+ let mockActiveConnectionId: string | null = 'c1';
30
+
31
+ const defaultSessions = [
32
+ {
33
+ pid: 1234,
34
+ user: 'admin',
35
+ state: 'active',
36
+ query: 'SELECT 1',
37
+ duration: '00:01:00',
38
+ durationMs: 60000,
39
+ database: 'dev',
40
+ },
41
+ ];
42
+
43
+ const defaultTables = [
44
+ {
45
+ tableName: 'users',
46
+ schemaName: 'public',
47
+ rowCount: 1000,
48
+ tableSize: '16 MB',
49
+ totalSize: '20 MB',
50
+ bloatRatio: 5,
51
+ },
52
+ ];
53
+
54
+ mock.module('@/hooks/use-monitoring-data', () => ({
55
+ useMonitoringData: mock(() => ({
56
+ data: {
57
+ activeSessions: defaultSessions,
58
+ tables: defaultTables,
59
+ },
60
+ loading: false,
61
+ error: null,
62
+ refresh: mockRefresh,
63
+ killSession: mockKillSession,
64
+ runMaintenance: mockRunMaintenance,
65
+ ...monitoringOverride,
66
+ })),
67
+ }));
68
+
69
+ mock.module('@/lib/storage', () => ({
70
+ storage: {
71
+ getConnections: mock(() => mockConnectionsList),
72
+ getActiveConnectionId: mock(() => mockActiveConnectionId),
73
+ },
74
+ }));
75
+
76
+ mock.module('@/lib/db-ui-config', () => ({
77
+ getDBIcon: () => {
78
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
79
+ const React = require('react');
80
+ return (props: Record<string, unknown>) => React.createElement('span', { ...props, 'data-testid': 'db-icon' });
81
+ },
82
+ getDBColor: () => 'text-blue-400',
83
+ }));
84
+
85
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
86
+ import { render, act, cleanup, fireEvent } from '@testing-library/react';
87
+ import React from 'react';
88
+
89
+ import { OperationsTab } from '@/components/admin/tabs/OperationsTab';
90
+
91
+ // =============================================================================
92
+ // Test data
93
+ // =============================================================================
94
+
95
+ const multiSessions = [
96
+ { pid: 100, user: 'admin', state: 'active', query: 'SELECT 1', duration: '00:00:05', durationMs: 5000, database: 'dev' },
97
+ { pid: 101, user: 'user1', state: 'idle', query: '', duration: '00:00:10', durationMs: 10000, database: 'dev' },
98
+ { pid: 102, user: 'user2', state: 'idle in transaction', query: 'UPDATE users SET x=1', duration: '00:02:00', durationMs: 120000, database: 'dev' },
99
+ { pid: 103, user: 'user3', state: 'idle in transaction (aborted)', query: 'INSERT INTO t', duration: '00:00:30', durationMs: 30000, database: 'dev' },
100
+ { pid: 104, user: 'user4', state: 'fastpath function call', query: '', duration: '00:00:01', durationMs: 1000, database: 'dev', waitEventType: 'Lock' },
101
+ ];
102
+
103
+ const multiTables = [
104
+ { tableName: 'users', schemaName: 'public', rowCount: 1000, tableSize: '16 MB', totalSize: '20 MB', bloatRatio: 5 },
105
+ { tableName: 'orders', schemaName: 'public', rowCount: 50000, tableSize: '128 MB', totalSize: '200 MB', bloatRatio: 25 },
106
+ { tableName: 'products', schemaName: 'public', rowCount: 200, tableSize: '2 MB', totalSize: '3 MB', bloatRatio: 0 },
107
+ ];
108
+
109
+ // =============================================================================
110
+ // OperationsTab Tests
111
+ // =============================================================================
112
+
113
+ describe('OperationsTab', () => {
114
+ beforeEach(() => {
115
+ // Reset overrides
116
+ monitoringOverride = {};
117
+ mockConnectionsList = [
118
+ { id: 'c1', name: 'PG Dev', type: 'postgres', host: 'localhost', port: 5432, database: 'dev', createdAt: new Date() },
119
+ ];
120
+ mockActiveConnectionId = 'c1';
121
+
122
+ // Clear mocks
123
+ mockRefresh.mockClear();
124
+ mockKillSession.mockClear();
125
+ mockKillSession.mockImplementation(() => true);
126
+ mockRunMaintenance.mockClear();
127
+ mockRunMaintenance.mockImplementation(() => true);
128
+ });
129
+
130
+ afterEach(() => {
131
+ cleanup();
132
+ });
133
+
134
+ // =========================================================================
135
+ // Existing rendering tests
136
+ // =========================================================================
137
+
138
+ test('renders connection selector', async () => {
139
+ let renderResult: ReturnType<typeof render>;
140
+ await act(async () => {
141
+ renderResult = render(<OperationsTab />);
142
+ });
143
+ const { queryByText } = renderResult!;
144
+ expect(queryByText('PG Dev')).not.toBeNull();
145
+ });
146
+
147
+ test('shows global operations section', async () => {
148
+ let renderResult: ReturnType<typeof render>;
149
+ await act(async () => {
150
+ renderResult = render(<OperationsTab />);
151
+ });
152
+ const { queryByText } = renderResult!;
153
+ expect(queryByText('Global Operations')).not.toBeNull();
154
+ expect(queryByText('Update Statistics')).not.toBeNull();
155
+ expect(queryByText('Reclaim Space')).not.toBeNull();
156
+ expect(queryByText('Rebuild Indexes')).not.toBeNull();
157
+ });
158
+
159
+ test('shows tables panel with table list', async () => {
160
+ let renderResult: ReturnType<typeof render>;
161
+ await act(async () => {
162
+ renderResult = render(<OperationsTab />);
163
+ });
164
+ const { queryByText } = renderResult!;
165
+ expect(queryByText('Tables (1)')).not.toBeNull();
166
+ expect(queryByText('users')).not.toBeNull();
167
+ });
168
+
169
+ test('shows sessions panel with session list', async () => {
170
+ let renderResult: ReturnType<typeof render>;
171
+ await act(async () => {
172
+ renderResult = render(<OperationsTab />);
173
+ });
174
+ const { queryByText } = renderResult!;
175
+ expect(queryByText('Sessions (1)')).not.toBeNull();
176
+ expect(queryByText('1234')).not.toBeNull();
177
+ });
178
+
179
+ test('maintenance buttons present', async () => {
180
+ let renderResult: ReturnType<typeof render>;
181
+ await act(async () => {
182
+ renderResult = render(<OperationsTab />);
183
+ });
184
+ const { queryByText } = renderResult!;
185
+ expect(queryByText('Run Analyze')).not.toBeNull();
186
+ expect(queryByText('Run Vacuum')).not.toBeNull();
187
+ expect(queryByText('Run Reindex')).not.toBeNull();
188
+ });
189
+
190
+ test('warning card present', async () => {
191
+ let renderResult: ReturnType<typeof render>;
192
+ await act(async () => {
193
+ renderResult = render(<OperationsTab />);
194
+ });
195
+ const { queryByText } = renderResult!;
196
+ expect(queryByText('Warning')).not.toBeNull();
197
+ expect(queryByText(/resource-intensive/)).not.toBeNull();
198
+ });
199
+
200
+ test('shows table size information', async () => {
201
+ let renderResult: ReturnType<typeof render>;
202
+ await act(async () => {
203
+ renderResult = render(<OperationsTab />);
204
+ });
205
+ const { queryByText } = renderResult!;
206
+ expect(queryByText('16 MB')).not.toBeNull();
207
+ });
208
+
209
+ test('shows session user info', async () => {
210
+ let renderResult: ReturnType<typeof render>;
211
+ await act(async () => {
212
+ renderResult = render(<OperationsTab />);
213
+ });
214
+ const { queryByText } = renderResult!;
215
+ expect(queryByText('admin')).not.toBeNull();
216
+ });
217
+
218
+ test('shows session query info', async () => {
219
+ let renderResult: ReturnType<typeof render>;
220
+ await act(async () => {
221
+ renderResult = render(<OperationsTab />);
222
+ });
223
+ const { container } = renderResult!;
224
+ expect(container.textContent).toContain('SELECT 1');
225
+ });
226
+
227
+ test('shows session duration', async () => {
228
+ let renderResult: ReturnType<typeof render>;
229
+ await act(async () => {
230
+ renderResult = render(<OperationsTab />);
231
+ });
232
+ const { container } = renderResult!;
233
+ expect(container.textContent).toContain('00:01:00');
234
+ });
235
+
236
+ test('shows row count for tables', async () => {
237
+ let renderResult: ReturnType<typeof render>;
238
+ await act(async () => {
239
+ renderResult = render(<OperationsTab />);
240
+ });
241
+ const { container } = renderResult!;
242
+ expect(container.textContent).toMatch(/1,?000/);
243
+ });
244
+
245
+ test('shows connection type in selector', async () => {
246
+ let renderResult: ReturnType<typeof render>;
247
+ await act(async () => {
248
+ renderResult = render(<OperationsTab />);
249
+ });
250
+ const { container } = renderResult!;
251
+ expect(container.textContent).toContain('(postgres)');
252
+ });
253
+
254
+ test('shows session state as Active badge', async () => {
255
+ let renderResult: ReturnType<typeof render>;
256
+ await act(async () => {
257
+ renderResult = render(<OperationsTab />);
258
+ });
259
+ const { container } = renderResult!;
260
+ expect(container.textContent).toContain('Active');
261
+ });
262
+
263
+ // =========================================================================
264
+ // Empty state: no connections
265
+ // =========================================================================
266
+
267
+ test('shows empty state when no connections', async () => {
268
+ mockConnectionsList = [];
269
+ let renderResult: ReturnType<typeof render>;
270
+ await act(async () => {
271
+ renderResult = render(<OperationsTab />);
272
+ });
273
+ const { queryByText } = renderResult!;
274
+ expect(queryByText('No Database Connections')).not.toBeNull();
275
+ expect(queryByText(/add a database connection/i)).not.toBeNull();
276
+ });
277
+
278
+ // =========================================================================
279
+ // Error state
280
+ // =========================================================================
281
+
282
+ test('shows error message when error and no data', async () => {
283
+ monitoringOverride = { data: null, error: 'Connection refused' };
284
+ let renderResult: ReturnType<typeof render>;
285
+ await act(async () => {
286
+ renderResult = render(<OperationsTab />);
287
+ });
288
+ const { queryByText } = renderResult!;
289
+ expect(queryByText('Connection refused')).not.toBeNull();
290
+ });
291
+
292
+ // =========================================================================
293
+ // Loading state
294
+ // =========================================================================
295
+
296
+ test('shows loading skeletons when loading with no data', async () => {
297
+ monitoringOverride = {
298
+ data: { activeSessions: [], tables: [] },
299
+ loading: true,
300
+ };
301
+ let renderResult: ReturnType<typeof render>;
302
+ await act(async () => {
303
+ renderResult = render(<OperationsTab />);
304
+ });
305
+ const { container } = renderResult!;
306
+ // At minimum, the component renders skeleton placeholders when loading
307
+ expect(container.textContent).toContain('Tables (0)');
308
+ expect(container.textContent).toContain('Sessions (0)');
309
+ });
310
+
311
+ // =========================================================================
312
+ // Empty sessions / empty tables
313
+ // =========================================================================
314
+
315
+ test('shows no sessions message when empty', async () => {
316
+ monitoringOverride = {
317
+ data: { activeSessions: [], tables: defaultTables },
318
+ };
319
+ let renderResult: ReturnType<typeof render>;
320
+ await act(async () => {
321
+ renderResult = render(<OperationsTab />);
322
+ });
323
+ const { queryByText } = renderResult!;
324
+ expect(queryByText('No active sessions found.')).not.toBeNull();
325
+ });
326
+
327
+ // =========================================================================
328
+ // Table search filter
329
+ // =========================================================================
330
+
331
+ test('filters tables by search input', async () => {
332
+ monitoringOverride = {
333
+ data: { activeSessions: defaultSessions, tables: multiTables },
334
+ };
335
+ let renderResult: ReturnType<typeof render>;
336
+ await act(async () => {
337
+ renderResult = render(<OperationsTab />);
338
+ });
339
+ const { container, queryByText } = renderResult!;
340
+
341
+ // All 3 tables visible initially
342
+ expect(queryByText('Tables (3)')).not.toBeNull();
343
+ expect(queryByText('users')).not.toBeNull();
344
+ expect(queryByText('orders')).not.toBeNull();
345
+ expect(queryByText('products')).not.toBeNull();
346
+
347
+ // Type in filter input
348
+ const filterInput = container.querySelector('input[placeholder="Filter..."]') as HTMLInputElement;
349
+ expect(filterInput).not.toBeNull();
350
+ await act(async () => {
351
+ fireEvent.change(filterInput, { target: { value: 'ord' } });
352
+ });
353
+
354
+ // Only 'orders' should match
355
+ expect(queryByText('orders')).not.toBeNull();
356
+ expect(queryByText('users')).toBeNull();
357
+ expect(queryByText('products')).toBeNull();
358
+ });
359
+
360
+ test('shows no tables found when filter matches nothing', async () => {
361
+ let renderResult: ReturnType<typeof render>;
362
+ await act(async () => {
363
+ renderResult = render(<OperationsTab />);
364
+ });
365
+ const { container, queryByText } = renderResult!;
366
+
367
+ const filterInput = container.querySelector('input[placeholder="Filter..."]') as HTMLInputElement;
368
+ await act(async () => {
369
+ fireEvent.change(filterInput, { target: { value: 'zzz_nonexistent' } });
370
+ });
371
+ expect(queryByText('No tables found.')).not.toBeNull();
372
+ });
373
+
374
+ // =========================================================================
375
+ // Bloat ratio badge
376
+ // =========================================================================
377
+
378
+ test('shows bloat ratio badge for high-bloat tables', async () => {
379
+ monitoringOverride = {
380
+ data: { activeSessions: defaultSessions, tables: multiTables },
381
+ };
382
+ let renderResult: ReturnType<typeof render>;
383
+ await act(async () => {
384
+ renderResult = render(<OperationsTab />);
385
+ });
386
+ const { container } = renderResult!;
387
+
388
+ // 'orders' table has 25% bloat (>10%) — should show badge
389
+ expect(container.textContent).toContain('25% bloat');
390
+ // 'products' table has 0% bloat — no bloat badge (only one bloat badge total)
391
+ const bloatBadges = container.textContent!.match(/\d+% bloat/g) || [];
392
+ expect(bloatBadges).toEqual(['25% bloat']);
393
+ });
394
+
395
+ // =========================================================================
396
+ // Session state badge variants
397
+ // =========================================================================
398
+
399
+ test('renders correct badges for different session states', async () => {
400
+ monitoringOverride = {
401
+ data: { activeSessions: multiSessions, tables: defaultTables },
402
+ };
403
+ let renderResult: ReturnType<typeof render>;
404
+ await act(async () => {
405
+ renderResult = render(<OperationsTab />);
406
+ });
407
+ const { container } = renderResult!;
408
+ const text = container.textContent || '';
409
+
410
+ expect(text).toContain('Active');
411
+ expect(text).toContain('Idle');
412
+ expect(text).toContain('Idle TX');
413
+ expect(text).toContain('Abort');
414
+ // Default state — 'fastpath function call'
415
+ expect(text).toContain('fastpath function call');
416
+ });
417
+
418
+ // =========================================================================
419
+ // Session summary counts
420
+ // =========================================================================
421
+
422
+ test('shows correct session summary counts', async () => {
423
+ monitoringOverride = {
424
+ data: { activeSessions: multiSessions, tables: defaultTables },
425
+ };
426
+ let renderResult: ReturnType<typeof render>;
427
+ await act(async () => {
428
+ renderResult = render(<OperationsTab />);
429
+ });
430
+ const { container } = renderResult!;
431
+ const text = container.textContent || '';
432
+
433
+ // multiSessions: 1 active, 1 idle, 2 idle in tx (one normal, one aborted), 1 waiting
434
+ expect(text).toContain('Sessions (5)');
435
+ });
436
+
437
+ // =========================================================================
438
+ // Refresh button
439
+ // =========================================================================
440
+
441
+ test('refresh button calls refresh', async () => {
442
+ let renderResult: ReturnType<typeof render>;
443
+ await act(async () => {
444
+ renderResult = render(<OperationsTab />);
445
+ });
446
+ const { queryByText } = renderResult!;
447
+
448
+ const refreshBtn = queryByText('Refresh');
449
+ expect(refreshBtn).not.toBeNull();
450
+ await act(async () => {
451
+ fireEvent.click(refreshBtn!.closest('button')!);
452
+ });
453
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
454
+ });
455
+
456
+ // =========================================================================
457
+ // handleRunMaintenance — success
458
+ // =========================================================================
459
+
460
+ test('handleRunMaintenance success adds success log entry', async () => {
461
+ let renderResult: ReturnType<typeof render>;
462
+ await act(async () => {
463
+ renderResult = render(<OperationsTab />);
464
+ });
465
+ const { queryByText } = renderResult!;
466
+
467
+ // Click "Run Analyze"
468
+ const analyzeBtn = queryByText('Run Analyze');
469
+ expect(analyzeBtn).not.toBeNull();
470
+ await act(async () => {
471
+ fireEvent.click(analyzeBtn!.closest('button')!);
472
+ });
473
+
474
+ expect(mockRunMaintenance).toHaveBeenCalledWith('analyze', undefined);
475
+ // Operation log should appear with success
476
+ expect(queryByText('Operation Log (this session)')).not.toBeNull();
477
+ expect(queryByText('ANALYZE')).not.toBeNull();
478
+ });
479
+
480
+ test('handleRunMaintenance vacuum', async () => {
481
+ let renderResult: ReturnType<typeof render>;
482
+ await act(async () => {
483
+ renderResult = render(<OperationsTab />);
484
+ });
485
+ const { queryByText } = renderResult!;
486
+
487
+ const vacuumBtn = queryByText('Run Vacuum');
488
+ await act(async () => {
489
+ fireEvent.click(vacuumBtn!.closest('button')!);
490
+ });
491
+ expect(mockRunMaintenance).toHaveBeenCalledWith('vacuum', undefined);
492
+ expect(queryByText('VACUUM')).not.toBeNull();
493
+ });
494
+
495
+ test('handleRunMaintenance reindex', async () => {
496
+ let renderResult: ReturnType<typeof render>;
497
+ await act(async () => {
498
+ renderResult = render(<OperationsTab />);
499
+ });
500
+ const { queryByText } = renderResult!;
501
+
502
+ const reindexBtn = queryByText('Run Reindex');
503
+ await act(async () => {
504
+ fireEvent.click(reindexBtn!.closest('button')!);
505
+ });
506
+ expect(mockRunMaintenance).toHaveBeenCalledWith('reindex', undefined);
507
+ expect(queryByText('REINDEX')).not.toBeNull();
508
+ });
509
+
510
+ // =========================================================================
511
+ // handleRunMaintenance — failure (returns false)
512
+ // =========================================================================
513
+
514
+ test('handleRunMaintenance failure shows failure in log', async () => {
515
+ mockRunMaintenance.mockImplementation(() => false);
516
+ let renderResult: ReturnType<typeof render>;
517
+ await act(async () => {
518
+ renderResult = render(<OperationsTab />);
519
+ });
520
+ const { queryByText, container } = renderResult!;
521
+
522
+ const analyzeBtn = queryByText('Run Analyze');
523
+ await act(async () => {
524
+ fireEvent.click(analyzeBtn!.closest('button')!);
525
+ });
526
+
527
+ expect(queryByText('ANALYZE')).not.toBeNull();
528
+ // The log entry should show — the component uses XCircle icon for failure
529
+ // We verify the log appears
530
+ expect(queryByText('Operation Log (this session)')).not.toBeNull();
531
+ // Target is 'all' for global operation
532
+ expect(container.textContent).toContain('all');
533
+ });
534
+
535
+ // =========================================================================
536
+ // handleRunMaintenance — exception (catch block)
537
+ // =========================================================================
538
+
539
+ test('handleRunMaintenance exception adds failure log entry', async () => {
540
+ mockRunMaintenance.mockImplementation(() => { throw new Error('DB error'); });
541
+ let renderResult: ReturnType<typeof render>;
542
+ await act(async () => {
543
+ renderResult = render(<OperationsTab />);
544
+ });
545
+ const { queryByText } = renderResult!;
546
+
547
+ const analyzeBtn = queryByText('Run Analyze');
548
+ await act(async () => {
549
+ fireEvent.click(analyzeBtn!.closest('button')!);
550
+ });
551
+
552
+ // Log should appear with failure entry
553
+ expect(queryByText('Operation Log (this session)')).not.toBeNull();
554
+ expect(queryByText('ANALYZE')).not.toBeNull();
555
+ });
556
+
557
+ // =========================================================================
558
+ // handleRunMaintenance — per-table operation
559
+ // =========================================================================
560
+
561
+ test('handleRunMaintenance for specific table', async () => {
562
+ let renderResult: ReturnType<typeof render>;
563
+ await act(async () => {
564
+ renderResult = render(<OperationsTab />);
565
+ });
566
+ const { container } = renderResult!;
567
+
568
+ // Find the table row for 'users' and click its analyze button (first icon button)
569
+ const tableRows = container.querySelectorAll('.divide-y > div');
570
+ const usersRow = Array.from(tableRows).find(row => row.textContent?.includes('users'));
571
+ expect(usersRow).not.toBeNull();
572
+
573
+ const buttons = usersRow!.querySelectorAll('button');
574
+ // First button is Analyze, second is Vacuum
575
+ expect(buttons.length).toBeGreaterThanOrEqual(2);
576
+ await act(async () => {
577
+ fireEvent.click(buttons[0]!);
578
+ });
579
+
580
+ expect(mockRunMaintenance).toHaveBeenCalledWith('analyze', 'users');
581
+ });
582
+
583
+ test('per-table vacuum button calls runMaintenance with table name', async () => {
584
+ let renderResult: ReturnType<typeof render>;
585
+ await act(async () => {
586
+ renderResult = render(<OperationsTab />);
587
+ });
588
+ const { container } = renderResult!;
589
+
590
+ const tableRows = container.querySelectorAll('.divide-y > div');
591
+ const usersRow = Array.from(tableRows).find(row => row.textContent?.includes('users'));
592
+ const buttons = usersRow!.querySelectorAll('button');
593
+ await act(async () => {
594
+ fireEvent.click(buttons[1]!);
595
+ });
596
+
597
+ expect(mockRunMaintenance).toHaveBeenCalledWith('vacuum', 'users');
598
+ });
599
+
600
+ // =========================================================================
601
+ // Kill session flow
602
+ // =========================================================================
603
+
604
+ test('kill button opens confirmation dialog', async () => {
605
+ let renderResult: ReturnType<typeof render>;
606
+ await act(async () => {
607
+ renderResult = render(<OperationsTab />);
608
+ });
609
+ const { container, baseElement } = renderResult!;
610
+
611
+ // Find the session row by PID 1234
612
+ const cells = container.querySelectorAll('td');
613
+ const pidCell = Array.from(cells).find(td => td.textContent?.includes('1234'));
614
+ expect(pidCell).not.toBeNull();
615
+ const row = pidCell!.closest('tr');
616
+ const killBtn = row!.querySelector('td:last-child button');
617
+ expect(killBtn).not.toBeNull();
618
+
619
+ await act(async () => {
620
+ fireEvent.click(killBtn!);
621
+ });
622
+
623
+ // Confirmation dialog should appear (may be portaled)
624
+ const dialogText = baseElement.textContent || '';
625
+ expect(dialogText).toContain('Terminate Session?');
626
+ expect(dialogText).toContain('1234');
627
+ });
628
+
629
+ test('confirming kill calls killSession and adds log entry', async () => {
630
+ let renderResult: ReturnType<typeof render>;
631
+ await act(async () => {
632
+ renderResult = render(<OperationsTab />);
633
+ });
634
+ const { container, baseElement } = renderResult!;
635
+
636
+ // Click kill button
637
+ const cells = container.querySelectorAll('td');
638
+ const pidCell = Array.from(cells).find(td => td.textContent?.includes('1234'));
639
+ const row = pidCell!.closest('tr');
640
+ const killBtn = row!.querySelector('td:last-child button');
641
+ await act(async () => {
642
+ fireEvent.click(killBtn!);
643
+ });
644
+
645
+ // Find and click "Terminate" button in the dialog
646
+ const allButtons = baseElement.querySelectorAll('button');
647
+ const terminateBtn = Array.from(allButtons).find(btn => btn.textContent?.trim() === 'Terminate');
648
+ expect(terminateBtn).not.toBeNull();
649
+ await act(async () => {
650
+ fireEvent.click(terminateBtn!);
651
+ });
652
+
653
+ expect(mockKillSession).toHaveBeenCalledWith(1234);
654
+ // Log entry should appear
655
+ expect(baseElement.textContent).toContain('KILL');
656
+ expect(baseElement.textContent).toContain('PID:1234');
657
+ });
658
+
659
+ test('cancel kill dialog does not call killSession', async () => {
660
+ let renderResult: ReturnType<typeof render>;
661
+ await act(async () => {
662
+ renderResult = render(<OperationsTab />);
663
+ });
664
+ const { container, baseElement } = renderResult!;
665
+
666
+ // Click kill button
667
+ const cells = container.querySelectorAll('td');
668
+ const pidCell = Array.from(cells).find(td => td.textContent?.includes('1234'));
669
+ const row = pidCell!.closest('tr');
670
+ const killBtn = row!.querySelector('td:last-child button');
671
+ await act(async () => {
672
+ fireEvent.click(killBtn!);
673
+ });
674
+
675
+ // Find and click "Cancel" button
676
+ const allButtons = baseElement.querySelectorAll('button');
677
+ const cancelBtn = Array.from(allButtons).find(btn => btn.textContent?.trim() === 'Cancel');
678
+ expect(cancelBtn).not.toBeNull();
679
+ await act(async () => {
680
+ fireEvent.click(cancelBtn!);
681
+ });
682
+
683
+ expect(mockKillSession).not.toHaveBeenCalled();
684
+ });
685
+
686
+ // =========================================================================
687
+ // Session duration badge variants
688
+ // =========================================================================
689
+
690
+ test('session with >60s shows destructive duration badge', async () => {
691
+ monitoringOverride = {
692
+ data: {
693
+ activeSessions: [
694
+ { pid: 200, user: 'u1', state: 'active', query: 'Q', duration: '00:02:00', durationMs: 120000, database: 'dev' },
695
+ { pid: 201, user: 'u2', state: 'idle', query: '', duration: '00:00:05', durationMs: 5000, database: 'dev' },
696
+ ],
697
+ tables: defaultTables,
698
+ },
699
+ };
700
+ let renderResult: ReturnType<typeof render>;
701
+ await act(async () => {
702
+ renderResult = render(<OperationsTab />);
703
+ });
704
+ const { container } = renderResult!;
705
+ const text = container.textContent || '';
706
+ expect(text).toContain('00:02:00');
707
+ expect(text).toContain('00:00:05');
708
+ });
709
+
710
+ // =========================================================================
711
+ // Connection selection with saved active ID
712
+ // =========================================================================
713
+
714
+ test('selects saved active connection on mount', async () => {
715
+ mockConnectionsList = [
716
+ { id: 'c1', name: 'PG Dev', type: 'postgres', host: 'localhost', port: 5432, database: 'dev', createdAt: new Date() },
717
+ { id: 'c2', name: 'MySQL Prod', type: 'mysql', host: 'localhost', port: 3306, database: 'prod', createdAt: new Date() },
718
+ ];
719
+ mockActiveConnectionId = 'c2';
720
+ let renderResult: ReturnType<typeof render>;
721
+ await act(async () => {
722
+ renderResult = render(<OperationsTab />);
723
+ });
724
+ const { queryByText } = renderResult!;
725
+ // Should show MySQL Prod as selected (savedId matches c2)
726
+ expect(queryByText('MySQL Prod')).not.toBeNull();
727
+ });
728
+
729
+ test('falls back to first connection when savedId not found', async () => {
730
+ mockActiveConnectionId = 'nonexistent';
731
+ let renderResult: ReturnType<typeof render>;
732
+ await act(async () => {
733
+ renderResult = render(<OperationsTab />);
734
+ });
735
+ const { queryByText } = renderResult!;
736
+ expect(queryByText('PG Dev')).not.toBeNull();
737
+ });
738
+
739
+ test('falls back to first connection when no savedId', async () => {
740
+ mockActiveConnectionId = null;
741
+ let renderResult: ReturnType<typeof render>;
742
+ await act(async () => {
743
+ renderResult = render(<OperationsTab />);
744
+ });
745
+ const { queryByText } = renderResult!;
746
+ expect(queryByText('PG Dev')).not.toBeNull();
747
+ });
748
+
749
+ // =========================================================================
750
+ // Session with no query shows dash
751
+ // =========================================================================
752
+
753
+ test('session with no query shows dash', async () => {
754
+ monitoringOverride = {
755
+ data: {
756
+ activeSessions: [
757
+ { pid: 300, user: 'admin', state: 'idle', query: '', duration: '00:00:01', durationMs: 1000, database: 'dev' },
758
+ ],
759
+ tables: defaultTables,
760
+ },
761
+ };
762
+ let renderResult: ReturnType<typeof render>;
763
+ await act(async () => {
764
+ renderResult = render(<OperationsTab />);
765
+ });
766
+ const { container } = renderResult!;
767
+ // When query is empty, component shows '-'
768
+ const cells = container.querySelectorAll('td');
769
+ const queryCell = Array.from(cells).find(td => td.textContent?.trim() === '-');
770
+ expect(queryCell).not.toBeNull();
771
+ });
772
+
773
+ // =========================================================================
774
+ // Loading skeletons — tables panel
775
+ // =========================================================================
776
+
777
+ test('shows loading skeletons in tables panel when loading=true and tables empty', async () => {
778
+ monitoringOverride = {
779
+ data: { activeSessions: defaultSessions, tables: [] },
780
+ loading: true,
781
+ };
782
+ let renderResult: ReturnType<typeof render>;
783
+ await act(async () => {
784
+ renderResult = render(<OperationsTab />);
785
+ });
786
+ const { container } = renderResult!;
787
+
788
+ // The tables panel shows 5 Skeleton divs when loading && tables.length === 0
789
+ const allSkeletons = container.querySelectorAll('[data-slot="skeleton"]');
790
+ // Tables panel renders 5 skeletons; sessions panel has data so no skeletons there
791
+ expect(allSkeletons.length).toBe(5);
792
+ // Tables count header still shows 0
793
+ expect(container.textContent).toContain('Tables (0)');
794
+ // Sessions should render normally (not skeletons) since sessions have data
795
+ expect(container.textContent).toContain('Sessions (1)');
796
+ });
797
+
798
+ // =========================================================================
799
+ // Loading skeletons — sessions panel
800
+ // =========================================================================
801
+
802
+ test('shows loading skeletons in sessions panel when loading=true and sessions empty', async () => {
803
+ monitoringOverride = {
804
+ data: { activeSessions: [], tables: defaultTables },
805
+ loading: true,
806
+ };
807
+ let renderResult: ReturnType<typeof render>;
808
+ await act(async () => {
809
+ renderResult = render(<OperationsTab />);
810
+ });
811
+ const { container } = renderResult!;
812
+
813
+ // The sessions panel shows 4 Skeleton divs when loading && sessions.length === 0
814
+ const allSkeletons = container.querySelectorAll('[data-slot="skeleton"]');
815
+ expect(allSkeletons.length).toBe(4);
816
+ // Sessions count header still shows 0
817
+ expect(container.textContent).toContain('Sessions (0)');
818
+ // Tables should render normally since tables have data
819
+ expect(container.textContent).toContain('Tables (1)');
820
+ expect(container.textContent).toContain('users');
821
+ });
822
+
823
+ // =========================================================================
824
+ // handleConnectionChange with non-existent connection id (guard)
825
+ // =========================================================================
826
+
827
+ test('handleConnectionChange with non-existent id does not change selection', async () => {
828
+ // Use a non-existent savedId to test the guard in handleConnectionChange
829
+ // When savedId doesn't match any connection, it falls back to first connection
830
+ mockConnectionsList = [
831
+ { id: 'c1', name: 'PG Dev', type: 'postgres', host: 'localhost', port: 5432, database: 'dev', createdAt: new Date() },
832
+ { id: 'c2', name: 'MySQL Prod', type: 'mysql', host: 'localhost', port: 3306, database: 'prod', createdAt: new Date() },
833
+ ];
834
+ // Set savedId to a non-existent id
835
+ mockActiveConnectionId = 'nonexistent-id';
836
+ let renderResult: ReturnType<typeof render>;
837
+ await act(async () => {
838
+ renderResult = render(<OperationsTab />);
839
+ });
840
+ const { container } = renderResult!;
841
+
842
+ // Since savedId doesn't match any connection, the guard falls back to first connection (PG Dev)
843
+ expect(container.textContent).toContain('PG Dev');
844
+ expect(container.textContent).toContain('(postgres)');
845
+
846
+ // The component should not crash and should still render the monitoring data
847
+ expect(container.textContent).toContain('Global Operations');
848
+ expect(container.textContent).toContain('Sessions');
849
+ expect(container.textContent).toContain('Tables');
850
+ });
851
+
852
+ // =========================================================================
853
+ // Session duration badge outline variant (10s-60s range)
854
+ // =========================================================================
855
+
856
+ test('session with 10s-60s duration shows outline variant badge', async () => {
857
+ monitoringOverride = {
858
+ data: {
859
+ activeSessions: [
860
+ { pid: 400, user: 'u1', state: 'active', query: 'SELECT slow()', duration: '00:00:30', durationMs: 30000, database: 'dev' },
861
+ { pid: 401, user: 'u2', state: 'idle', query: '', duration: '00:00:05', durationMs: 5000, database: 'dev' },
862
+ { pid: 402, user: 'u3', state: 'active', query: 'SELECT very_slow()', duration: '00:02:00', durationMs: 120000, database: 'dev' },
863
+ ],
864
+ tables: defaultTables,
865
+ },
866
+ };
867
+ let renderResult: ReturnType<typeof render>;
868
+ await act(async () => {
869
+ renderResult = render(<OperationsTab />);
870
+ });
871
+ const { container } = renderResult!;
872
+
873
+ // Duration badge variant logic:
874
+ // PID 400: durationMs=30000 (>10000, <=60000) -> variant="outline"
875
+ // PID 401: durationMs=5000 (<=10000) -> variant="secondary"
876
+ // PID 402: durationMs=120000 (>60000) -> variant="destructive"
877
+
878
+ // Badge component renders as <span data-slot="badge">
879
+ const allBadges = Array.from(container.querySelectorAll('span[data-slot="badge"]'));
880
+
881
+ // Find the badge containing '00:00:30' (outline variant for 10s-60s range)
882
+ const durationBadge400 = allBadges.find(
883
+ badge => badge.textContent?.includes('00:00:30')
884
+ );
885
+ expect(durationBadge400).toBeDefined();
886
+ // Outline variant: has text-foreground, does NOT have bg-destructive or bg-secondary
887
+ expect(durationBadge400!.className).toContain('text-foreground');
888
+ expect(durationBadge400!.className).not.toContain('bg-destructive');
889
+ expect(durationBadge400!.className).not.toContain('bg-secondary');
890
+ expect(durationBadge400!.className).not.toContain('border-transparent');
891
+
892
+ // Find the badge containing '00:02:00' (destructive variant for >60s)
893
+ const durationBadge402 = allBadges.find(
894
+ badge => badge.textContent?.includes('00:02:00')
895
+ );
896
+ expect(durationBadge402).toBeDefined();
897
+ expect(durationBadge402!.className).toContain('bg-destructive');
898
+
899
+ // Find the badge containing '00:00:05' (secondary variant for <=10s)
900
+ const durationBadge401 = allBadges.find(
901
+ badge => badge.textContent?.includes('00:00:05')
902
+ );
903
+ expect(durationBadge401).toBeDefined();
904
+ expect(durationBadge401!.className).toContain('bg-secondary');
905
+ });
906
+
907
+ // =========================================================================
908
+ // Kill dialog shows user and state in description
909
+ // =========================================================================
910
+
911
+ test('kill dialog shows user and state in description', async () => {
912
+ monitoringOverride = {
913
+ data: {
914
+ activeSessions: [
915
+ { pid: 500, user: 'db_admin', state: 'idle in transaction', query: 'UPDATE t SET x=1', duration: '00:05:00', durationMs: 300000, database: 'dev' },
916
+ ],
917
+ tables: defaultTables,
918
+ },
919
+ };
920
+ let renderResult: ReturnType<typeof render>;
921
+ await act(async () => {
922
+ renderResult = render(<OperationsTab />);
923
+ });
924
+ const { container, baseElement } = renderResult!;
925
+
926
+ // Find and click the kill button for PID 500
927
+ const cells = container.querySelectorAll('td');
928
+ const pidCell = Array.from(cells).find(td => td.textContent?.includes('500'));
929
+ expect(pidCell).not.toBeNull();
930
+ const row = pidCell!.closest('tr');
931
+ const killBtn = row!.querySelector('td:last-child button');
932
+ expect(killBtn).not.toBeNull();
933
+
934
+ await act(async () => {
935
+ fireEvent.click(killBtn!);
936
+ });
937
+
938
+ // Dialog should be open and show user and state info
939
+ const dialogText = baseElement.textContent || '';
940
+ expect(dialogText).toContain('Terminate Session?');
941
+ expect(dialogText).toContain('500');
942
+ // User is shown in the description
943
+ expect(dialogText).toContain('db_admin');
944
+ // State is shown in the description
945
+ expect(dialogText).toContain('idle in transaction');
946
+ // Also verify the warning about uncommitted transactions
947
+ expect(dialogText).toContain('uncommitted transactions');
948
+ });
949
+
950
+ // =========================================================================
951
+ // Error hidden when both error AND data present
952
+ // =========================================================================
953
+
954
+ test('error message is hidden when both error and data are present', async () => {
955
+ monitoringOverride = {
956
+ data: { activeSessions: defaultSessions, tables: defaultTables },
957
+ error: 'Intermittent connection error',
958
+ };
959
+ let renderResult: ReturnType<typeof render>;
960
+ await act(async () => {
961
+ renderResult = render(<OperationsTab />);
962
+ });
963
+ const { queryByText, container } = renderResult!;
964
+
965
+ // The error message should NOT be displayed because data is present
966
+ // (source code: `error && !data` — data is truthy, so error div is skipped)
967
+ expect(queryByText('Intermittent connection error')).toBeNull();
968
+
969
+ // But data should still render normally
970
+ expect(queryByText('Sessions (1)')).not.toBeNull();
971
+ expect(queryByText('Tables (1)')).not.toBeNull();
972
+ expect(container.textContent).toContain('users');
973
+ expect(container.textContent).toContain('1234');
974
+ });
975
+ });