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/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/Core/ServerDashboard/Providers/ClickHouseDashboardProvider.swift b/TablePro/Core/ServerDashboard/Providers/ClickHouseDashboardProvider.swift new file mode 100644 index 00000000..7d2fcc98 --- /dev/null +++ b/TablePro/Core/ServerDashboard/Providers/ClickHouseDashboardProvider.swift @@ -0,0 +1,157 @@ +// +// 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))" + 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") + } + } +} 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..add91e5f --- /dev/null +++ b/TablePro/Core/ServerDashboard/Providers/MSSQLDashboardProvider.swift @@ -0,0 +1,141 @@ +// +// 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 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 + 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_ms"])) ?? 0) / 1_000 + 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"]), + durationSeconds: secs, + 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 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 > 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_ms"])) ?? 0) / 1_000 + 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..f3c3f294 --- /dev/null +++ b/TablePro/Core/ServerDashboard/Providers/MySQLDashboardProvider.swift @@ -0,0 +1,195 @@ +// +// 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["state"]), + durationSeconds: secs, + 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..eb770f07 --- /dev/null +++ b/TablePro/Core/ServerDashboard/Providers/PostgreSQLDashboardProvider.swift @@ -0,0 +1,168 @@ +// +// 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() + AND backend_type = 'client backend' + 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"]), + durationSeconds: secs, + 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 WHERE backend_type = 'client backend'") + 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..d21d7ca2 --- /dev/null +++ b/TablePro/Models/ServerDashboard/ServerDashboardModels.swift @@ -0,0 +1,68 @@ +// +// 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 off = 0 + case oneSecond = 1 + case twoSeconds = 2 + case fiveSeconds = 5 + case tenSeconds = 10 + case thirtySeconds = 30 + + 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 durationSeconds: Int + 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..3c0d0a17 --- /dev/null +++ b/TablePro/ViewModels/ServerDashboardViewModel.swift @@ -0,0 +1,229 @@ +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 || refreshInterval != .off { + startAutoRefresh() + } + } + } + + var isPaused: Bool = false + var isRefreshing: Bool = false + 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 + + @ObservationIgnored nonisolated(unsafe) 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 + while !Task.isCancelled { + guard let self else { return } + if !self.isPaused { + await self.refreshNow() + } + let interval = self.refreshInterval.rawValue + guard interval > 0 else { break } + try? await Task.sleep(for: .seconds(interval)) + } + } + } + + func stopAutoRefresh() { + refreshTask?.cancel() + refreshTask = nil + isRefreshing = false + } + + // 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 + } + + // 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 } + + 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) + sessions.sort(using: sessionSortOrder) + } 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)") + actionError = 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)") + actionError = 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/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 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..4743fe43 --- /dev/null +++ b/TablePro/Views/ServerDashboard/DashboardToolbarView.swift @@ -0,0 +1,70 @@ +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) + } +} 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..183f2c5e --- /dev/null +++ b/TablePro/Views/ServerDashboard/ServerDashboardView.swift @@ -0,0 +1,71 @@ +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?")) + } + .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 new file mode 100644 index 00000000..a3ac3b44 --- /dev/null +++ b/TablePro/Views/ServerDashboard/SessionsTableView.swift @@ -0,0 +1,92 @@ +import SwiftUI + +struct SessionsTableView: View { + @Bindable var viewModel: ServerDashboardViewModel + @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: $viewModel.sessionSortOrder) { + 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: \.durationSeconds) { 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")) + .accessibilityLabel(String(localized: "Cancel query for session \(session.id)")) + } + 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")) + .accessibilityLabel(String(localized: "Terminate session \(session.id)")) + } + } + } + .width(60) + } + .onChange(of: viewModel.sessionSortOrder) { _, 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..5f31abb8 --- /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: 200) + } + } + } + } + + 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..616b05f4 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(String(localized: "Dashboard"), systemImage: "gauge.with.dots.needle.33percent") + } + .help(String(localized: "Server Dashboard")) + .disabled(state.connectionState != .connected || !(actions?.supportsServerDashboard ?? false)) + Button { actions?.toggleHistoryPanel() } label: { 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", diff --git a/docs/features/server-dashboard.mdx b/docs/features/server-dashboard.mdx new file mode 100644 index 00000000..ce29d5ff --- /dev/null +++ b/docs/features/server-dashboard.mdx @@ -0,0 +1,81 @@ +--- +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. + + + Server Dashboard + Server Dashboard + + +## 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.