Skip to content

Commit a7e0be6

Browse files
authored
feat: add Server Dashboard & Activity Monitor (#658)
* feat: add Server Dashboard with active sessions, metrics, and slow query monitoring * fix: address review feedback for server dashboard * fix: show "Server Dashboard" in toolbar title instead of "SQL Query" * fix: filter out PostgreSQL background workers from dashboard sessions * fix: address 11 review issues in server dashboard * fix: skip dashboard refresh silently when connection not ready yet * docs: add server dashboard page to navigation * docs: add screenshot placeholders to server dashboard page * wip
1 parent 63bad40 commit a7e0be6

30 files changed

Lines changed: 1750 additions & 7 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Server Dashboard with active sessions, server metrics, and slow query monitoring (PostgreSQL, MySQL, MSSQL, ClickHouse, DuckDB, SQLite)
13+
1014
## [0.30.1] - 2026-04-10
1115

1216
### Added

TablePro/ContentView.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ struct ContentView: View {
3737
init(payload: EditorTabPayload?) {
3838
self.payload = payload
3939
let defaultTitle: String
40-
if let tableName = payload?.tableName {
40+
if payload?.tabType == .serverDashboard {
41+
defaultTitle = String(localized: "Server Dashboard")
42+
} else if let tableName = payload?.tableName {
4143
defaultTitle = tableName
4244
} else if let connectionId = payload?.connectionId,
4345
let connection = DatabaseManager.shared.activeSessions[connectionId]?.connection {
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
//
2+
// ClickHouseDashboardProvider.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
struct ClickHouseDashboardProvider: ServerDashboardQueryProvider {
9+
let supportedPanels: Set<DashboardPanel> = [.activeSessions, .serverMetrics, .slowQueries]
10+
11+
func fetchSessions(execute: (String) async throws -> QueryResult) async throws -> [DashboardSession] {
12+
let sql = """
13+
SELECT query_id, user, current_database, elapsed, read_rows,
14+
memory_usage, left(query, 1000) AS query
15+
FROM system.processes
16+
ORDER BY elapsed DESC
17+
"""
18+
let result = try await execute(sql)
19+
let col = columnIndex(from: result.columns)
20+
return result.rows.map { row in
21+
let elapsed = Double(value(row, at: col["elapsed"])) ?? 0
22+
let readRows = value(row, at: col["read_rows"])
23+
let memUsage = value(row, at: col["memory_usage"])
24+
let stateDescription = "rows: \(readRows), mem: \(formatBytes(memUsage))"
25+
let secs = Int(elapsed)
26+
return DashboardSession(
27+
id: value(row, at: col["query_id"]),
28+
user: value(row, at: col["user"]),
29+
database: value(row, at: col["current_database"]),
30+
state: stateDescription,
31+
durationSeconds: secs,
32+
duration: formatDuration(seconds: secs),
33+
query: value(row, at: col["query"]),
34+
canCancel: false
35+
)
36+
}
37+
}
38+
39+
func fetchMetrics(execute: (String) async throws -> QueryResult) async throws -> [DashboardMetric] {
40+
var metrics: [DashboardMetric] = []
41+
42+
let metricsResult = try await execute("""
43+
SELECT metric, value FROM system.metrics
44+
WHERE metric IN ('Query', 'Merge', 'PartMutation')
45+
""")
46+
let col = columnIndex(from: metricsResult.columns)
47+
for row in metricsResult.rows {
48+
let metric = value(row, at: col["metric"])
49+
let val = value(row, at: col["value"])
50+
let (label, icon) = metricDisplay(for: metric)
51+
metrics.append(DashboardMetric(
52+
id: metric.lowercased(),
53+
label: label,
54+
value: val,
55+
unit: "",
56+
icon: icon
57+
))
58+
}
59+
60+
let diskResult = try await execute("""
61+
SELECT formatReadableSize(sum(bytes_on_disk)) AS disk_usage
62+
FROM system.parts WHERE active
63+
""")
64+
if let row = diskResult.rows.first {
65+
metrics.append(DashboardMetric(
66+
id: "disk_usage",
67+
label: String(localized: "Disk Usage"),
68+
value: value(row, at: 0),
69+
unit: "",
70+
icon: "internaldrive"
71+
))
72+
}
73+
74+
return metrics
75+
}
76+
77+
func fetchSlowQueries(execute: (String) async throws -> QueryResult) async throws -> [DashboardSlowQuery] {
78+
let sql = """
79+
SELECT user, query_duration_ms / 1000 AS duration_secs,
80+
left(query, 1000) AS query
81+
FROM system.query_log
82+
WHERE type = 'QueryFinish' AND query_duration_ms > 1000
83+
ORDER BY event_time DESC
84+
LIMIT 20
85+
"""
86+
let result = try await execute(sql)
87+
let col = columnIndex(from: result.columns)
88+
return result.rows.map { row in
89+
let secs = Int(value(row, at: col["duration_secs"])) ?? 0
90+
return DashboardSlowQuery(
91+
duration: formatDuration(seconds: secs),
92+
query: value(row, at: col["query"]),
93+
user: value(row, at: col["user"]),
94+
database: ""
95+
)
96+
}
97+
}
98+
99+
func killSessionSQL(processId: String) -> String? {
100+
let uuidPattern = #"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"#
101+
guard processId.range(of: uuidPattern, options: [.regularExpression, .caseInsensitive]) != nil else {
102+
return nil
103+
}
104+
return "KILL QUERY WHERE query_id = '\(processId)'"
105+
}
106+
}
107+
108+
// MARK: - Helpers
109+
110+
private extension ClickHouseDashboardProvider {
111+
func columnIndex(from columns: [String]) -> [String: Int] {
112+
var map: [String: Int] = [:]
113+
for (index, name) in columns.enumerated() {
114+
map[name.lowercased()] = index
115+
}
116+
return map
117+
}
118+
119+
func value(_ row: [String?], at index: Int?) -> String {
120+
guard let index, index < row.count else { return "" }
121+
return row[index] ?? ""
122+
}
123+
124+
func formatDuration(seconds: Int) -> String {
125+
if seconds >= 3_600 {
126+
return "\(seconds / 3_600)h \((seconds % 3_600) / 60)m"
127+
} else if seconds >= 60 {
128+
return "\(seconds / 60)m \(seconds % 60)s"
129+
}
130+
return "\(seconds)s"
131+
}
132+
133+
func formatBytes(_ string: String) -> String {
134+
guard let bytes = Double(string) else { return string }
135+
if bytes >= 1_073_741_824 {
136+
return String(format: "%.1f GB", bytes / 1_073_741_824)
137+
} else if bytes >= 1_048_576 {
138+
return String(format: "%.1f MB", bytes / 1_048_576)
139+
} else if bytes >= 1_024 {
140+
return String(format: "%.1f KB", bytes / 1_024)
141+
}
142+
return "\(Int(bytes)) B"
143+
}
144+
145+
func metricDisplay(for metric: String) -> (String, String) {
146+
switch metric {
147+
case "Query":
148+
return (String(localized: "Active Queries"), "bolt.horizontal")
149+
case "Merge":
150+
return (String(localized: "Active Merges"), "arrow.triangle.merge")
151+
case "PartMutation":
152+
return (String(localized: "Part Mutations"), "gearshape.2")
153+
default:
154+
return (metric, "chart.bar")
155+
}
156+
}
157+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
//
2+
// DuckDBDashboardProvider.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
struct DuckDBDashboardProvider: ServerDashboardQueryProvider {
9+
let supportedPanels: Set<DashboardPanel> = [.serverMetrics]
10+
11+
func fetchMetrics(execute: (String) async throws -> QueryResult) async throws -> [DashboardMetric] {
12+
var metrics: [DashboardMetric] = []
13+
14+
let sizeResult = try await execute("SELECT * FROM pragma_database_size()")
15+
if let row = sizeResult.rows.first {
16+
let col = columnIndex(from: sizeResult.columns)
17+
let dbSize = value(row, at: col["database_size"])
18+
let blockSize = value(row, at: col["block_size"])
19+
let totalBlocks = value(row, at: col["total_blocks"])
20+
21+
if !dbSize.isEmpty {
22+
metrics.append(DashboardMetric(
23+
id: "db_size",
24+
label: String(localized: "Database Size"),
25+
value: dbSize,
26+
unit: "",
27+
icon: "internaldrive"
28+
))
29+
}
30+
if !blockSize.isEmpty {
31+
metrics.append(DashboardMetric(
32+
id: "block_size",
33+
label: String(localized: "Block Size"),
34+
value: blockSize,
35+
unit: "",
36+
icon: "square.grid.3x3"
37+
))
38+
}
39+
if !totalBlocks.isEmpty {
40+
metrics.append(DashboardMetric(
41+
id: "total_blocks",
42+
label: String(localized: "Total Blocks"),
43+
value: totalBlocks,
44+
unit: "",
45+
icon: "cube"
46+
))
47+
}
48+
}
49+
50+
let settingsResult = try await execute("""
51+
SELECT current_setting('memory_limit') AS memory_limit,
52+
current_setting('threads') AS threads
53+
""")
54+
if let row = settingsResult.rows.first {
55+
let col = columnIndex(from: settingsResult.columns)
56+
let memLimit = value(row, at: col["memory_limit"])
57+
let threads = value(row, at: col["threads"])
58+
59+
if !memLimit.isEmpty {
60+
metrics.append(DashboardMetric(
61+
id: "memory_limit",
62+
label: String(localized: "Memory Limit"),
63+
value: memLimit,
64+
unit: "",
65+
icon: "memorychip"
66+
))
67+
}
68+
if !threads.isEmpty {
69+
metrics.append(DashboardMetric(
70+
id: "threads",
71+
label: String(localized: "Threads"),
72+
value: threads,
73+
unit: "",
74+
icon: "cpu"
75+
))
76+
}
77+
}
78+
79+
return metrics
80+
}
81+
}
82+
83+
// MARK: - Helpers
84+
85+
private extension DuckDBDashboardProvider {
86+
func columnIndex(from columns: [String]) -> [String: Int] {
87+
var map: [String: Int] = [:]
88+
for (index, name) in columns.enumerated() {
89+
map[name.lowercased()] = index
90+
}
91+
return map
92+
}
93+
94+
func value(_ row: [String?], at index: Int?) -> String {
95+
guard let index, index < row.count else { return "" }
96+
return row[index] ?? ""
97+
}
98+
}

0 commit comments

Comments
 (0)