From fc11d3b9fda9783173a82ead46bd7f13da7811e7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 28 May 2026 21:12:46 +0700 Subject: [PATCH 1/2] fix(hig): use NSSplitView for Server Dashboard panels (#1464) --- CHANGELOG.md | 1 + .../ServerDashboardSplitView.swift | 92 ++++++++++++++++++ .../ServerDashboard/ServerDashboardView.swift | 22 +---- .../ServerDashboard/SlowQueryListView.swift | 38 ++++---- .../ServerDashboardViewModelTests.swift | 94 +++++++++++++++++++ docs/features/server-dashboard.mdx | 4 +- 6 files changed, 209 insertions(+), 42 deletions(-) create mode 100644 TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift create mode 100644 TableProTests/ViewModels/ServerDashboardViewModelTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b66a87740..638c47931 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The Generate Token sheet focuses the Token Name field on first open. (#1093) - Double-clicking a CSV or TSV file when TablePro is closed opens the file directly. (#1443) - Opening a `.sql` file names the tab after the file instead of showing "SQL Query". (#1220) +- Server Dashboard now shows the Slow Queries panel. The panels are in a vertical split with draggable dividers, and divider positions are remembered across launches. (#1464) ## [0.45.0] - 2026-05-26 diff --git a/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift b/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift new file mode 100644 index 000000000..10dbe8c73 --- /dev/null +++ b/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift @@ -0,0 +1,92 @@ +import AppKit +import SwiftUI + +struct ServerDashboardSplitView: NSViewControllerRepresentable { + @Bindable var viewModel: ServerDashboardViewModel + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSViewController(context: Context) -> NSSplitViewController { + let splitViewController = NSSplitViewController() + splitViewController.splitView.isVertical = false + splitViewController.splitView.dividerStyle = .thin + splitViewController.splitView.autosaveName = "ServerDashboardSplit" + + let panels = orderedPanels() + + for panel in panels { + let item = makeItem(for: panel, coordinator: context.coordinator) + splitViewController.addSplitViewItem(item) + } + + context.coordinator.installedPanels = panels + return splitViewController + } + + func updateNSViewController(_ splitViewController: NSSplitViewController, context: Context) { + context.coordinator.sessionsController?.rootView = SessionsTableView(viewModel: viewModel) + context.coordinator.metricsController?.rootView = MetricsBarView( + metrics: viewModel.metrics, + error: viewModel.panelErrors[.serverMetrics] + ) + context.coordinator.slowQueriesController?.rootView = SlowQueryListView( + queries: viewModel.slowQueries, + error: viewModel.panelErrors[.slowQueries] + ) + } + + private func orderedPanels() -> [DashboardPanel] { + let supported = viewModel.supportedPanels + let order: [DashboardPanel] = [.activeSessions, .serverMetrics, .slowQueries] + return order.filter { supported.contains($0) } + } + + private func makeItem(for panel: DashboardPanel, coordinator: Coordinator) -> NSSplitViewItem { + switch panel { + case .activeSessions: + let controller = NSHostingController(rootView: SessionsTableView(viewModel: viewModel)) + let item = NSSplitViewItem(viewController: controller) + item.minimumThickness = 120 + item.holdingPriority = .defaultLow + coordinator.sessionsController = controller + return item + + case .serverMetrics: + let controller = NSHostingController( + rootView: MetricsBarView( + metrics: viewModel.metrics, + error: viewModel.panelErrors[.serverMetrics] + ) + ) + let item = NSSplitViewItem(viewController: controller) + item.minimumThickness = 76 + item.maximumThickness = 200 + item.holdingPriority = NSLayoutConstraint.Priority(260) + coordinator.metricsController = controller + return item + + case .slowQueries: + let controller = NSHostingController( + rootView: SlowQueryListView( + queries: viewModel.slowQueries, + error: viewModel.panelErrors[.slowQueries] + ) + ) + let item = NSSplitViewItem(viewController: controller) + item.minimumThickness = 100 + item.canCollapse = true + item.holdingPriority = NSLayoutConstraint.Priority(255) + coordinator.slowQueriesController = controller + return item + } + } + + final class Coordinator { + var sessionsController: NSHostingController? + var metricsController: NSHostingController? + var slowQueriesController: NSHostingController? + var installedPanels: [DashboardPanel] = [] + } +} diff --git a/TablePro/Views/ServerDashboard/ServerDashboardView.swift b/TablePro/Views/ServerDashboard/ServerDashboardView.swift index 183f2c5e0..fb4b9596a 100644 --- a/TablePro/Views/ServerDashboard/ServerDashboardView.swift +++ b/TablePro/Views/ServerDashboard/ServerDashboardView.swift @@ -15,27 +15,7 @@ struct ServerDashboardView: View { description: Text("Server monitoring is not available for this database type.") ) } else { - VStack(spacing: 0) { - if viewModel.supportedPanels.contains(.activeSessions) { - SessionsTableView(viewModel: viewModel) - } - - if viewModel.supportedPanels.contains(.serverMetrics) { - Divider() - MetricsBarView( - metrics: viewModel.metrics, - error: viewModel.panelErrors[.serverMetrics] - ) - } - - if viewModel.supportedPanels.contains(.slowQueries) { - Divider() - SlowQueryListView( - queries: viewModel.slowQueries, - error: viewModel.panelErrors[.slowQueries] - ) - } - } + ServerDashboardSplitView(viewModel: viewModel) } } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/TablePro/Views/ServerDashboard/SlowQueryListView.swift b/TablePro/Views/ServerDashboard/SlowQueryListView.swift index d7e6bebcc..4d3cc6278 100644 --- a/TablePro/Views/ServerDashboard/SlowQueryListView.swift +++ b/TablePro/Views/ServerDashboard/SlowQueryListView.swift @@ -3,43 +3,41 @@ import SwiftUI struct SlowQueryListView: View { let queries: [DashboardSlowQuery] let error: String? - @State private var isExpanded = true var body: some View { - DisclosureGroup(isExpanded: $isExpanded) { + VStack(alignment: .leading, spacing: 0) { + HStack { + Label(String(localized: "Slow Queries"), systemImage: "tortoise") + .font(.headline) + Text("(\(queries.count))") + .foregroundStyle(.secondary) + Spacer() + if let error { + Label(error, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + if queries.isEmpty && error == nil { Text(String(localized: "No slow queries")) .foregroundStyle(.secondary) .font(.caption) - .padding(.vertical, 4) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { List { ForEach(queries) { query in slowQueryRow(query) .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0)) + .listRowInsets(EdgeInsets(top: 2, leading: 12, bottom: 2, trailing: 12)) } } .listStyle(.plain) .scrollContentBackground(.hidden) - .frame(maxHeight: 200) - } - } label: { - HStack { - Label(String(localized: "Slow Queries"), systemImage: "tortoise") - .font(.headline) - Text("(\(queries.count))") - .foregroundStyle(.secondary) - Spacer() - if let error { - Label(error, systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(.orange) - } } } - .padding(.horizontal, 12) - .padding(.vertical, 8) } private func slowQueryRow(_ query: DashboardSlowQuery) -> some View { diff --git a/TableProTests/ViewModels/ServerDashboardViewModelTests.swift b/TableProTests/ViewModels/ServerDashboardViewModelTests.swift new file mode 100644 index 000000000..111904a8e --- /dev/null +++ b/TableProTests/ViewModels/ServerDashboardViewModelTests.swift @@ -0,0 +1,94 @@ +// +// ServerDashboardViewModelTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct ServerDashboardViewModelTests { + private func makeViewModel(databaseType: DatabaseType) -> ServerDashboardViewModel { + ServerDashboardViewModel(connectionId: UUID(), databaseType: databaseType, services: .live) + } + + @Test("MySQL dashboard exposes sessions, metrics, and slow queries") + func mySQLSupportedPanels() { + let vm = makeViewModel(databaseType: .mysql) + #expect(vm.supportedPanels == [.activeSessions, .serverMetrics, .slowQueries]) + #expect(vm.isSupported) + } + + @Test("PostgreSQL dashboard exposes sessions, metrics, and slow queries") + func postgresSupportedPanels() { + let vm = makeViewModel(databaseType: .postgresql) + #expect(vm.supportedPanels == [.activeSessions, .serverMetrics, .slowQueries]) + } + + @Test("SQLite dashboard exposes only server metrics") + func sqliteSupportedPanels() { + let vm = makeViewModel(databaseType: .sqlite) + #expect(vm.supportedPanels == [.serverMetrics]) + #expect(!vm.supportedPanels.contains(.slowQueries)) + } + + @Test("DuckDB dashboard exposes only server metrics") + func duckDBSupportedPanels() { + let vm = makeViewModel(databaseType: .duckdb) + #expect(vm.supportedPanels == [.serverMetrics]) + } + + @Test("Redis returns no provider and an empty dashboard") + func redisHasNoDashboard() { + let vm = makeViewModel(databaseType: .redis) + #expect(vm.supportedPanels.isEmpty) + #expect(!vm.isSupported) + } + + @Test("MySQL supports both kill session and cancel query") + func mySQLKillAndCancelCapabilities() { + let vm = makeViewModel(databaseType: .mysql) + #expect(vm.canKillSessions) + #expect(vm.canCancelQueries) + } + + @Test("MSSQL supports kill session but not cancel query") + func mssqlKillButNoCancel() { + let vm = makeViewModel(databaseType: .mssql) + #expect(vm.canKillSessions) + #expect(!vm.canCancelQueries) + } + + @Test("ClickHouse supports neither kill nor cancel") + func clickHouseHasNoActions() { + let vm = makeViewModel(databaseType: .clickhouse) + #expect(!vm.canKillSessions) + #expect(!vm.canCancelQueries) + } + + @Test("confirmKillSession stores process id and shows confirmation") + func confirmKillSessionUpdatesState() { + let vm = makeViewModel(databaseType: .mysql) + vm.confirmKillSession(processId: "42") + #expect(vm.pendingKillProcessId == "42") + #expect(vm.showKillConfirmation) + } + + @Test("confirmCancelQuery stores process id and shows confirmation") + func confirmCancelQueryUpdatesState() { + let vm = makeViewModel(databaseType: .mysql) + vm.confirmCancelQuery(processId: "99") + #expect(vm.pendingCancelProcessId == "99") + #expect(vm.showCancelConfirmation) + } + + @Test("stopAutoRefresh clears the refreshing flag") + func stopAutoRefreshClearsRefreshingFlag() { + let vm = makeViewModel(databaseType: .mysql) + vm.isRefreshing = true + vm.stopAutoRefresh() + #expect(!vm.isRefreshing) + } +} diff --git a/docs/features/server-dashboard.mdx b/docs/features/server-dashboard.mdx index c0b0e5a08..525bfd0b4 100644 --- a/docs/features/server-dashboard.mdx +++ b/docs/features/server-dashboard.mdx @@ -57,7 +57,9 @@ A horizontal strip of key metrics displayed as cards: ## Slow Queries -A collapsible list of queries running longer than 1 second, sorted by duration. Each entry shows the elapsed time, SQL text, user, and database. +A list of queries running longer than 1 second, sorted by duration. Each entry shows the elapsed time, SQL text, user, and database. + +The dashboard panels sit in a vertical split. Drag the dividers between Active Sessions, Server Metrics, and Slow Queries to resize each section; positions are remembered across launches. ## Auto-refresh From 87e9075bc23ff9105a8b72bab1291ddb3ca5bd00 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 28 May 2026 21:16:04 +0700 Subject: [PATCH 2/2] refactor(hig): clean up server dashboard split-view wrapper per review --- .../ServerDashboard/ServerDashboardSplitView.swift | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift b/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift index 10dbe8c73..5e1a747a3 100644 --- a/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift +++ b/TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift @@ -2,7 +2,7 @@ import AppKit import SwiftUI struct ServerDashboardSplitView: NSViewControllerRepresentable { - @Bindable var viewModel: ServerDashboardViewModel + let viewModel: ServerDashboardViewModel func makeCoordinator() -> Coordinator { Coordinator() @@ -14,14 +14,11 @@ struct ServerDashboardSplitView: NSViewControllerRepresentable { splitViewController.splitView.dividerStyle = .thin splitViewController.splitView.autosaveName = "ServerDashboardSplit" - let panels = orderedPanels() - - for panel in panels { + for panel in orderedPanels() { let item = makeItem(for: panel, coordinator: context.coordinator) splitViewController.addSplitViewItem(item) } - context.coordinator.installedPanels = panels return splitViewController } @@ -63,7 +60,7 @@ struct ServerDashboardSplitView: NSViewControllerRepresentable { let item = NSSplitViewItem(viewController: controller) item.minimumThickness = 76 item.maximumThickness = 200 - item.holdingPriority = NSLayoutConstraint.Priority(260) + item.holdingPriority = .defaultHigh coordinator.metricsController = controller return item @@ -77,7 +74,7 @@ struct ServerDashboardSplitView: NSViewControllerRepresentable { let item = NSSplitViewItem(viewController: controller) item.minimumThickness = 100 item.canCollapse = true - item.holdingPriority = NSLayoutConstraint.Priority(255) + item.holdingPriority = .defaultHigh coordinator.slowQueriesController = controller return item } @@ -87,6 +84,5 @@ struct ServerDashboardSplitView: NSViewControllerRepresentable { var sessionsController: NSHostingController? var metricsController: NSHostingController? var slowQueriesController: NSHostingController? - var installedPanels: [DashboardPanel] = [] } }