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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Server Dashboard with active sessions, server metrics, and slow query monitoring (PostgreSQL, MySQL, MSSQL, ClickHouse, DuckDB, SQLite)

## [0.30.1] - 2026-04-10

### Added
Expand Down
4 changes: 3 additions & 1 deletion TablePro/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ struct ContentView: View {
init(payload: EditorTabPayload?) {
self.payload = payload
let defaultTitle: String
if let tableName = payload?.tableName {
if payload?.tabType == .serverDashboard {
defaultTitle = String(localized: "Server Dashboard")
} else if let tableName = payload?.tableName {
defaultTitle = tableName
} else if let connectionId = payload?.connectionId,
let connection = DatabaseManager.shared.activeSessions[connectionId]?.connection {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
//
// ClickHouseDashboardProvider.swift
// TablePro
//

import Foundation

struct ClickHouseDashboardProvider: ServerDashboardQueryProvider {
let supportedPanels: Set<DashboardPanel> = [.activeSessions, .serverMetrics, .slowQueries]

func fetchSessions(execute: (String) async throws -> QueryResult) async throws -> [DashboardSession] {
let sql = """
SELECT query_id, user, current_database, elapsed, read_rows,
memory_usage, left(query, 1000) AS query
FROM system.processes
ORDER BY elapsed DESC
"""
let result = try await execute(sql)
let col = columnIndex(from: result.columns)
return result.rows.map { row in
let elapsed = Double(value(row, at: col["elapsed"])) ?? 0
let readRows = value(row, at: col["read_rows"])
let memUsage = value(row, at: col["memory_usage"])
let stateDescription = "rows: \(readRows), mem: \(formatBytes(memUsage))"
let secs = Int(elapsed)
return DashboardSession(
id: value(row, at: col["query_id"]),
user: value(row, at: col["user"]),
database: value(row, at: col["current_database"]),
state: stateDescription,
durationSeconds: secs,
duration: formatDuration(seconds: secs),
query: value(row, at: col["query"]),
canCancel: false
)
}
}

func fetchMetrics(execute: (String) async throws -> QueryResult) async throws -> [DashboardMetric] {
var metrics: [DashboardMetric] = []

let metricsResult = try await execute("""
SELECT metric, value FROM system.metrics
WHERE metric IN ('Query', 'Merge', 'PartMutation')
""")
let col = columnIndex(from: metricsResult.columns)
for row in metricsResult.rows {
let metric = value(row, at: col["metric"])
let val = value(row, at: col["value"])
let (label, icon) = metricDisplay(for: metric)
metrics.append(DashboardMetric(
id: metric.lowercased(),
label: label,
value: val,
unit: "",
icon: icon
))
}

let diskResult = try await execute("""
SELECT formatReadableSize(sum(bytes_on_disk)) AS disk_usage
FROM system.parts WHERE active
""")
if let row = diskResult.rows.first {
metrics.append(DashboardMetric(
id: "disk_usage",
label: String(localized: "Disk Usage"),
value: value(row, at: 0),
unit: "",
icon: "internaldrive"
))
}

return metrics
}

func fetchSlowQueries(execute: (String) async throws -> QueryResult) async throws -> [DashboardSlowQuery] {
let sql = """
SELECT user, query_duration_ms / 1000 AS duration_secs,
left(query, 1000) AS query
FROM system.query_log
WHERE type = 'QueryFinish' AND query_duration_ms > 1000
ORDER BY event_time DESC
LIMIT 20
"""
let result = try await execute(sql)
let col = columnIndex(from: result.columns)
return result.rows.map { row in
let secs = Int(value(row, at: col["duration_secs"])) ?? 0
return DashboardSlowQuery(
duration: formatDuration(seconds: secs),
query: value(row, at: col["query"]),
user: value(row, at: col["user"]),
database: ""
)
}
}

func killSessionSQL(processId: String) -> String? {
let uuidPattern = #"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"#
guard processId.range(of: uuidPattern, options: [.regularExpression, .caseInsensitive]) != nil else {
return nil
}
return "KILL QUERY WHERE query_id = '\(processId)'"
}
}

// MARK: - Helpers

private extension ClickHouseDashboardProvider {
func columnIndex(from columns: [String]) -> [String: Int] {
var map: [String: Int] = [:]
for (index, name) in columns.enumerated() {
map[name.lowercased()] = index
}
return map
}

func value(_ row: [String?], at index: Int?) -> String {
guard let index, index < row.count else { return "" }
return row[index] ?? ""
}

func formatDuration(seconds: Int) -> String {
if seconds >= 3_600 {
return "\(seconds / 3_600)h \((seconds % 3_600) / 60)m"
} else if seconds >= 60 {
return "\(seconds / 60)m \(seconds % 60)s"
}
return "\(seconds)s"
}

func formatBytes(_ string: String) -> String {
guard let bytes = Double(string) else { return string }
if bytes >= 1_073_741_824 {
return String(format: "%.1f GB", bytes / 1_073_741_824)
} else if bytes >= 1_048_576 {
return String(format: "%.1f MB", bytes / 1_048_576)
} else if bytes >= 1_024 {
return String(format: "%.1f KB", bytes / 1_024)
}
return "\(Int(bytes)) B"
}

func metricDisplay(for metric: String) -> (String, String) {
switch metric {
case "Query":
return (String(localized: "Active Queries"), "bolt.horizontal")
case "Merge":
return (String(localized: "Active Merges"), "arrow.triangle.merge")
case "PartMutation":
return (String(localized: "Part Mutations"), "gearshape.2")
default:
return (metric, "chart.bar")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
//
// DuckDBDashboardProvider.swift
// TablePro
//

import Foundation

struct DuckDBDashboardProvider: ServerDashboardQueryProvider {
let supportedPanels: Set<DashboardPanel> = [.serverMetrics]

func fetchMetrics(execute: (String) async throws -> QueryResult) async throws -> [DashboardMetric] {
var metrics: [DashboardMetric] = []

let sizeResult = try await execute("SELECT * FROM pragma_database_size()")
if let row = sizeResult.rows.first {
let col = columnIndex(from: sizeResult.columns)
let dbSize = value(row, at: col["database_size"])
let blockSize = value(row, at: col["block_size"])
let totalBlocks = value(row, at: col["total_blocks"])

if !dbSize.isEmpty {
metrics.append(DashboardMetric(
id: "db_size",
label: String(localized: "Database Size"),
value: dbSize,
unit: "",
icon: "internaldrive"
))
}
if !blockSize.isEmpty {
metrics.append(DashboardMetric(
id: "block_size",
label: String(localized: "Block Size"),
value: blockSize,
unit: "",
icon: "square.grid.3x3"
))
}
if !totalBlocks.isEmpty {
metrics.append(DashboardMetric(
id: "total_blocks",
label: String(localized: "Total Blocks"),
value: totalBlocks,
unit: "",
icon: "cube"
))
}
}

let settingsResult = try await execute("""
SELECT current_setting('memory_limit') AS memory_limit,
current_setting('threads') AS threads
""")
if let row = settingsResult.rows.first {
let col = columnIndex(from: settingsResult.columns)
let memLimit = value(row, at: col["memory_limit"])
let threads = value(row, at: col["threads"])

if !memLimit.isEmpty {
metrics.append(DashboardMetric(
id: "memory_limit",
label: String(localized: "Memory Limit"),
value: memLimit,
unit: "",
icon: "memorychip"
))
}
if !threads.isEmpty {
metrics.append(DashboardMetric(
id: "threads",
label: String(localized: "Threads"),
value: threads,
unit: "",
icon: "cpu"
))
}
}

return metrics
}
}

// MARK: - Helpers

private extension DuckDBDashboardProvider {
func columnIndex(from columns: [String]) -> [String: Int] {
var map: [String: Int] = [:]
for (index, name) in columns.enumerated() {
map[name.lowercased()] = index
}
return map
}

func value(_ row: [String?], at index: Int?) -> String {
guard let index, index < row.count else { return "" }
return row[index] ?? ""
}
}
Loading
Loading