Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
88 changes: 88 additions & 0 deletions TablePro/Views/ServerDashboard/ServerDashboardSplitView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import AppKit
import SwiftUI

struct ServerDashboardSplitView: NSViewControllerRepresentable {
let 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"

for panel in orderedPanels() {
let item = makeItem(for: panel, coordinator: context.coordinator)
splitViewController.addSplitViewItem(item)
}

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 = .defaultHigh
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 = .defaultHigh
coordinator.slowQueriesController = controller
return item
}
}

final class Coordinator {
var sessionsController: NSHostingController<SessionsTableView>?
var metricsController: NSHostingController<MetricsBarView>?
var slowQueriesController: NSHostingController<SlowQueryListView>?
}
}
22 changes: 1 addition & 21 deletions TablePro/Views/ServerDashboard/ServerDashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 18 additions & 20 deletions TablePro/Views/ServerDashboard/SlowQueryListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
94 changes: 94 additions & 0 deletions TableProTests/ViewModels/ServerDashboardViewModelTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 3 additions & 1 deletion docs/features/server-dashboard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading