Skip to content

Commit fc11d3b

Browse files
committed
fix(hig): use NSSplitView for Server Dashboard panels (#1464)
1 parent e7f4235 commit fc11d3b

6 files changed

Lines changed: 209 additions & 42 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4343
- The Generate Token sheet focuses the Token Name field on first open. (#1093)
4444
- Double-clicking a CSV or TSV file when TablePro is closed opens the file directly. (#1443)
4545
- Opening a `.sql` file names the tab after the file instead of showing "SQL Query". (#1220)
46+
- 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)
4647

4748
## [0.45.0] - 2026-05-26
4849

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import AppKit
2+
import SwiftUI
3+
4+
struct ServerDashboardSplitView: NSViewControllerRepresentable {
5+
@Bindable var viewModel: ServerDashboardViewModel
6+
7+
func makeCoordinator() -> Coordinator {
8+
Coordinator()
9+
}
10+
11+
func makeNSViewController(context: Context) -> NSSplitViewController {
12+
let splitViewController = NSSplitViewController()
13+
splitViewController.splitView.isVertical = false
14+
splitViewController.splitView.dividerStyle = .thin
15+
splitViewController.splitView.autosaveName = "ServerDashboardSplit"
16+
17+
let panels = orderedPanels()
18+
19+
for panel in panels {
20+
let item = makeItem(for: panel, coordinator: context.coordinator)
21+
splitViewController.addSplitViewItem(item)
22+
}
23+
24+
context.coordinator.installedPanels = panels
25+
return splitViewController
26+
}
27+
28+
func updateNSViewController(_ splitViewController: NSSplitViewController, context: Context) {
29+
context.coordinator.sessionsController?.rootView = SessionsTableView(viewModel: viewModel)
30+
context.coordinator.metricsController?.rootView = MetricsBarView(
31+
metrics: viewModel.metrics,
32+
error: viewModel.panelErrors[.serverMetrics]
33+
)
34+
context.coordinator.slowQueriesController?.rootView = SlowQueryListView(
35+
queries: viewModel.slowQueries,
36+
error: viewModel.panelErrors[.slowQueries]
37+
)
38+
}
39+
40+
private func orderedPanels() -> [DashboardPanel] {
41+
let supported = viewModel.supportedPanels
42+
let order: [DashboardPanel] = [.activeSessions, .serverMetrics, .slowQueries]
43+
return order.filter { supported.contains($0) }
44+
}
45+
46+
private func makeItem(for panel: DashboardPanel, coordinator: Coordinator) -> NSSplitViewItem {
47+
switch panel {
48+
case .activeSessions:
49+
let controller = NSHostingController(rootView: SessionsTableView(viewModel: viewModel))
50+
let item = NSSplitViewItem(viewController: controller)
51+
item.minimumThickness = 120
52+
item.holdingPriority = .defaultLow
53+
coordinator.sessionsController = controller
54+
return item
55+
56+
case .serverMetrics:
57+
let controller = NSHostingController(
58+
rootView: MetricsBarView(
59+
metrics: viewModel.metrics,
60+
error: viewModel.panelErrors[.serverMetrics]
61+
)
62+
)
63+
let item = NSSplitViewItem(viewController: controller)
64+
item.minimumThickness = 76
65+
item.maximumThickness = 200
66+
item.holdingPriority = NSLayoutConstraint.Priority(260)
67+
coordinator.metricsController = controller
68+
return item
69+
70+
case .slowQueries:
71+
let controller = NSHostingController(
72+
rootView: SlowQueryListView(
73+
queries: viewModel.slowQueries,
74+
error: viewModel.panelErrors[.slowQueries]
75+
)
76+
)
77+
let item = NSSplitViewItem(viewController: controller)
78+
item.minimumThickness = 100
79+
item.canCollapse = true
80+
item.holdingPriority = NSLayoutConstraint.Priority(255)
81+
coordinator.slowQueriesController = controller
82+
return item
83+
}
84+
}
85+
86+
final class Coordinator {
87+
var sessionsController: NSHostingController<SessionsTableView>?
88+
var metricsController: NSHostingController<MetricsBarView>?
89+
var slowQueriesController: NSHostingController<SlowQueryListView>?
90+
var installedPanels: [DashboardPanel] = []
91+
}
92+
}

TablePro/Views/ServerDashboard/ServerDashboardView.swift

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,27 +15,7 @@ struct ServerDashboardView: View {
1515
description: Text("Server monitoring is not available for this database type.")
1616
)
1717
} else {
18-
VStack(spacing: 0) {
19-
if viewModel.supportedPanels.contains(.activeSessions) {
20-
SessionsTableView(viewModel: viewModel)
21-
}
22-
23-
if viewModel.supportedPanels.contains(.serverMetrics) {
24-
Divider()
25-
MetricsBarView(
26-
metrics: viewModel.metrics,
27-
error: viewModel.panelErrors[.serverMetrics]
28-
)
29-
}
30-
31-
if viewModel.supportedPanels.contains(.slowQueries) {
32-
Divider()
33-
SlowQueryListView(
34-
queries: viewModel.slowQueries,
35-
error: viewModel.panelErrors[.slowQueries]
36-
)
37-
}
38-
}
18+
ServerDashboardSplitView(viewModel: viewModel)
3919
}
4020
}
4121
.frame(maxWidth: .infinity, maxHeight: .infinity)

TablePro/Views/ServerDashboard/SlowQueryListView.swift

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,43 +3,41 @@ import SwiftUI
33
struct SlowQueryListView: View {
44
let queries: [DashboardSlowQuery]
55
let error: String?
6-
@State private var isExpanded = true
76

87
var body: some View {
9-
DisclosureGroup(isExpanded: $isExpanded) {
8+
VStack(alignment: .leading, spacing: 0) {
9+
HStack {
10+
Label(String(localized: "Slow Queries"), systemImage: "tortoise")
11+
.font(.headline)
12+
Text("(\(queries.count))")
13+
.foregroundStyle(.secondary)
14+
Spacer()
15+
if let error {
16+
Label(error, systemImage: "exclamationmark.triangle.fill")
17+
.font(.caption)
18+
.foregroundStyle(.orange)
19+
}
20+
}
21+
.padding(.horizontal, 12)
22+
.padding(.vertical, 8)
23+
1024
if queries.isEmpty && error == nil {
1125
Text(String(localized: "No slow queries"))
1226
.foregroundStyle(.secondary)
1327
.font(.caption)
14-
.padding(.vertical, 4)
28+
.frame(maxWidth: .infinity, maxHeight: .infinity)
1529
} else {
1630
List {
1731
ForEach(queries) { query in
1832
slowQueryRow(query)
1933
.listRowSeparator(.hidden)
20-
.listRowInsets(EdgeInsets(top: 2, leading: 0, bottom: 2, trailing: 0))
34+
.listRowInsets(EdgeInsets(top: 2, leading: 12, bottom: 2, trailing: 12))
2135
}
2236
}
2337
.listStyle(.plain)
2438
.scrollContentBackground(.hidden)
25-
.frame(maxHeight: 200)
26-
}
27-
} label: {
28-
HStack {
29-
Label(String(localized: "Slow Queries"), systemImage: "tortoise")
30-
.font(.headline)
31-
Text("(\(queries.count))")
32-
.foregroundStyle(.secondary)
33-
Spacer()
34-
if let error {
35-
Label(error, systemImage: "exclamationmark.triangle.fill")
36-
.font(.caption)
37-
.foregroundStyle(.orange)
38-
}
3939
}
4040
}
41-
.padding(.horizontal, 12)
42-
.padding(.vertical, 8)
4341
}
4442

4543
private func slowQueryRow(_ query: DashboardSlowQuery) -> some View {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
//
2+
// ServerDashboardViewModelTests.swift
3+
// TableProTests
4+
//
5+
6+
import Foundation
7+
@testable import TablePro
8+
import TableProPluginKit
9+
import Testing
10+
11+
@MainActor
12+
struct ServerDashboardViewModelTests {
13+
private func makeViewModel(databaseType: DatabaseType) -> ServerDashboardViewModel {
14+
ServerDashboardViewModel(connectionId: UUID(), databaseType: databaseType, services: .live)
15+
}
16+
17+
@Test("MySQL dashboard exposes sessions, metrics, and slow queries")
18+
func mySQLSupportedPanels() {
19+
let vm = makeViewModel(databaseType: .mysql)
20+
#expect(vm.supportedPanels == [.activeSessions, .serverMetrics, .slowQueries])
21+
#expect(vm.isSupported)
22+
}
23+
24+
@Test("PostgreSQL dashboard exposes sessions, metrics, and slow queries")
25+
func postgresSupportedPanels() {
26+
let vm = makeViewModel(databaseType: .postgresql)
27+
#expect(vm.supportedPanels == [.activeSessions, .serverMetrics, .slowQueries])
28+
}
29+
30+
@Test("SQLite dashboard exposes only server metrics")
31+
func sqliteSupportedPanels() {
32+
let vm = makeViewModel(databaseType: .sqlite)
33+
#expect(vm.supportedPanels == [.serverMetrics])
34+
#expect(!vm.supportedPanels.contains(.slowQueries))
35+
}
36+
37+
@Test("DuckDB dashboard exposes only server metrics")
38+
func duckDBSupportedPanels() {
39+
let vm = makeViewModel(databaseType: .duckdb)
40+
#expect(vm.supportedPanels == [.serverMetrics])
41+
}
42+
43+
@Test("Redis returns no provider and an empty dashboard")
44+
func redisHasNoDashboard() {
45+
let vm = makeViewModel(databaseType: .redis)
46+
#expect(vm.supportedPanels.isEmpty)
47+
#expect(!vm.isSupported)
48+
}
49+
50+
@Test("MySQL supports both kill session and cancel query")
51+
func mySQLKillAndCancelCapabilities() {
52+
let vm = makeViewModel(databaseType: .mysql)
53+
#expect(vm.canKillSessions)
54+
#expect(vm.canCancelQueries)
55+
}
56+
57+
@Test("MSSQL supports kill session but not cancel query")
58+
func mssqlKillButNoCancel() {
59+
let vm = makeViewModel(databaseType: .mssql)
60+
#expect(vm.canKillSessions)
61+
#expect(!vm.canCancelQueries)
62+
}
63+
64+
@Test("ClickHouse supports neither kill nor cancel")
65+
func clickHouseHasNoActions() {
66+
let vm = makeViewModel(databaseType: .clickhouse)
67+
#expect(!vm.canKillSessions)
68+
#expect(!vm.canCancelQueries)
69+
}
70+
71+
@Test("confirmKillSession stores process id and shows confirmation")
72+
func confirmKillSessionUpdatesState() {
73+
let vm = makeViewModel(databaseType: .mysql)
74+
vm.confirmKillSession(processId: "42")
75+
#expect(vm.pendingKillProcessId == "42")
76+
#expect(vm.showKillConfirmation)
77+
}
78+
79+
@Test("confirmCancelQuery stores process id and shows confirmation")
80+
func confirmCancelQueryUpdatesState() {
81+
let vm = makeViewModel(databaseType: .mysql)
82+
vm.confirmCancelQuery(processId: "99")
83+
#expect(vm.pendingCancelProcessId == "99")
84+
#expect(vm.showCancelConfirmation)
85+
}
86+
87+
@Test("stopAutoRefresh clears the refreshing flag")
88+
func stopAutoRefreshClearsRefreshingFlag() {
89+
let vm = makeViewModel(databaseType: .mysql)
90+
vm.isRefreshing = true
91+
vm.stopAutoRefresh()
92+
#expect(!vm.isRefreshing)
93+
}
94+
}

docs/features/server-dashboard.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,9 @@ A horizontal strip of key metrics displayed as cards:
5757

5858
## Slow Queries
5959

60-
A collapsible list of queries running longer than 1 second, sorted by duration. Each entry shows the elapsed time, SQL text, user, and database.
60+
A list of queries running longer than 1 second, sorted by duration. Each entry shows the elapsed time, SQL text, user, and database.
61+
62+
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.
6163

6264
## Auto-refresh
6365

0 commit comments

Comments
 (0)