From f4fa16eae11c41f09690b43375a86eba05387890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 10 Apr 2026 11:53:20 +0700 Subject: [PATCH 1/8] feat: add Server Dashboard with active sessions, metrics, and slow query monitoring --- CHANGELOG.md | 4 + .../ClickHouseDashboardProvider.swift | 152 +++++++++++++ .../Providers/DuckDBDashboardProvider.swift | 98 ++++++++ .../Providers/MSSQLDashboardProvider.swift | 140 ++++++++++++ .../Providers/MySQLDashboardProvider.swift | 194 ++++++++++++++++ .../PostgreSQLDashboardProvider.swift | 166 ++++++++++++++ .../Providers/SQLiteDashboardProvider.swift | 90 ++++++++ .../ServerDashboardQueryProvider.swift | 24 ++ .../ServerDashboardQueryProviderFactory.swift | 27 +++ .../Infrastructure/SessionStateFactory.swift | 2 + TablePro/Models/Query/QueryTabManager.swift | 13 ++ TablePro/Models/Query/QueryTabState.swift | 9 +- .../ServerDashboardModels.swift | 67 ++++++ TablePro/TableProApp.swift | 5 + .../ViewModels/ServerDashboardViewModel.swift | 215 ++++++++++++++++++ .../Main/Child/MainEditorContentView.swift | 30 ++- ...inContentCoordinator+ServerDashboard.swift | 7 + .../Main/MainContentCommandActions.swift | 9 + .../DashboardToolbarView.swift | 71 ++++++ .../ServerDashboard/MetricsBarView.swift | 66 ++++++ .../ServerDashboard/ServerDashboardView.swift | 61 +++++ .../ServerDashboard/SessionsTableView.swift | 91 ++++++++ .../ServerDashboard/SlowQueryListView.swift | 84 +++++++ .../Views/Toolbar/TableProToolbarView.swift | 8 + 24 files changed, 1628 insertions(+), 5 deletions(-) create mode 100644 TablePro/Core/ServerDashboard/Providers/ClickHouseDashboardProvider.swift create mode 100644 TablePro/Core/ServerDashboard/Providers/DuckDBDashboardProvider.swift create mode 100644 TablePro/Core/ServerDashboard/Providers/MSSQLDashboardProvider.swift create mode 100644 TablePro/Core/ServerDashboard/Providers/MySQLDashboardProvider.swift create mode 100644 TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift create mode 100644 TablePro/Core/ServerDashboard/Providers/SQLiteDashboardProvider.swift create mode 100644 TablePro/Core/ServerDashboard/ServerDashboardQueryProvider.swift create mode 100644 TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift create mode 100644 TablePro/Models/ServerDashboard/ServerDashboardModels.swift create mode 100644 TablePro/ViewModels/ServerDashboardViewModel.swift create mode 100644 TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift create mode 100644 TablePro/Views/ServerDashboard/DashboardToolbarView.swift create mode 100644 TablePro/Views/ServerDashboard/MetricsBarView.swift create mode 100644 TablePro/Views/ServerDashboard/ServerDashboardView.swift create mode 100644 TablePro/Views/ServerDashboard/SessionsTableView.swift create mode 100644 TablePro/Views/ServerDashboard/SlowQueryListView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 80353386..f7eeeb77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.0] - 2026-04-10 ### Added diff --git a/TablePro/Core/ServerDashboard/Providers/ClickHouseDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/ClickHouseDashboardProvider.swift new file mode 100644 index 00000000..ff5f85ce --- /dev/null +++ b/TablePro/Core/ServerDashboard/Providers/ClickHouseDashboardProvider.swift @@ -0,0 +1,152 @@ +// +// ClickHouseDashboardProvider.swift +// TablePro +// + +import Foundation + +struct ClickHouseDashboardProvider: ServerDashboardQueryProvider { + let supportedPanels: Set = [.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))" + return DashboardSession( + id: value(row, at: col["query_id"]), + user: value(row, at: col["user"]), + database: value(row, at: col["current_database"]), + state: stateDescription, + duration: formatDuration(seconds: Int(elapsed)), + 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 escaped = processId.replacingOccurrences(of: "'", with: "\\'") + return "KILL QUERY WHERE query_id = '\(escaped)'" + } +} + +// 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") + } + } +} diff --git a/TablePro/Core/ServerDashboard/Providers/DuckDBDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/DuckDBDashboardProvider.swift new file mode 100644 index 00000000..a95c587e --- /dev/null +++ b/TablePro/Core/ServerDashboard/Providers/DuckDBDashboardProvider.swift @@ -0,0 +1,98 @@ +// +// DuckDBDashboardProvider.swift +// TablePro +// + +import Foundation + +struct DuckDBDashboardProvider: ServerDashboardQueryProvider { + let supportedPanels: Set = [.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] ?? "" + } +} diff --git a/TablePro/Core/ServerDashboard/Providers/MSSQLDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/MSSQLDashboardProvider.swift new file mode 100644 index 00000000..91c5671a --- /dev/null +++ b/TablePro/Core/ServerDashboard/Providers/MSSQLDashboardProvider.swift @@ -0,0 +1,140 @@ +// +// MSSQLDashboardProvider.swift +// TablePro +// + +import Foundation + +struct MSSQLDashboardProvider: ServerDashboardQueryProvider { + let supportedPanels: Set = [.activeSessions, .serverMetrics, .slowQueries] + + func fetchSessions(execute: (String) async throws -> QueryResult) async throws -> [DashboardSession] { + let sql = """ + SELECT s.session_id, s.login_name, DB_NAME(s.database_id) AS db_name, + s.status, r.total_elapsed_time / 1000 AS duration_secs, + r.command, LEFT(t.text, 1000) AS query_text + FROM sys.dm_exec_sessions s + LEFT JOIN sys.dm_exec_requests r ON s.session_id = r.session_id + OUTER APPLY sys.dm_exec_sql_text(r.sql_handle) t + WHERE s.is_user_process = 1 + ORDER BY r.total_elapsed_time DESC + """ + 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 DashboardSession( + id: value(row, at: col["session_id"]), + user: value(row, at: col["login_name"]), + database: value(row, at: col["db_name"]), + state: value(row, at: col["status"]), + duration: formatDuration(seconds: secs), + query: value(row, at: col["query_text"]), + canCancel: false + ) + } + } + + func fetchMetrics(execute: (String) async throws -> QueryResult) async throws -> [DashboardMetric] { + var metrics: [DashboardMetric] = [] + + let connResult = try await execute( + "SELECT count(*) FROM sys.dm_exec_sessions WHERE is_user_process = 1" + ) + if let row = connResult.rows.first { + metrics.append(DashboardMetric( + id: "connections", + label: String(localized: "User Sessions"), + value: value(row, at: 0), + unit: "", + icon: "person.2" + )) + } + + let uptimeResult = try await execute(""" + SELECT DATEDIFF(SECOND, sqlserver_start_time, GETDATE()) AS uptime_secs + FROM sys.dm_os_sys_info + """) + if let row = uptimeResult.rows.first { + let secs = Int(value(row, at: 0)) ?? 0 + metrics.append(DashboardMetric( + id: "uptime", + label: String(localized: "Uptime"), + value: formatDuration(seconds: secs), + unit: "", + icon: "clock" + )) + } + + let sizeResult = try await execute(""" + SELECT SUM(size * 8 / 1024) AS size_mb FROM sys.database_files + """) + if let row = sizeResult.rows.first { + let sizeMb = value(row, at: 0) + metrics.append(DashboardMetric( + id: "db_size", + label: String(localized: "Database Size"), + value: "\(sizeMb) MB", + unit: "", + icon: "internaldrive" + )) + } + + return metrics + } + + func fetchSlowQueries(execute: (String) async throws -> QueryResult) async throws -> [DashboardSlowQuery] { + let sql = """ + SELECT s.session_id, s.login_name, DB_NAME(s.database_id) AS db_name, + r.total_elapsed_time / 1000 AS duration_secs, + LEFT(t.text, 1000) AS query_text + FROM sys.dm_exec_sessions s + JOIN sys.dm_exec_requests r ON s.session_id = r.session_id + OUTER APPLY sys.dm_exec_sql_text(r.sql_handle) t + WHERE s.is_user_process = 1 AND r.total_elapsed_time > 1000 + ORDER BY r.total_elapsed_time DESC + """ + 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_text"]), + user: value(row, at: col["login_name"]), + database: value(row, at: col["db_name"]) + ) + } + } + + func killSessionSQL(processId: String) -> String? { + guard let spid = Int(processId) else { return nil } + return "KILL \(spid)" + } +} + +// MARK: - Helpers + +private extension MSSQLDashboardProvider { + 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" + } +} diff --git a/TablePro/Core/ServerDashboard/Providers/MySQLDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/MySQLDashboardProvider.swift new file mode 100644 index 00000000..5478195f --- /dev/null +++ b/TablePro/Core/ServerDashboard/Providers/MySQLDashboardProvider.swift @@ -0,0 +1,194 @@ +// +// MySQLDashboardProvider.swift +// TablePro +// + +import Foundation + +struct MySQLDashboardProvider: ServerDashboardQueryProvider { + let supportedPanels: Set = [.activeSessions, .serverMetrics, .slowQueries] + + func fetchSessions(execute: (String) async throws -> QueryResult) async throws -> [DashboardSession] { + let sql = """ + SELECT ID, USER, DB, COMMAND, TIME, STATE, LEFT(INFO, 1000) AS INFO + FROM information_schema.PROCESSLIST + WHERE ID <> CONNECTION_ID() + ORDER BY TIME DESC + """ + 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["time"])) ?? 0 + return DashboardSession( + id: value(row, at: col["id"]), + user: value(row, at: col["user"]), + database: value(row, at: col["db"]), + state: value(row, at: col["command"]), + duration: formatDuration(seconds: secs), + query: value(row, at: col["info"]) + ) + } + } + + func fetchMetrics(execute: (String) async throws -> QueryResult) async throws -> [DashboardMetric] { + var metrics: [DashboardMetric] = [] + + let statusResult = try await execute("SHOW GLOBAL STATUS") + var statusMap: [String: String] = [:] + for row in statusResult.rows { + let key = value(row, at: 0).lowercased() + statusMap[key] = value(row, at: 1) + } + + if let connected = statusMap["threads_connected"] { + metrics.append(DashboardMetric( + id: "threads_connected", + label: String(localized: "Connected Threads"), + value: connected, + unit: "", + icon: "person.2" + )) + } + + if let running = statusMap["threads_running"] { + metrics.append(DashboardMetric( + id: "threads_running", + label: String(localized: "Running Threads"), + value: running, + unit: "", + icon: "bolt.horizontal" + )) + } + + if let uptimeSecs = statusMap["uptime"], let secs = Int(uptimeSecs) { + metrics.append(DashboardMetric( + id: "uptime", + label: String(localized: "Uptime"), + value: formatDuration(seconds: secs), + unit: "", + icon: "clock" + )) + } + + if let questions = statusMap["questions"] { + metrics.append(DashboardMetric( + id: "questions", + label: String(localized: "Total Queries"), + value: questions, + unit: "", + icon: "text.magnifyingglass" + )) + } + + if let slow = statusMap["slow_queries"] { + metrics.append(DashboardMetric( + id: "slow_queries", + label: String(localized: "Slow Queries"), + value: slow, + unit: "", + icon: "tortoise" + )) + } + + let maxConnResult = try await execute("SELECT @@max_connections") + if let row = maxConnResult.rows.first { + metrics.append(DashboardMetric( + id: "max_connections", + label: String(localized: "Max Connections"), + value: value(row, at: 0), + unit: "", + icon: "person.3" + )) + } + + if let received = statusMap["bytes_received"] { + metrics.append(DashboardMetric( + id: "bytes_received", + label: String(localized: "Bytes Received"), + value: formatBytes(received), + unit: "", + icon: "arrow.down.circle" + )) + } + + if let sent = statusMap["bytes_sent"] { + metrics.append(DashboardMetric( + id: "bytes_sent", + label: String(localized: "Bytes Sent"), + value: formatBytes(sent), + unit: "", + icon: "arrow.up.circle" + )) + } + + return metrics + } + + func fetchSlowQueries(execute: (String) async throws -> QueryResult) async throws -> [DashboardSlowQuery] { + let sql = """ + SELECT ID, USER, DB, TIME, LEFT(INFO, 1000) AS INFO + FROM information_schema.PROCESSLIST + WHERE COMMAND <> 'Sleep' AND TIME > 1 AND ID <> CONNECTION_ID() + ORDER BY TIME DESC + """ + 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["time"])) ?? 0 + return DashboardSlowQuery( + duration: formatDuration(seconds: secs), + query: value(row, at: col["info"]), + user: value(row, at: col["user"]), + database: value(row, at: col["db"]) + ) + } + } + + func killSessionSQL(processId: String) -> String? { + guard let id = Int(processId) else { return nil } + return "KILL \(id)" + } + + func cancelQuerySQL(processId: String) -> String? { + guard let id = Int(processId) else { return nil } + return "KILL QUERY \(id)" + } +} + +// MARK: - Helpers + +private extension MySQLDashboardProvider { + 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" + } +} diff --git a/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift new file mode 100644 index 00000000..687d3305 --- /dev/null +++ b/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift @@ -0,0 +1,166 @@ +// +// PostgreSQLDashboardProvider.swift +// TablePro +// + +import Foundation + +struct PostgreSQLDashboardProvider: ServerDashboardQueryProvider { + let supportedPanels: Set = [.activeSessions, .serverMetrics, .slowQueries] + + func fetchSessions(execute: (String) async throws -> QueryResult) async throws -> [DashboardSession] { + let sql = """ + SELECT pid, usename, datname, state, + EXTRACT(EPOCH FROM (now() - query_start))::int AS duration_secs, + left(query, 1000) AS query + FROM pg_stat_activity + WHERE pid <> pg_backend_pid() + ORDER BY query_start NULLS LAST + """ + let result = try await execute(sql) + let col = columnIndex(from: result.columns) + return result.rows.map { row in + let pid = value(row, at: col["pid"]) + let secs = Int(value(row, at: col["duration_secs"])) ?? 0 + return DashboardSession( + id: pid, + user: value(row, at: col["usename"]), + database: value(row, at: col["datname"]), + state: value(row, at: col["state"]), + duration: formatDuration(seconds: secs), + query: value(row, at: col["query"]) + ) + } + } + + func fetchMetrics(execute: (String) async throws -> QueryResult) async throws -> [DashboardMetric] { + var metrics: [DashboardMetric] = [] + + let connections = try await execute("SELECT count(*) FROM pg_stat_activity") + if let row = connections.rows.first { + metrics.append(DashboardMetric( + id: "connections", + label: String(localized: "Connections"), + value: value(row, at: 0), + unit: "", + icon: "person.2" + )) + } + + let cacheHit = try await execute(""" + SELECT CASE WHEN blks_hit + blks_read = 0 THEN '0' + ELSE round(blks_hit::numeric / (blks_hit + blks_read) * 100, 1)::text + END + FROM pg_stat_database WHERE datname = current_database() + """) + if let row = cacheHit.rows.first { + metrics.append(DashboardMetric( + id: "cache_hit", + label: String(localized: "Cache Hit Ratio"), + value: value(row, at: 0), + unit: "%", + icon: "bolt" + )) + } + + let dbSize = try await execute("SELECT pg_size_pretty(pg_database_size(current_database()))") + if let row = dbSize.rows.first { + metrics.append(DashboardMetric( + id: "db_size", + label: String(localized: "Database Size"), + value: value(row, at: 0), + unit: "", + icon: "internaldrive" + )) + } + + let uptime = try await execute( + "SELECT date_trunc('second', now() - pg_postmaster_start_time())::text" + ) + if let row = uptime.rows.first { + metrics.append(DashboardMetric( + id: "uptime", + label: String(localized: "Uptime"), + value: value(row, at: 0), + unit: "", + icon: "clock" + )) + } + + let activeQueries = try await execute(""" + SELECT count(*) FROM pg_stat_activity + WHERE state = 'active' AND pid <> pg_backend_pid() + """) + if let row = activeQueries.rows.first { + metrics.append(DashboardMetric( + id: "active_queries", + label: String(localized: "Active Queries"), + value: value(row, at: 0), + unit: "", + icon: "bolt.horizontal" + )) + } + + return metrics + } + + func fetchSlowQueries(execute: (String) async throws -> QueryResult) async throws -> [DashboardSlowQuery] { + let sql = """ + SELECT pid, usename, datname, + EXTRACT(EPOCH FROM (now() - query_start))::int AS duration_secs, + left(query, 1000) AS query + FROM pg_stat_activity + WHERE state = 'active' + AND now() - query_start > interval '1 second' + AND pid <> pg_backend_pid() + ORDER BY query_start + """ + 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["usename"]), + database: value(row, at: col["datname"]) + ) + } + } + + func killSessionSQL(processId: String) -> String? { + guard let pid = Int(processId) else { return nil } + return "SELECT pg_terminate_backend(\(pid))" + } + + func cancelQuerySQL(processId: String) -> String? { + guard let pid = Int(processId) else { return nil } + return "SELECT pg_cancel_backend(\(pid))" + } +} + +// MARK: - Helpers + +private extension PostgreSQLDashboardProvider { + 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" + } +} diff --git a/TablePro/Core/ServerDashboard/Providers/SQLiteDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/SQLiteDashboardProvider.swift new file mode 100644 index 00000000..fe95377e --- /dev/null +++ b/TablePro/Core/ServerDashboard/Providers/SQLiteDashboardProvider.swift @@ -0,0 +1,90 @@ +// +// SQLiteDashboardProvider.swift +// TablePro +// + +import Foundation + +struct SQLiteDashboardProvider: ServerDashboardQueryProvider { + let supportedPanels: Set = [.serverMetrics] + + func fetchMetrics(execute: (String) async throws -> QueryResult) async throws -> [DashboardMetric] { + var metrics: [DashboardMetric] = [] + + let pageCountResult = try await execute("PRAGMA page_count") + let pageSizeResult = try await execute("PRAGMA page_size") + + let pageCount = pageCountResult.rows.first.flatMap { Int(value($0, at: 0)) } ?? 0 + let pageSize = pageSizeResult.rows.first.flatMap { Int(value($0, at: 0)) } ?? 0 + let dbSizeBytes = pageCount * pageSize + + metrics.append(DashboardMetric( + id: "db_size", + label: String(localized: "Database Size"), + value: formatBytes(dbSizeBytes), + unit: "", + icon: "internaldrive" + )) + + metrics.append(DashboardMetric( + id: "page_count", + label: String(localized: "Page Count"), + value: "\(pageCount)", + unit: "", + icon: "doc" + )) + + metrics.append(DashboardMetric( + id: "page_size", + label: String(localized: "Page Size"), + value: formatBytes(pageSize), + unit: "", + icon: "square.grid.3x3" + )) + + let journalResult = try await execute("PRAGMA journal_mode") + if let row = journalResult.rows.first { + metrics.append(DashboardMetric( + id: "journal_mode", + label: String(localized: "Journal Mode"), + value: value(row, at: 0).uppercased(), + unit: "", + icon: "doc.text" + )) + } + + let cacheResult = try await execute("PRAGMA cache_size") + if let row = cacheResult.rows.first { + let cacheSize = value(row, at: 0) + metrics.append(DashboardMetric( + id: "cache_size", + label: String(localized: "Cache Size"), + value: cacheSize, + unit: String(localized: "pages"), + icon: "memorychip" + )) + } + + return metrics + } +} + +// MARK: - Helpers + +private extension SQLiteDashboardProvider { + func value(_ row: [String?], at index: Int?) -> String { + guard let index, index < row.count else { return "" } + return row[index] ?? "" + } + + func formatBytes(_ bytes: Int) -> String { + if bytes >= 1_073_741_824 { + return String(format: "%.1f GB", Double(bytes) / 1_073_741_824) + } else if bytes >= 1_048_576 { + return String(format: "%.1f MB", Double(bytes) / 1_048_576) + } else if bytes >= 1_024 { + return String(format: "%.1f KB", Double(bytes) / 1_024) + } + return "\(bytes) B" + } +} diff --git a/TablePro/Core/ServerDashboard/ServerDashboardQueryProvider.swift b/TablePro/Core/ServerDashboard/ServerDashboardQueryProvider.swift new file mode 100644 index 00000000..1f98bbe1 --- /dev/null +++ b/TablePro/Core/ServerDashboard/ServerDashboardQueryProvider.swift @@ -0,0 +1,24 @@ +// +// ServerDashboardQueryProvider.swift +// TablePro +// + +import Foundation + +/// Provides database-specific queries and result parsing for the server dashboard. +protocol ServerDashboardQueryProvider { + var supportedPanels: Set { get } + func fetchSessions(execute: (String) async throws -> QueryResult) async throws -> [DashboardSession] + func fetchMetrics(execute: (String) async throws -> QueryResult) async throws -> [DashboardMetric] + func fetchSlowQueries(execute: (String) async throws -> QueryResult) async throws -> [DashboardSlowQuery] + func killSessionSQL(processId: String) -> String? + func cancelQuerySQL(processId: String) -> String? +} + +extension ServerDashboardQueryProvider { + func fetchSessions(execute: (String) async throws -> QueryResult) async throws -> [DashboardSession] { [] } + func fetchMetrics(execute: (String) async throws -> QueryResult) async throws -> [DashboardMetric] { [] } + func fetchSlowQueries(execute: (String) async throws -> QueryResult) async throws -> [DashboardSlowQuery] { [] } + func killSessionSQL(processId: String) -> String? { nil } + func cancelQuerySQL(processId: String) -> String? { nil } +} diff --git a/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift b/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift new file mode 100644 index 00000000..1f24abe9 --- /dev/null +++ b/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift @@ -0,0 +1,27 @@ +// +// ServerDashboardQueryProviderFactory.swift +// TablePro +// + +import Foundation + +enum ServerDashboardQueryProviderFactory { + static func provider(for databaseType: DatabaseType) -> ServerDashboardQueryProvider? { + switch databaseType { + case .postgresql, .redshift: + return PostgreSQLDashboardProvider() + case .mysql, .mariadb: + return MySQLDashboardProvider() + case .mssql: + return MSSQLDashboardProvider() + case .clickhouse: + return ClickHouseDashboardProvider() + case .duckdb: + return DuckDBDashboardProvider() + case .sqlite: + return SQLiteDashboardProvider() + default: + return nil + } + } +} diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index b4e71001..e49459d9 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -98,6 +98,8 @@ enum SessionStateFactory { schemaKey: payload.erDiagramSchemaKey ?? payload.databaseName ?? connection.database, databaseName: payload.databaseName ?? connection.database ) + case .serverDashboard: + tabMgr.addServerDashboardTab() } case .newEmptyTab: tabMgr.addTab(databaseName: payload.databaseName ?? connection.database) diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index 3d63546e..e9115055 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -125,6 +125,19 @@ final class QueryTabManager { selectedTabId = newTab.id } + func addServerDashboardTab() { + if let existing = tabs.first(where: { $0.tabType == .serverDashboard }) { + selectedTabId = existing.id + return + } + let tabTitle = String(localized: "Server Dashboard") + var newTab = QueryTab(title: tabTitle, tabType: .serverDashboard) + newTab.isEditable = false + newTab.hasUserInteraction = true + tabs.append(newTab) + selectedTabId = newTab.id + } + func addPreviewTableTab( tableName: String, databaseType: DatabaseType = .mysql, diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index a6da6a99..b6fc74e6 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -7,10 +7,11 @@ import Foundation /// Type of tab enum TabType: Equatable, Codable, Hashable { - case query // SQL editor tab - case table // Direct table view tab - case createTable // Create new table tab - case erDiagram // ER diagram tab + case query // SQL editor tab + case table // Direct table view tab + case createTable // Create new table tab + case erDiagram // ER diagram tab + case serverDashboard // Server dashboard tab } /// Minimal representation of a tab for persistence diff --git a/TablePro/Models/ServerDashboard/ServerDashboardModels.swift b/TablePro/Models/ServerDashboard/ServerDashboardModels.swift new file mode 100644 index 00000000..9d053f6b --- /dev/null +++ b/TablePro/Models/ServerDashboard/ServerDashboardModels.swift @@ -0,0 +1,67 @@ +// +// ServerDashboardModels.swift +// TablePro +// + +import Foundation + +// MARK: - Dashboard Panel + +enum DashboardPanel: Hashable { + case activeSessions + case serverMetrics + case slowQueries +} + +// MARK: - Refresh Interval + +enum DashboardRefreshInterval: Double, CaseIterable, Identifiable { + case oneSecond = 1 + case twoSeconds = 2 + case fiveSeconds = 5 + case tenSeconds = 10 + case thirtySeconds = 30 + case off = 0 + + var id: Double { rawValue } + + var displayLabel: String { + switch self { + case .off: return String(localized: "Off") + default: return "\(Int(rawValue))s" + } + } +} + +// MARK: - Dashboard Session + +struct DashboardSession: Identifiable { + let id: String + let user: String + let database: String + let state: String + let duration: String + let query: String + var canKill: Bool = true + var canCancel: Bool = true +} + +// MARK: - Dashboard Metric + +struct DashboardMetric: Identifiable { + let id: String + let label: String + let value: String + let unit: String + let icon: String +} + +// MARK: - Dashboard Slow Query + +struct DashboardSlowQuery: Identifiable { + let id = UUID() + let duration: String + let query: String + let user: String + let database: String +} diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 0113c38f..d06668c4 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -237,6 +237,11 @@ struct AppMenuCommands: Commands { } .disabled(!(actions?.isConnected ?? false)) + Button(String(localized: "Server Dashboard")) { + actions?.showServerDashboard() + } + .disabled(!(actions?.isConnected ?? false) || !(actions?.supportsServerDashboard ?? false)) + Divider() Button(String(localized: "Export Connections...")) { diff --git a/TablePro/ViewModels/ServerDashboardViewModel.swift b/TablePro/ViewModels/ServerDashboardViewModel.swift new file mode 100644 index 00000000..4a05c957 --- /dev/null +++ b/TablePro/ViewModels/ServerDashboardViewModel.swift @@ -0,0 +1,215 @@ +import Foundation +import os + +@MainActor +@Observable +final class ServerDashboardViewModel { + private static let logger = Logger(subsystem: "com.TablePro", category: "ServerDashboard") + + // MARK: - Configuration + + let connectionId: UUID + let databaseType: DatabaseType + private(set) var provider: ServerDashboardQueryProvider? + + // MARK: - Data + + var sessions: [DashboardSession] = [] + var metrics: [DashboardMetric] = [] + var slowQueries: [DashboardSlowQuery] = [] + + // MARK: - Refresh State + + var refreshInterval: DashboardRefreshInterval = .fiveSeconds { + didSet { + guard oldValue != refreshInterval else { return } + if refreshTask != nil { + startAutoRefresh() + } + } + } + + var isPaused: Bool = false + var isRefreshing: Bool = false + var lastRefreshDate: Date? + var panelErrors: [DashboardPanel: String] = [:] + + // MARK: - Kill / Cancel Confirmation + + var showKillConfirmation: Bool = false + var pendingKillProcessId: String? + var showCancelConfirmation: Bool = false + var pendingCancelProcessId: String? + + // MARK: - Private + + @ObservationIgnored private var refreshTask: Task? + + // MARK: - Computed Properties + + var supportedPanels: Set { + provider?.supportedPanels ?? [] + } + + var isSupported: Bool { + provider != nil + } + + var canKillSessions: Bool { + provider?.killSessionSQL(processId: "0") != nil + } + + var canCancelQueries: Bool { + provider?.cancelQuerySQL(processId: "0") != nil + } + + // MARK: - Initialization + + init(connectionId: UUID, databaseType: DatabaseType) { + self.connectionId = connectionId + self.databaseType = databaseType + self.provider = ServerDashboardQueryProviderFactory.provider(for: databaseType) + } + + deinit { + refreshTask?.cancel() + } + + // MARK: - Auto Refresh + + func startAutoRefresh() { + refreshTask?.cancel() + + guard refreshInterval != .off else { + refreshTask = nil + return + } + + refreshTask = Task { [weak self] in + guard let self else { return } + + while !Task.isCancelled { + if !self.isPaused { + await self.refreshNow() + } + + try? await Task.sleep(for: .seconds(self.refreshInterval.rawValue)) + } + } + } + + func stopAutoRefresh() { + refreshTask?.cancel() + refreshTask = nil + } + + // MARK: - Data Fetching + + func refreshNow() async { + guard !isRefreshing else { return } + guard let provider else { + Self.logger.warning("No query provider available for \(self.databaseType.rawValue)") + return + } + + isRefreshing = true + defer { isRefreshing = false } + + let execute: (String) async throws -> QueryResult = { [connectionId] query in + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + throw DatabaseError.connectionFailed( + String(localized: "No active connection") + ) + } + return try await driver.execute(query: query) + } + + var newPanelErrors: [DashboardPanel: String] = [:] + + if provider.supportedPanels.contains(.activeSessions) { + do { + sessions = try await provider.fetchSessions(execute: execute) + } catch { + Self.logger.warning("Failed to fetch sessions: \(error.localizedDescription)") + newPanelErrors[.activeSessions] = error.localizedDescription + } + } + + if provider.supportedPanels.contains(.serverMetrics) { + do { + metrics = try await provider.fetchMetrics(execute: execute) + } catch { + Self.logger.warning("Failed to fetch metrics: \(error.localizedDescription)") + newPanelErrors[.serverMetrics] = error.localizedDescription + } + } + + if provider.supportedPanels.contains(.slowQueries) { + do { + slowQueries = try await provider.fetchSlowQueries(execute: execute) + } catch { + Self.logger.warning("Failed to fetch slow queries: \(error.localizedDescription)") + newPanelErrors[.slowQueries] = error.localizedDescription + } + } + + panelErrors = newPanelErrors + lastRefreshDate = Date() + } + + // MARK: - Kill Session + + func confirmKillSession(processId: String) { + pendingKillProcessId = processId + showKillConfirmation = true + } + + func executeKillSession() async { + guard let processId = pendingKillProcessId else { return } + pendingKillProcessId = nil + showKillConfirmation = false + + guard let sql = provider?.killSessionSQL(processId: processId) else { return } + + do { + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + throw DatabaseError.connectionFailed( + String(localized: "No active connection") + ) + } + _ = try await driver.execute(query: sql) + Self.logger.info("Killed session \(processId)") + await refreshNow() + } catch { + Self.logger.error("Failed to kill session \(processId): \(error.localizedDescription)") + } + } + + // MARK: - Cancel Query + + func confirmCancelQuery(processId: String) { + pendingCancelProcessId = processId + showCancelConfirmation = true + } + + func executeCancelQuery() async { + guard let processId = pendingCancelProcessId else { return } + pendingCancelProcessId = nil + showCancelConfirmation = false + + guard let sql = provider?.cancelQuerySQL(processId: processId) else { return } + + do { + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + throw DatabaseError.connectionFailed( + String(localized: "No active connection") + ) + } + _ = try await driver.execute(query: sql) + Self.logger.info("Cancelled query for process \(processId)") + await refreshNow() + } catch { + Self.logger.error("Failed to cancel query for process \(processId): \(error.localizedDescription)") + } + } +} diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 3a329460..495050ee 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -72,6 +72,7 @@ struct MainEditorContentView: View { @State private var tabProviderCache: [UUID: RowProviderCacheEntry] = [:] @State private var cachedChangeManager: AnyChangeManager? @State private var erDiagramViewModels: [UUID: ERDiagramViewModel] = [:] + @State private var serverDashboardViewModels: [UUID: ServerDashboardViewModel] = [:] @State private var favoriteDialogQuery: FavoriteDialogQuery? // Native macOS window tabs โ€” no LRU tracking needed (single tab per window) @@ -121,7 +122,8 @@ struct MainEditorContentView: View { ) } .onChange(of: tabManager.tabIds) { _, newIds in - guard !sortCache.isEmpty || !tabProviderCache.isEmpty || !erDiagramViewModels.isEmpty else { + guard !sortCache.isEmpty || !tabProviderCache.isEmpty || !erDiagramViewModels.isEmpty + || !serverDashboardViewModels.isEmpty else { coordinator.cleanupSortCache(openTabIds: Set(newIds)) return } @@ -130,6 +132,7 @@ struct MainEditorContentView: View { coordinator.cleanupSortCache(openTabIds: openTabIds) tabProviderCache = tabProviderCache.filter { openTabIds.contains($0.key) } erDiagramViewModels = erDiagramViewModels.filter { openTabIds.contains($0.key) } + serverDashboardViewModels = serverDashboardViewModels.filter { openTabIds.contains($0.key) } } .onChange(of: tabManager.selectedTabId) { _, newId in updateHasQueryText() @@ -184,9 +187,34 @@ struct MainEditorContentView: View { ) case .erDiagram: erDiagramContent(tab: tab) + case .serverDashboard: + serverDashboardContent(tab: tab) } } + // MARK: - Server Dashboard Tab Content + + @ViewBuilder + private func serverDashboardContent(tab: QueryTab) -> some View { + Group { + if let vm = serverDashboardViewModels[tab.id] { + ServerDashboardView(viewModel: vm) + } else { + ProgressView(String(localized: "Loading dashboard...")) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + guard serverDashboardViewModels[tab.id] == nil else { return } + let vm = ServerDashboardViewModel( + connectionId: connection.id, + databaseType: connection.type + ) + serverDashboardViewModels[tab.id] = vm + } + } + } + .id(tab.id) + } + // MARK: - ER Diagram Tab Content @ViewBuilder diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift new file mode 100644 index 00000000..d6fb31ca --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift @@ -0,0 +1,7 @@ +import Foundation + +extension MainContentCoordinator { + func openServerDashboardTab() { + tabManager.addServerDashboardTab() + } +} diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 85bae6b7..8c6f179a 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -478,6 +478,15 @@ final class MainContentCommandActions { coordinator?.showERDiagram() } + func showServerDashboard() { + coordinator?.openServerDashboardTab() + } + + var supportsServerDashboard: Bool { + guard let type = coordinator?.connection.type else { return false } + return ServerDashboardQueryProviderFactory.provider(for: type) != nil + } + // MARK: - Tab Navigation (Group A โ€” Called Directly) func selectTab(number: Int) { diff --git a/TablePro/Views/ServerDashboard/DashboardToolbarView.swift b/TablePro/Views/ServerDashboard/DashboardToolbarView.swift new file mode 100644 index 00000000..bd9f2dfc --- /dev/null +++ b/TablePro/Views/ServerDashboard/DashboardToolbarView.swift @@ -0,0 +1,71 @@ +import SwiftUI + +struct DashboardToolbarView: View { + @Bindable var viewModel: ServerDashboardViewModel + + var body: some View { + HStack(spacing: 12) { + Menu { + ForEach(DashboardRefreshInterval.allCases) { interval in + Button { + viewModel.refreshInterval = interval + } label: { + HStack { + Text(interval.displayLabel) + if viewModel.refreshInterval == interval { + Image(systemName: "checkmark") + } + } + } + } + } label: { + Label(viewModel.refreshInterval.displayLabel, systemImage: "arrow.clockwise") + .monospacedDigit() + } + .menuStyle(.borderlessButton) + .fixedSize() + + Button { + viewModel.isPaused.toggle() + } label: { + Image(systemName: viewModel.isPaused ? "play.fill" : "pause.fill") + } + .buttonStyle(.borderless) + .help(viewModel.isPaused ? String(localized: "Resume") : String(localized: "Pause")) + .disabled(viewModel.refreshInterval == .off) + + Button { + Task { await viewModel.refreshNow() } + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .help(String(localized: "Refresh Now")) + .disabled(viewModel.isRefreshing) + .keyboardShortcut("r", modifiers: .command) + + Spacer() + + if viewModel.isRefreshing { + ProgressView() + .controlSize(.small) + } + + if let date = viewModel.lastRefreshDate { + Text(date, style: .time) + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + } + + Text(viewModel.databaseType.rawValue) + .font(.caption) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.quaternary, in: Capsule()) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color(nsColor: .windowBackgroundColor)) + } +} diff --git a/TablePro/Views/ServerDashboard/MetricsBarView.swift b/TablePro/Views/ServerDashboard/MetricsBarView.swift new file mode 100644 index 00000000..ba8f7f8f --- /dev/null +++ b/TablePro/Views/ServerDashboard/MetricsBarView.swift @@ -0,0 +1,66 @@ +import SwiftUI + +struct MetricsBarView: View { + let metrics: [DashboardMetric] + let error: String? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Label(String(localized: "Server Metrics"), systemImage: "gauge.with.dots.needle.33percent") + .font(.headline) + Spacer() + if let error { + Label(error, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.red) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + + if metrics.isEmpty && error == nil { + ProgressView() + .frame(maxWidth: .infinity, minHeight: 60) + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + ForEach(metrics) { metric in + metricCard(metric) + } + } + .padding(.horizontal, 12) + .padding(.bottom, 8) + } + } + } + } + + private func metricCard(_ metric: DashboardMetric) -> some View { + HStack(spacing: 8) { + Image(systemName: metric.icon) + .foregroundStyle(.secondary) + .frame(width: 16) + + VStack(alignment: .leading, spacing: 2) { + Text(metric.label) + .font(.caption) + .foregroundStyle(.secondary) + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text(metric.value) + .font(.system(.body, design: .monospaced)) + .fontWeight(.medium) + .monospacedDigit() + if !metric.unit.isEmpty { + Text(metric.unit) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 6)) + } +} diff --git a/TablePro/Views/ServerDashboard/ServerDashboardView.swift b/TablePro/Views/ServerDashboard/ServerDashboardView.swift new file mode 100644 index 00000000..12c19278 --- /dev/null +++ b/TablePro/Views/ServerDashboard/ServerDashboardView.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct ServerDashboardView: View { + @Bindable var viewModel: ServerDashboardViewModel + + var body: some View { + VStack(spacing: 0) { + DashboardToolbarView(viewModel: viewModel) + Divider() + + if viewModel.supportedPanels.isEmpty { + ContentUnavailableView( + String(localized: "Dashboard Not Available"), + systemImage: "gauge.with.dots.needle.0percent", + 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] + ) + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { viewModel.startAutoRefresh() } + .onDisappear { viewModel.stopAutoRefresh() } + .alert(String(localized: "Terminate Session"), isPresented: $viewModel.showKillConfirmation) { + Button(String(localized: "Cancel"), role: .cancel) { viewModel.pendingKillProcessId = nil } + Button(String(localized: "Terminate"), role: .destructive) { + Task { await viewModel.executeKillSession() } + } + } message: { + Text(String(localized: "Are you sure you want to terminate this session? Any running queries will be aborted.")) + } + .alert(String(localized: "Cancel Query"), isPresented: $viewModel.showCancelConfirmation) { + Button(String(localized: "Keep Running"), role: .cancel) { viewModel.pendingCancelProcessId = nil } + Button(String(localized: "Cancel Query"), role: .destructive) { + Task { await viewModel.executeCancelQuery() } + } + } message: { + Text(String(localized: "Are you sure you want to cancel the running query for this session?")) + } + } +} diff --git a/TablePro/Views/ServerDashboard/SessionsTableView.swift b/TablePro/Views/ServerDashboard/SessionsTableView.swift new file mode 100644 index 00000000..0e49b2b3 --- /dev/null +++ b/TablePro/Views/ServerDashboard/SessionsTableView.swift @@ -0,0 +1,91 @@ +import SwiftUI + +struct SessionsTableView: View { + @Bindable var viewModel: ServerDashboardViewModel + @State private var sortOrder = [KeyPathComparator(\DashboardSession.duration, order: .reverse)] + @State private var selection: Set = [] + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Label(String(localized: "Active Sessions"), systemImage: "person.2") + .font(.headline) + Text("(\(viewModel.sessions.count))") + .foregroundStyle(.secondary) + Spacer() + if let error = viewModel.panelErrors[.activeSessions] { + Label(error, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.red) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Table(viewModel.sessions, selection: $selection, sortOrder: $sortOrder) { + TableColumn(String(localized: "PID"), value: \.id) { session in + Text(session.id).monospacedDigit() + } + .width(min: 50, ideal: 70) + + TableColumn(String(localized: "User"), value: \.user) + .width(min: 60, ideal: 100) + + TableColumn(String(localized: "Database"), value: \.database) + .width(min: 60, ideal: 100) + + TableColumn(String(localized: "State"), value: \.state) { session in + Text(session.state) + .foregroundStyle(stateColor(session.state)) + } + .width(min: 60, ideal: 80) + + TableColumn(String(localized: "Duration"), value: \.duration) { session in + Text(session.duration).monospacedDigit() + } + .width(min: 50, ideal: 80) + + TableColumn(String(localized: "Query"), value: \.query) { session in + Text(session.query) + .lineLimit(1) + .truncationMode(.tail) + .help(session.query) + } + + TableColumn("") { session in + HStack(spacing: 4) { + if session.canCancel, viewModel.canCancelQueries { + Button { viewModel.confirmCancelQuery(processId: session.id) } label: { + Image(systemName: "stop.circle") + } + .buttonStyle(.borderless) + .help(String(localized: "Cancel Query")) + } + if session.canKill, viewModel.canKillSessions { + Button { viewModel.confirmKillSession(processId: session.id) } label: { + Image(systemName: "xmark.circle") + .foregroundStyle(.red) + } + .buttonStyle(.borderless) + .help(String(localized: "Terminate Session")) + } + } + } + .width(60) + } + .onChange(of: sortOrder) { _, newOrder in + viewModel.sessions.sort(using: newOrder) + } + } + } + + private func stateColor(_ state: String) -> Color { + switch state.lowercased() { + case "active", "running": return .green + case "idle": return .secondary + case "idle in transaction": return .orange + case "waiting", "locked": return .red + default: return .primary + } + } +} diff --git a/TablePro/Views/ServerDashboard/SlowQueryListView.swift b/TablePro/Views/ServerDashboard/SlowQueryListView.swift new file mode 100644 index 00000000..9ee82bfc --- /dev/null +++ b/TablePro/Views/ServerDashboard/SlowQueryListView.swift @@ -0,0 +1,84 @@ +import SwiftUI + +struct SlowQueryListView: View { + let queries: [DashboardSlowQuery] + let error: String? + @State private var isExpanded = true + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { isExpanded.toggle() } + } 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") + .font(.caption) + .foregroundStyle(.red) + } + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .foregroundStyle(.secondary) + .font(.caption) + } + } + .buttonStyle(.plain) + .padding(.horizontal, 12) + .padding(.vertical, 8) + + if isExpanded { + if queries.isEmpty && error == nil { + Text(String(localized: "No slow queries")) + .foregroundStyle(.secondary) + .font(.caption) + .padding(.horizontal, 12) + .padding(.bottom, 8) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 4) { + ForEach(queries) { query in + slowQueryRow(query) + } + } + .padding(.horizontal, 12) + .padding(.bottom, 8) + } + .frame(maxHeight: 150) + } + } + } + } + + private func slowQueryRow(_ query: DashboardSlowQuery) -> some View { + HStack(alignment: .top, spacing: 8) { + Text(query.duration) + .font(.system(.caption, design: .monospaced)) + .monospacedDigit() + .foregroundStyle(.orange) + .frame(width: 50, alignment: .trailing) + + VStack(alignment: .leading, spacing: 2) { + Text(query.query) + .font(.system(.caption, design: .monospaced)) + .lineLimit(2) + .truncationMode(.tail) + HStack(spacing: 4) { + if !query.user.isEmpty { + Text(query.user) + } + if !query.database.isEmpty { + Text("ยท") + Text(query.database) + } + } + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + .padding(.vertical, 2) + } +} diff --git a/TablePro/Views/Toolbar/TableProToolbarView.swift b/TablePro/Views/Toolbar/TableProToolbarView.swift index 7e17794f..9d511e26 100644 --- a/TablePro/Views/Toolbar/TableProToolbarView.swift +++ b/TablePro/Views/Toolbar/TableProToolbarView.swift @@ -185,6 +185,14 @@ struct TableProToolbar: ViewModifier { // MARK: - Secondary Action (Overflow) ToolbarItemGroup(placement: .secondaryAction) { + Button { + actions?.showServerDashboard() + } label: { + Label("Dashboard", systemImage: "gauge.with.dots.needle.33percent") + } + .help(String(localized: "Server Dashboard")) + .disabled(state.connectionState != .connected || !(actions?.supportsServerDashboard ?? false)) + Button { actions?.toggleHistoryPanel() } label: { From 19d3b683ea3234a7b9a8b782d680fd68b8b84dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 10 Apr 2026 12:13:37 +0700 Subject: [PATCH 2/8] fix: address review feedback for server dashboard --- .../ClickHouseDashboardProvider.swift | 11 ++- .../Providers/MSSQLDashboardProvider.swift | 1 + .../Providers/MySQLDashboardProvider.swift | 1 + .../PostgreSQLDashboardProvider.swift | 1 + .../ServerDashboardModels.swift | 1 + .../ViewModels/ServerDashboardViewModel.swift | 4 +- .../ServerDashboard/SessionsTableView.swift | 2 +- docs/features/server-dashboard.mdx | 68 +++++++++++++++++++ 8 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 docs/features/server-dashboard.mdx diff --git a/TablePro/Core/ServerDashboard/Providers/ClickHouseDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/ClickHouseDashboardProvider.swift index ff5f85ce..7d2fcc98 100644 --- a/TablePro/Core/ServerDashboard/Providers/ClickHouseDashboardProvider.swift +++ b/TablePro/Core/ServerDashboard/Providers/ClickHouseDashboardProvider.swift @@ -22,12 +22,14 @@ struct ClickHouseDashboardProvider: ServerDashboardQueryProvider { 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, - duration: formatDuration(seconds: Int(elapsed)), + durationSeconds: secs, + duration: formatDuration(seconds: secs), query: value(row, at: col["query"]), canCancel: false ) @@ -95,8 +97,11 @@ struct ClickHouseDashboardProvider: ServerDashboardQueryProvider { } func killSessionSQL(processId: String) -> String? { - let escaped = processId.replacingOccurrences(of: "'", with: "\\'") - return "KILL QUERY WHERE query_id = '\(escaped)'" + 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)'" } } diff --git a/TablePro/Core/ServerDashboard/Providers/MSSQLDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/MSSQLDashboardProvider.swift index 91c5671a..695f4456 100644 --- a/TablePro/Core/ServerDashboard/Providers/MSSQLDashboardProvider.swift +++ b/TablePro/Core/ServerDashboard/Providers/MSSQLDashboardProvider.swift @@ -28,6 +28,7 @@ struct MSSQLDashboardProvider: ServerDashboardQueryProvider { user: value(row, at: col["login_name"]), database: value(row, at: col["db_name"]), state: value(row, at: col["status"]), + durationSeconds: secs, duration: formatDuration(seconds: secs), query: value(row, at: col["query_text"]), canCancel: false diff --git a/TablePro/Core/ServerDashboard/Providers/MySQLDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/MySQLDashboardProvider.swift index 5478195f..cb599cae 100644 --- a/TablePro/Core/ServerDashboard/Providers/MySQLDashboardProvider.swift +++ b/TablePro/Core/ServerDashboard/Providers/MySQLDashboardProvider.swift @@ -24,6 +24,7 @@ struct MySQLDashboardProvider: ServerDashboardQueryProvider { user: value(row, at: col["user"]), database: value(row, at: col["db"]), state: value(row, at: col["command"]), + durationSeconds: secs, duration: formatDuration(seconds: secs), query: value(row, at: col["info"]) ) diff --git a/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift index 687d3305..19a78046 100644 --- a/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift +++ b/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift @@ -27,6 +27,7 @@ struct PostgreSQLDashboardProvider: ServerDashboardQueryProvider { user: value(row, at: col["usename"]), database: value(row, at: col["datname"]), state: value(row, at: col["state"]), + durationSeconds: secs, duration: formatDuration(seconds: secs), query: value(row, at: col["query"]) ) diff --git a/TablePro/Models/ServerDashboard/ServerDashboardModels.swift b/TablePro/Models/ServerDashboard/ServerDashboardModels.swift index 9d053f6b..5a54cd26 100644 --- a/TablePro/Models/ServerDashboard/ServerDashboardModels.swift +++ b/TablePro/Models/ServerDashboard/ServerDashboardModels.swift @@ -40,6 +40,7 @@ struct DashboardSession: Identifiable { let user: String let database: String let state: String + let durationSeconds: Int let duration: String let query: String var canKill: Bool = true diff --git a/TablePro/ViewModels/ServerDashboardViewModel.swift b/TablePro/ViewModels/ServerDashboardViewModel.swift index 4a05c957..5c4b76da 100644 --- a/TablePro/ViewModels/ServerDashboardViewModel.swift +++ b/TablePro/ViewModels/ServerDashboardViewModel.swift @@ -23,7 +23,7 @@ final class ServerDashboardViewModel { var refreshInterval: DashboardRefreshInterval = .fiveSeconds { didSet { guard oldValue != refreshInterval else { return } - if refreshTask != nil { + if refreshTask != nil || refreshInterval != .off { startAutoRefresh() } } @@ -43,7 +43,7 @@ final class ServerDashboardViewModel { // MARK: - Private - @ObservationIgnored private var refreshTask: Task? + @ObservationIgnored nonisolated(unsafe) private var refreshTask: Task? // MARK: - Computed Properties diff --git a/TablePro/Views/ServerDashboard/SessionsTableView.swift b/TablePro/Views/ServerDashboard/SessionsTableView.swift index 0e49b2b3..490c7992 100644 --- a/TablePro/Views/ServerDashboard/SessionsTableView.swift +++ b/TablePro/Views/ServerDashboard/SessionsTableView.swift @@ -2,7 +2,7 @@ import SwiftUI struct SessionsTableView: View { @Bindable var viewModel: ServerDashboardViewModel - @State private var sortOrder = [KeyPathComparator(\DashboardSession.duration, order: .reverse)] + @State private var sortOrder = [KeyPathComparator(\DashboardSession.durationSeconds, order: .reverse)] @State private var selection: Set = [] var body: some View { diff --git a/docs/features/server-dashboard.mdx b/docs/features/server-dashboard.mdx new file mode 100644 index 00000000..fa1171eb --- /dev/null +++ b/docs/features/server-dashboard.mdx @@ -0,0 +1,68 @@ +--- +title: Server Dashboard +description: Monitor active sessions, server metrics, and slow queries in real time +--- + +# Server Dashboard + +View live server state at a glance. The dashboard shows active sessions, key server metrics, and slow-running queries with configurable auto-refresh. + +Open it from the menu bar **View > Server Dashboard** or click the **Dashboard** button in the toolbar overflow menu. + +## Active Sessions + +A sortable table of all connections to the server, showing: + +- **PID** - process or session ID +- **User** - connected user +- **Database** - target database +- **State** - current status (active, idle, sleeping) +- **Duration** - how long the current operation has been running +- **Query** - the SQL statement being executed (truncated, hover for full text) + +### Kill and Cancel + +Each session row has action buttons: + +- **Cancel Query** (stop icon) - cancels the running query without terminating the connection +- **Terminate Session** (x icon) - kills the entire connection + +Both actions show a confirmation alert before executing. + +## Server Metrics + +A horizontal strip of key metrics displayed as cards: + +| Database | Metrics shown | +|----------|--------------| +| PostgreSQL | Active connections, cache hit ratio, database size, uptime, active queries | +| MySQL/MariaDB | Threads connected, threads running, uptime, total queries, slow queries, max connections | +| MSSQL | Active connections, uptime, database size | +| ClickHouse | Active queries, merges, disk usage | +| DuckDB | Database size, memory limit, threads | +| SQLite | Database size, journal mode, cache size | + +## 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. + +## Auto-refresh + +The dashboard toolbar provides refresh controls: + +- **Interval picker** - choose 1s, 2s, 5s (default), 10s, 30s, or Off +- **Pause/Resume** - temporarily stop refreshing without changing the interval +- **Manual refresh** - click or press **Cmd+R** +- **Last refresh time** - shown on the right + +## Database Support + +| Feature | PostgreSQL | MySQL | MSSQL | ClickHouse | DuckDB | SQLite | +|---------|:----------:|:-----:|:-----:|:----------:|:------:|:------:| +| Active Sessions | Yes | Yes | Yes | Yes | - | - | +| Server Metrics | Yes | Yes | Yes | Yes | Yes | Yes | +| Slow Queries | Yes | Yes | Yes | Yes | - | - | +| Kill Session | Yes | Yes | Yes | Yes | - | - | +| Cancel Query | Yes | Yes | - | - | - | - | + +For databases that don't support a panel, that section is hidden automatically. NoSQL databases (Redis, MongoDB, etc.) do not support the dashboard - the menu item and toolbar button are disabled. From 2f4ac61051cb71a01adfed2f4e08c20be835509f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 10 Apr 2026 12:19:34 +0700 Subject: [PATCH 3/8] fix: show "Server Dashboard" in toolbar title instead of "SQL Query" --- TablePro/ContentView.swift | 4 +++- TablePro/Views/Main/Extensions/MainContentView+Setup.swift | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index 7c01696c..39471b39 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -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 { diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index dfcc679e..11273ee7 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -154,7 +154,9 @@ extension MainContentView { /// Update window title, proxy icon, and dirty dot based on the selected tab. func updateWindowTitleAndFileState() { let selectedTab = tabManager.selectedTab - if selectedTab?.tabType == .createTable { + if selectedTab?.tabType == .serverDashboard { + windowTitle = String(localized: "Server Dashboard") + } else if selectedTab?.tabType == .createTable { windowTitle = String(localized: "Create Table") } else if let fileURL = selectedTab?.sourceFileURL { windowTitle = fileURL.deletingPathExtension().lastPathComponent From f52e0121cf32f6c54efee49bccb451243dd3df28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 10 Apr 2026 12:31:48 +0700 Subject: [PATCH 4/8] fix: filter out PostgreSQL background workers from dashboard sessions --- .../Providers/PostgreSQLDashboardProvider.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift index 19a78046..eb770f07 100644 --- a/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift +++ b/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift @@ -15,6 +15,7 @@ struct PostgreSQLDashboardProvider: ServerDashboardQueryProvider { left(query, 1000) AS query FROM pg_stat_activity WHERE pid <> pg_backend_pid() + AND backend_type = 'client backend' ORDER BY query_start NULLS LAST """ let result = try await execute(sql) @@ -37,7 +38,7 @@ struct PostgreSQLDashboardProvider: ServerDashboardQueryProvider { func fetchMetrics(execute: (String) async throws -> QueryResult) async throws -> [DashboardMetric] { var metrics: [DashboardMetric] = [] - let connections = try await execute("SELECT count(*) FROM pg_stat_activity") + let connections = try await execute("SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'") if let row = connections.rows.first { metrics.append(DashboardMetric( id: "connections", From 6362da8406b1a84a8c9e6c1205745eb7fa8deba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 10 Apr 2026 13:06:13 +0700 Subject: [PATCH 5/8] fix: address 11 review issues in server dashboard --- .../Providers/MSSQLDashboardProvider.swift | 10 +++++----- .../Providers/MySQLDashboardProvider.swift | 2 +- .../ServerDashboardModels.swift | 2 +- .../ViewModels/ServerDashboardViewModel.swift | 19 +++++++++++++++---- .../DashboardToolbarView.swift | 1 - .../ServerDashboard/ServerDashboardView.swift | 10 ++++++++++ .../ServerDashboard/SessionsTableView.swift | 9 +++++---- .../ServerDashboard/SlowQueryListView.swift | 2 +- .../Views/Toolbar/TableProToolbarView.swift | 2 +- 9 files changed, 39 insertions(+), 18 deletions(-) diff --git a/TablePro/Core/ServerDashboard/Providers/MSSQLDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/MSSQLDashboardProvider.swift index 695f4456..add91e5f 100644 --- a/TablePro/Core/ServerDashboard/Providers/MSSQLDashboardProvider.swift +++ b/TablePro/Core/ServerDashboard/Providers/MSSQLDashboardProvider.swift @@ -11,7 +11,7 @@ struct MSSQLDashboardProvider: ServerDashboardQueryProvider { func fetchSessions(execute: (String) async throws -> QueryResult) async throws -> [DashboardSession] { let sql = """ SELECT s.session_id, s.login_name, DB_NAME(s.database_id) AS db_name, - s.status, r.total_elapsed_time / 1000 AS duration_secs, + s.status, r.total_elapsed_time AS duration_ms, r.command, LEFT(t.text, 1000) AS query_text FROM sys.dm_exec_sessions s LEFT JOIN sys.dm_exec_requests r ON s.session_id = r.session_id @@ -22,7 +22,7 @@ struct MSSQLDashboardProvider: ServerDashboardQueryProvider { 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 + let secs = (Int(value(row, at: col["duration_ms"])) ?? 0) / 1_000 return DashboardSession( id: value(row, at: col["session_id"]), user: value(row, at: col["login_name"]), @@ -87,18 +87,18 @@ struct MSSQLDashboardProvider: ServerDashboardQueryProvider { func fetchSlowQueries(execute: (String) async throws -> QueryResult) async throws -> [DashboardSlowQuery] { let sql = """ SELECT s.session_id, s.login_name, DB_NAME(s.database_id) AS db_name, - r.total_elapsed_time / 1000 AS duration_secs, + r.total_elapsed_time AS duration_ms, LEFT(t.text, 1000) AS query_text FROM sys.dm_exec_sessions s JOIN sys.dm_exec_requests r ON s.session_id = r.session_id OUTER APPLY sys.dm_exec_sql_text(r.sql_handle) t - WHERE s.is_user_process = 1 AND r.total_elapsed_time > 1000 + WHERE s.is_user_process = 1 AND r.total_elapsed_time > 1_000 ORDER BY r.total_elapsed_time DESC """ 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 + let secs = (Int(value(row, at: col["duration_ms"])) ?? 0) / 1_000 return DashboardSlowQuery( duration: formatDuration(seconds: secs), query: value(row, at: col["query_text"]), diff --git a/TablePro/Core/ServerDashboard/Providers/MySQLDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/MySQLDashboardProvider.swift index cb599cae..f3c3f294 100644 --- a/TablePro/Core/ServerDashboard/Providers/MySQLDashboardProvider.swift +++ b/TablePro/Core/ServerDashboard/Providers/MySQLDashboardProvider.swift @@ -23,7 +23,7 @@ struct MySQLDashboardProvider: ServerDashboardQueryProvider { id: value(row, at: col["id"]), user: value(row, at: col["user"]), database: value(row, at: col["db"]), - state: value(row, at: col["command"]), + state: value(row, at: col["state"]), durationSeconds: secs, duration: formatDuration(seconds: secs), query: value(row, at: col["info"]) diff --git a/TablePro/Models/ServerDashboard/ServerDashboardModels.swift b/TablePro/Models/ServerDashboard/ServerDashboardModels.swift index 5a54cd26..d21d7ca2 100644 --- a/TablePro/Models/ServerDashboard/ServerDashboardModels.swift +++ b/TablePro/Models/ServerDashboard/ServerDashboardModels.swift @@ -16,12 +16,12 @@ enum DashboardPanel: Hashable { // MARK: - Refresh Interval enum DashboardRefreshInterval: Double, CaseIterable, Identifiable { + case off = 0 case oneSecond = 1 case twoSeconds = 2 case fiveSeconds = 5 case tenSeconds = 10 case thirtySeconds = 30 - case off = 0 var id: Double { rawValue } diff --git a/TablePro/ViewModels/ServerDashboardViewModel.swift b/TablePro/ViewModels/ServerDashboardViewModel.swift index 5c4b76da..1c29be9b 100644 --- a/TablePro/ViewModels/ServerDashboardViewModel.swift +++ b/TablePro/ViewModels/ServerDashboardViewModel.swift @@ -34,12 +34,19 @@ final class ServerDashboardViewModel { var lastRefreshDate: Date? var panelErrors: [DashboardPanel: String] = [:] + // MARK: - Sort State + + var sessionSortOrder: [KeyPathComparator] = [ + KeyPathComparator(\DashboardSession.durationSeconds, order: .reverse), + ] + // MARK: - Kill / Cancel Confirmation var showKillConfirmation: Bool = false var pendingKillProcessId: String? var showCancelConfirmation: Bool = false var pendingCancelProcessId: String? + var actionError: String? // MARK: - Private @@ -86,14 +93,14 @@ final class ServerDashboardViewModel { } refreshTask = Task { [weak self] in - guard let self else { return } - while !Task.isCancelled { + guard let self else { return } if !self.isPaused { await self.refreshNow() } - - try? await Task.sleep(for: .seconds(self.refreshInterval.rawValue)) + let interval = self.refreshInterval.rawValue + guard interval > 0 else { break } + try? await Task.sleep(for: .seconds(interval)) } } } @@ -101,6 +108,7 @@ final class ServerDashboardViewModel { func stopAutoRefresh() { refreshTask?.cancel() refreshTask = nil + isRefreshing = false } // MARK: - Data Fetching @@ -129,6 +137,7 @@ final class ServerDashboardViewModel { if provider.supportedPanels.contains(.activeSessions) { do { sessions = try await provider.fetchSessions(execute: execute) + sessions.sort(using: sessionSortOrder) } catch { Self.logger.warning("Failed to fetch sessions: \(error.localizedDescription)") newPanelErrors[.activeSessions] = error.localizedDescription @@ -182,6 +191,7 @@ final class ServerDashboardViewModel { await refreshNow() } catch { Self.logger.error("Failed to kill session \(processId): \(error.localizedDescription)") + actionError = error.localizedDescription } } @@ -210,6 +220,7 @@ final class ServerDashboardViewModel { await refreshNow() } catch { Self.logger.error("Failed to cancel query for process \(processId): \(error.localizedDescription)") + actionError = error.localizedDescription } } } diff --git a/TablePro/Views/ServerDashboard/DashboardToolbarView.swift b/TablePro/Views/ServerDashboard/DashboardToolbarView.swift index bd9f2dfc..4743fe43 100644 --- a/TablePro/Views/ServerDashboard/DashboardToolbarView.swift +++ b/TablePro/Views/ServerDashboard/DashboardToolbarView.swift @@ -66,6 +66,5 @@ struct DashboardToolbarView: View { } .padding(.horizontal, 12) .padding(.vertical, 6) - .background(Color(nsColor: .windowBackgroundColor)) } } diff --git a/TablePro/Views/ServerDashboard/ServerDashboardView.swift b/TablePro/Views/ServerDashboard/ServerDashboardView.swift index 12c19278..183f2c5e 100644 --- a/TablePro/Views/ServerDashboard/ServerDashboardView.swift +++ b/TablePro/Views/ServerDashboard/ServerDashboardView.swift @@ -57,5 +57,15 @@ struct ServerDashboardView: View { } message: { Text(String(localized: "Are you sure you want to cancel the running query for this session?")) } + .alert(String(localized: "Action Failed"), isPresented: Binding( + get: { viewModel.actionError != nil }, + set: { if !$0 { viewModel.actionError = nil } } + )) { + Button(String(localized: "OK"), role: .cancel) { viewModel.actionError = nil } + } message: { + if let error = viewModel.actionError { + Text(error) + } + } } } diff --git a/TablePro/Views/ServerDashboard/SessionsTableView.swift b/TablePro/Views/ServerDashboard/SessionsTableView.swift index 490c7992..a3ac3b44 100644 --- a/TablePro/Views/ServerDashboard/SessionsTableView.swift +++ b/TablePro/Views/ServerDashboard/SessionsTableView.swift @@ -2,7 +2,6 @@ import SwiftUI struct SessionsTableView: View { @Bindable var viewModel: ServerDashboardViewModel - @State private var sortOrder = [KeyPathComparator(\DashboardSession.durationSeconds, order: .reverse)] @State private var selection: Set = [] var body: some View { @@ -22,7 +21,7 @@ struct SessionsTableView: View { .padding(.horizontal, 12) .padding(.vertical, 8) - Table(viewModel.sessions, selection: $selection, sortOrder: $sortOrder) { + Table(viewModel.sessions, selection: $selection, sortOrder: $viewModel.sessionSortOrder) { TableColumn(String(localized: "PID"), value: \.id) { session in Text(session.id).monospacedDigit() } @@ -40,7 +39,7 @@ struct SessionsTableView: View { } .width(min: 60, ideal: 80) - TableColumn(String(localized: "Duration"), value: \.duration) { session in + TableColumn(String(localized: "Duration"), value: \.durationSeconds) { session in Text(session.duration).monospacedDigit() } .width(min: 50, ideal: 80) @@ -60,6 +59,7 @@ struct SessionsTableView: View { } .buttonStyle(.borderless) .help(String(localized: "Cancel Query")) + .accessibilityLabel(String(localized: "Cancel query for session \(session.id)")) } if session.canKill, viewModel.canKillSessions { Button { viewModel.confirmKillSession(processId: session.id) } label: { @@ -68,12 +68,13 @@ struct SessionsTableView: View { } .buttonStyle(.borderless) .help(String(localized: "Terminate Session")) + .accessibilityLabel(String(localized: "Terminate session \(session.id)")) } } } .width(60) } - .onChange(of: sortOrder) { _, newOrder in + .onChange(of: viewModel.sessionSortOrder) { _, newOrder in viewModel.sessions.sort(using: newOrder) } } diff --git a/TablePro/Views/ServerDashboard/SlowQueryListView.swift b/TablePro/Views/ServerDashboard/SlowQueryListView.swift index 9ee82bfc..5f31abb8 100644 --- a/TablePro/Views/ServerDashboard/SlowQueryListView.swift +++ b/TablePro/Views/ServerDashboard/SlowQueryListView.swift @@ -47,7 +47,7 @@ struct SlowQueryListView: View { .padding(.horizontal, 12) .padding(.bottom, 8) } - .frame(maxHeight: 150) + .frame(maxHeight: 200) } } } diff --git a/TablePro/Views/Toolbar/TableProToolbarView.swift b/TablePro/Views/Toolbar/TableProToolbarView.swift index 9d511e26..616b05f4 100644 --- a/TablePro/Views/Toolbar/TableProToolbarView.swift +++ b/TablePro/Views/Toolbar/TableProToolbarView.swift @@ -188,7 +188,7 @@ struct TableProToolbar: ViewModifier { Button { actions?.showServerDashboard() } label: { - Label("Dashboard", systemImage: "gauge.with.dots.needle.33percent") + Label(String(localized: "Dashboard"), systemImage: "gauge.with.dots.needle.33percent") } .help(String(localized: "Server Dashboard")) .disabled(state.connectionState != .connected || !(actions?.supportsServerDashboard ?? false)) From 093da9c0ba39e18b174565f1b9660d2e1036b2c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 10 Apr 2026 13:08:24 +0700 Subject: [PATCH 6/8] fix: skip dashboard refresh silently when connection not ready yet --- TablePro/ViewModels/ServerDashboardViewModel.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/TablePro/ViewModels/ServerDashboardViewModel.swift b/TablePro/ViewModels/ServerDashboardViewModel.swift index 1c29be9b..3c0d0a17 100644 --- a/TablePro/ViewModels/ServerDashboardViewModel.swift +++ b/TablePro/ViewModels/ServerDashboardViewModel.swift @@ -120,6 +120,9 @@ final class ServerDashboardViewModel { return } + // Skip silently if connection is not ready yet โ€” the refresh loop will retry + guard DatabaseManager.shared.driver(for: connectionId) != nil else { return } + isRefreshing = true defer { isRefreshing = false } From 35ec1644e022c42d945c04f9f5bc70659ffa9f43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 10 Apr 2026 13:55:30 +0700 Subject: [PATCH 7/8] docs: add server dashboard page to navigation --- docs/docs.json | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs.json b/docs/docs.json index 28e4bcc9..8812cfd5 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -53,6 +53,7 @@ "features/sql-editor", "features/explain-visualization", "features/er-diagram", + "features/server-dashboard", "features/data-grid", "features/autocomplete", "features/table-structure", From 843f78c87a010ccb9445615dc6b5df4e525deadb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 10 Apr 2026 13:57:50 +0700 Subject: [PATCH 8/8] docs: add screenshot placeholders to server dashboard page --- docs/features/server-dashboard.mdx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/features/server-dashboard.mdx b/docs/features/server-dashboard.mdx index fa1171eb..ce29d5ff 100644 --- a/docs/features/server-dashboard.mdx +++ b/docs/features/server-dashboard.mdx @@ -9,6 +9,19 @@ View live server state at a glance. The dashboard shows active sessions, key ser Open it from the menu bar **View > Server Dashboard** or click the **Dashboard** button in the toolbar overflow menu. + + Server Dashboard + Server Dashboard + + ## Active Sessions A sortable table of all connections to the server, showing: