diff --git a/apple/Packages/TableProCore/Sources/TableProRedisCore/RedisSlowLogGrid.swift b/apple/Packages/TableProCore/Sources/TableProRedisCore/RedisSlowLogGrid.swift new file mode 100644 index 0000000000..f207132635 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProRedisCore/RedisSlowLogGrid.swift @@ -0,0 +1,14 @@ +import Foundation + +// The driver builds this grid from a SLOWLOG GET reply and the Server Dashboard reads it back by column name. +public enum RedisSlowLogGrid { + public static let id = "ID" + public static let time = "Time" + public static let durationMicroseconds = "Duration (µs)" + public static let command = "Command" + public static let client = "Client" + public static let clientName = "Client Name" + + public static let columns = [id, time, durationMicroseconds, command, client, clientName] + public static let columnTypeNames = ["Int64", "Int64", "Int64", "String", "String", "String"] +} diff --git a/apple/Packages/TableProCore/Sources/TableProRedisCore/RedisTopologyDiagnostics.swift b/apple/Packages/TableProCore/Sources/TableProRedisCore/RedisTopologyDiagnostics.swift index f25a8b21ca..d4229945ef 100644 --- a/apple/Packages/TableProCore/Sources/TableProRedisCore/RedisTopologyDiagnostics.swift +++ b/apple/Packages/TableProCore/Sources/TableProRedisCore/RedisTopologyDiagnostics.swift @@ -14,7 +14,7 @@ public enum RedisServerMode: String, Sendable { } public enum RedisServerInfo { - static func value(_ key: String, in info: String) -> String? { + public static func value(_ key: String, in info: String) -> String? { for line in info.components(separatedBy: .newlines) { let trimmed = line.trimmingCharacters(in: .whitespaces) guard trimmed.hasPrefix("\(key):") else { continue } diff --git a/apple/Packages/TableProCore/Tests/TableProRedisCoreTests/RedisConnectionModeTests.swift b/apple/Packages/TableProCore/Tests/TableProRedisCoreTests/RedisConnectionModeTests.swift index 13a5a58248..e3739588c9 100644 --- a/apple/Packages/TableProCore/Tests/TableProRedisCoreTests/RedisConnectionModeTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProRedisCoreTests/RedisConnectionModeTests.swift @@ -111,6 +111,14 @@ struct RedisServerInfoTests { #expect(RedisServerInfo.mode(from: info) == .cluster) } + @Test("A field is read by its whole name, not by a longer one it begins") + func readsAFieldByItsWholeName() { + let info = "# Memory\r\nused_memory_human:1.70M\r\nused_memory:1778512\r\n" + #expect(RedisServerInfo.value("used_memory", in: info) == "1778512") + #expect(RedisServerInfo.value("used_memory_human", in: info) == "1.70M") + #expect(RedisServerInfo.value("used_memory_peak", in: info) == nil) + } + @Test("An absent key is absent, not a wrong answer") func missingKeyIsNil() { #expect(RedisServerInfo.mode(from: "# Server\r\n") == nil) diff --git a/apple/Plugins/RedisDriverPlugin/RedisCommandParser.swift b/apple/Plugins/RedisDriverPlugin/RedisCommandParser.swift index 8ba4d8751d..c08fdbf66e 100644 --- a/apple/Plugins/RedisDriverPlugin/RedisCommandParser.swift +++ b/apple/Plugins/RedisDriverPlugin/RedisCommandParser.swift @@ -64,6 +64,7 @@ enum RedisOperation { case select(database: Int) case configGet(parameters: [String]) case configSet(parameter: String, value: String) + case slowlogGet(count: Int?) case command(args: [RedisArgument]) // Multi @@ -74,6 +75,10 @@ enum RedisOperation { /// One command run on the database it names, after which the session goes back to the one it /// belongs on. indirect case inDatabase(database: Int, operation: RedisOperation) + + /// A server command the app sends on its own, held back rather than queued while the user's + /// MULTI block is open, the way the health probe is. + indirect case outsideBlock(RedisOperation) } /// Options for SET command @@ -160,6 +165,7 @@ struct RedisCommandParser { let command = first.text.uppercased() let args = Array(tokens.dropFirst()) if command == "DB" { return try parseInDatabase(args) } + if command == "OUTSIDEBLOCK" { return try parseOutsideBlock(args) } if let typedCount = typedArgumentCount[command], args.count > typedCount { return .command(args: tokens) @@ -206,6 +212,9 @@ struct RedisCommandParser { "MULTI", "EXEC", "DISCARD", "AUTH": return try parseServerCommand(command, args: args, tokens: tokens) + case "SLOWLOG": + return parseSlowLog(args, tokens: tokens) + case "KEYBROWSE": return try parseKeyBrowse(args) @@ -249,6 +258,26 @@ struct RedisCommandParser { ) } + /// Only INFO and SLOWLOG GET keep their own grids; anything else goes to the server as typed. + private static func parseOutsideBlock(_ args: [RedisArgument]) throws -> RedisOperation { + guard !args.isEmpty else { throw RedisParseError.emptySyntax } + switch try parse(tokens: args) { + case .info(let sections): + return .outsideBlock(.info(sections: sections)) + case .slowlogGet(let count): + return .outsideBlock(.slowlogGet(count: count)) + default: + return .outsideBlock(.command(args: args)) + } + } + + private static func parseSlowLog(_ args: [RedisArgument], tokens: [RedisArgument]) -> RedisOperation { + guard args.first?.text.uppercased() == "GET", args.count <= 2 else { return .command(args: tokens) } + guard args.count == 2 else { return .slowlogGet(count: nil) } + guard let count = Int(args[1].text) else { return .command(args: tokens) } + return .slowlogGet(count: count) + } + /// `DB` names the database the read reaches whichever database the session is on: a refresh, /// a later page and an export all read the database the row names rather than the one the /// session last moved to, and the key tree lists the database the sidebar shows. diff --git a/apple/Plugins/RedisDriverPlugin/RedisCommandRouting.swift b/apple/Plugins/RedisDriverPlugin/RedisCommandRouting.swift index f42e39b087..0d4e002611 100644 --- a/apple/Plugins/RedisDriverPlugin/RedisCommandRouting.swift +++ b/apple/Plugins/RedisDriverPlugin/RedisCommandRouting.swift @@ -308,6 +308,8 @@ struct RedisCommandRouting: Sendable { add(spec("memory", 0, 0, 0)) add(spec("memory|stats", 0, 0, 0, request: .allShards, response: .special)) add(spec("memory|usage", 2, 2, 1, readOnly: true)) + add(spec("slowlog", 0, 0, 0)) + add(spec("slowlog|get", 0, 0, 0, request: .allNodes)) return table }() } diff --git a/apple/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift b/apple/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift index 7523bcdd50..de2e746b55 100644 --- a/apple/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift +++ b/apple/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift @@ -69,13 +69,19 @@ extension RedisPluginDriver { case .xrange, .xlen: return try await executeStreamOperation(operation, connection: conn, startTime: startTime) - case .ping, .info, .dbsize, .flushdb, .select, .configGet, .configSet, .command, .multi, .exec, .discard: + case .ping, .info, .dbsize, .flushdb, .select, .configGet, .configSet, .slowlogGet, .command, + .multi, .exec, .discard: return try await executeServerOperation(operation, connection: conn, startTime: startTime) case .inDatabase(let database, let operation): return try await conn.withDatabase(database) { try await runOperation(operation, connection: conn, startTime: startTime) } + + case .outsideBlock(let operation): + return try await executeServerOperation( + operation, connection: conn, startTime: startTime, scope: .outsideBlock + ) } } @@ -473,7 +479,8 @@ extension RedisPluginDriver { func executeServerOperation( _ operation: RedisOperation, connection conn: any RedisCommandChannel, - startTime: Date + startTime: Date, + scope: RedisCommandScope = .session ) async throws -> PluginQueryResult { switch operation { case .ping: @@ -487,7 +494,7 @@ extension RedisPluginDriver { ) case .info(let sections): - let result = try await conn.run(["INFO"] + sections) + let result = try await conn.run(["INFO"] + sections, scope: scope) let infoText = result.stringValue ?? String(describing: result) return PluginQueryResult( columns: ["info"], @@ -524,8 +531,12 @@ extension RedisPluginDriver { try await conn.run(["CONFIG", "SET", parameter, value]) return buildStatusResult("OK", startTime: startTime) + case .slowlogGet(let count): + let result = try await conn.run(["SLOWLOG", "GET"] + (count.map { [String($0)] } ?? []), scope: scope) + return RedisReplyGrid.slowLog(result).queryResult(startTime: startTime) + case .command(let args): - let result = try await conn.run(args.asRedisArguments) + let result = try await conn.run(args.asRedisArguments, scope: scope) return RedisReplyGrid.generic(result).queryResult(startTime: startTime) case .multi: diff --git a/apple/Plugins/RedisDriverPlugin/RedisReplyGrid.swift b/apple/Plugins/RedisDriverPlugin/RedisReplyGrid.swift index 71e713090d..23a4d82c00 100644 --- a/apple/Plugins/RedisDriverPlugin/RedisReplyGrid.swift +++ b/apple/Plugins/RedisDriverPlugin/RedisReplyGrid.swift @@ -5,6 +5,7 @@ import Foundation import TableProPluginKit +import TableProRedisCore struct RedisReplyGrid: Equatable { let columns: [String] @@ -77,6 +78,14 @@ struct RedisReplyGrid: Equatable { ) } + static func slowLog(_ reply: RedisReply) -> RedisReplyGrid { + RedisReplyGrid( + columns: RedisSlowLogGrid.columns, + columnTypeNames: RedisSlowLogGrid.columnTypeNames, + rows: (reply.arrayValue ?? []).compactMap(slowLogRow) + ) + } + static func generic(_ reply: RedisReply) -> RedisReplyGrid { switch reply { case .integer(let value): @@ -104,6 +113,25 @@ struct RedisReplyGrid: Equatable { } } + // Redis before 4.0 sends four fields per entry, without the client's address and name. + private static func slowLogRow(_ entry: RedisReply) -> PluginRow? { + guard let fields = entry.arrayValue, fields.count >= 4 else { return nil } + let command = (fields[3].arrayValue ?? []).map(quotedArgument).joined(separator: " ") + return [ + .text(fields[0].displayText), + .text(fields[1].displayText), + .text(fields[2].displayText), + .text(command), + fields.count > 4 ? .text(fields[4].displayText) : .null, + fields.count > 5 ? .text(fields[5].displayText) : .null, + ] + } + + private static func quotedArgument(_ argument: RedisReply) -> String { + if case .data(let bytes) = argument { return RedisArgumentCodec.quote(bytes) } + return RedisArgumentCodec.quote(argument.displayText) + } + private static func streamRow(_ entry: RedisReply) -> PluginRow? { guard let parts = entry.arrayValue, parts.count >= 2, let fields = parts[1].arrayValue else { return nil diff --git a/apple/Plugins/RedisDriverTests/RedisCommandRoutingTests.swift b/apple/Plugins/RedisDriverTests/RedisCommandRoutingTests.swift index bd55682109..307640e11e 100644 --- a/apple/Plugins/RedisDriverTests/RedisCommandRoutingTests.swift +++ b/apple/Plugins/RedisDriverTests/RedisCommandRoutingTests.swift @@ -122,6 +122,14 @@ struct RedisCommandRoutingPolicyTests { #expect(routing.spec(for: args("INFO"))?.responsePolicy == .special) } + /// Redis 6 reports no tips, and without this entry a cluster's slow log came from one node. + @Test("SLOWLOG GET reaches every node and concatenates, while SLOWLOG LEN stays on one") + func slowLogGet() { + #expect(routing.spec(for: args("SLOWLOG", "GET", "20"))?.clusterFanOut == .everyNode) + #expect(routing.spec(for: args("SLOWLOG", "GET"))?.responsePolicy == nil) + #expect(routing.spec(for: args("SLOWLOG", "LEN"))?.clusterFanOut == .single) + } + @Test("DEL and MGET are multi-shard") func multiShard() { #expect(routing.spec(for: args("DEL", "a"))?.requestPolicy == .multiShard) diff --git a/apple/Plugins/RedisDriverTests/RedisDashboardCommandTests.swift b/apple/Plugins/RedisDriverTests/RedisDashboardCommandTests.swift new file mode 100644 index 0000000000..fbf97ab211 --- /dev/null +++ b/apple/Plugins/RedisDriverTests/RedisDashboardCommandTests.swift @@ -0,0 +1,142 @@ +// +// RedisDashboardCommandTests.swift +// TableProTests +// +// The Server Dashboard reads SLOWLOG GET as a grid and sends every command as OUTSIDEBLOCK, so a +// refresh is held back while the user has a MULTI block open instead of joining it. +// + +import Foundation +import TableProPluginKit +import TableProRedisCore +import Testing + +struct RedisSlowLogParseTests { + @Test("SLOWLOG GET, with or without a count, is typed", arguments: [ + ("SLOWLOG GET", nil), + ("slowlog get 20", 20), + ] as [(String, Int?)]) + func slowLogGetIsTyped(statement: String, count: Int?) throws { + guard case .slowlogGet(let parsed) = try RedisCommandParser.parse(statement) else { + Issue.record("Expected .slowlogGet for \(statement)") + return + } + #expect(parsed == count) + } + + @Test("Any other SLOWLOG form goes to the server as typed", arguments: [ + "SLOWLOG LEN", "SLOWLOG RESET", "SLOWLOG GET abc", "SLOWLOG GET 1 2", "SLOWLOG", + ]) + func otherFormsAreVerbatim(statement: String) throws { + guard case .command = try RedisCommandParser.parse(statement) else { + Issue.record("Expected .command for \(statement)") + return + } + } +} + +struct RedisOutsideBlockParseTests { + @Test("OUTSIDEBLOCK keeps the grid of INFO and SLOWLOG GET") + func keepsTypedServerGrids() throws { + guard case .outsideBlock(.info(let sections)) = try RedisCommandParser.parse("OUTSIDEBLOCK INFO") else { + Issue.record("Expected INFO outside the block") + return + } + #expect(sections.isEmpty) + guard case .outsideBlock(.slowlogGet(let count)) = try RedisCommandParser.parse("OUTSIDEBLOCK SLOWLOG GET 20") else { + Issue.record("Expected SLOWLOG GET outside the block") + return + } + #expect(count == 20) + } + + @Test("Any other command is sent as typed", arguments: [ + "OUTSIDEBLOCK CLIENT LIST", "OUTSIDEBLOCK CLIENT KILL ID 21", "outsideblock GET key", + ]) + func otherCommandsAreVerbatim(statement: String) throws { + guard case .outsideBlock(.command(let args)) = try RedisCommandParser.parse(statement) else { + Issue.record("Expected a verbatim command outside the block for \(statement)") + return + } + #expect(args.map { $0.text } == Array(statement.split(separator: " ").dropFirst().map(String.init))) + } + + @Test("OUTSIDEBLOCK with nothing after it is refused") + func emptyIsRefused() { + #expect(throws: RedisParseError.self) { try RedisCommandParser.parse("OUTSIDEBLOCK") } + } +} + +struct RedisSlowLogGridTests { + @Test("An entry becomes one row, its arguments quoted the way the console reads them") + func entryBecomesARow() { + let reply = RedisReply.array([ + .array([ + .integer(9), .integer(1_792_537_207), .integer(626), + .array([.string("SET"), .string("foo"), .string("a b,c]")]), + .string("127.0.0.1:59713"), .string("worker"), .integer(3), + ]), + ]) + let grid = RedisReplyGrid.slowLog(reply) + #expect(grid.columns == RedisSlowLogGrid.columns) + #expect(grid.columnTypeNames.count == grid.columns.count) + #expect(grid.rows == [[ + .text("9"), .text("1792537207"), .text("626"), .text(#"SET foo "a b,c]""#), + .text("127.0.0.1:59713"), .text("worker"), + ]]) + } + + @Test("Redis before 4.0 sends no client, and a malformed entry is skipped") + func olderAndMalformedEntries() { + let reply = RedisReply.array([ + .array([.integer(1), .integer(1_500_000_000), .integer(20_000), .array([.string("KEYS"), .string("*")])]), + .array([.integer(2)]), + .string("not an entry"), + ]) + #expect(RedisReplyGrid.slowLog(reply).rows == [[ + .text("1"), .text("1500000000"), .text("20000"), .text("KEYS *"), .null, .null, + ]]) + } +} + +struct RedisOutsideBlockExecutionTests { + private func driver() -> RedisPluginDriver { + RedisPluginDriver(config: DriverConnectionConfig( + host: "127.0.0.1", port: 6_379, username: "", password: "", database: "0" + )) + } + + @Test("A dashboard command runs outside the block scope") + func runsOutsideTheBlock() async throws { + let channel = StubRedisChannel([.string("id=21 addr=10.0.0.4:51022 cmd=get")]) + let result = try await driver().executeOperation( + try RedisCommandParser.parse("OUTSIDEBLOCK CLIENT LIST"), connection: channel, startTime: Date() + ) + #expect(channel.sentCommands == [["CLIENT", "LIST"]]) + #expect(channel.sentScopes == [.outsideBlock]) + #expect(result.rows.first?.first?.asText?.hasPrefix("id=21") == true) + } + + @Test("With the user's MULTI block open the command is held back, never queued into it") + func heldBackWhileTheBlockIsOpen() async throws { + let channel = StubRedisChannel([]) + channel.observeOpenBlock() + await #expect(throws: RedisHeldBackCommand(command: "INFO", held: .openBlock)) { + _ = try await driver().executeOperation( + try RedisCommandParser.parse("OUTSIDEBLOCK INFO"), connection: channel, startTime: Date() + ) + } + #expect(channel.sentCommands.isEmpty) + } + + @Test("The same command typed by the user still joins the user's block") + func typedCommandStillQueues() async throws { + let channel = StubRedisChannel([.status("QUEUED")]) + channel.observeOpenBlock() + let result = try await driver().executeOperation( + try RedisCommandParser.parse("SLOWLOG GET 5"), connection: channel, startTime: Date() + ) + #expect(channel.sentScopes == [.session]) + #expect(result.rows.first?.first?.asText == "QUEUED") + } +} diff --git a/apple/TablePro/Core/Database/DatabaseDriver.swift b/apple/TablePro/Core/Database/DatabaseDriver.swift index a4f391af4d..6fee7e17fb 100644 --- a/apple/TablePro/Core/Database/DatabaseDriver.swift +++ b/apple/TablePro/Core/Database/DatabaseDriver.swift @@ -101,6 +101,11 @@ protocol DatabaseDriver: AnyObject, Sendable { /// rest of the fetch, which for some drivers cancels the statement on the server. func executeBoundedQuery(query: String, rowCap: Int) async throws -> QueryResult? + /// Runs a statement the app wrote to read, telling the driver it may only read. A driver that + /// evaluates statements in a scripting engine with state refuses any write the script reaches, + /// whatever an earlier statement redefined. + func executeRead(query: String) async throws -> QueryResult + /// Whether ``executeBatch(query:rowCap:parameters:)`` sends a text to the server whole and answers with every /// result set it produced. var supportsResultSetBatches: Bool { get } @@ -473,6 +478,10 @@ extension DatabaseDriver { func executeBoundedQuery(query: String, rowCap: Int) async throws -> QueryResult? { nil } + func executeRead(query: String) async throws -> QueryResult { + try await execute(query: query) + } + var supportsResultSetBatches: Bool { false } func executeBatch(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryBatchResult? { nil } diff --git a/apple/TablePro/Core/MCP/MCPConnectionBridge+Server.swift b/apple/TablePro/Core/MCP/MCPConnectionBridge+Server.swift index e900034aed..0985208d9e 100644 --- a/apple/TablePro/Core/MCP/MCPConnectionBridge+Server.swift +++ b/apple/TablePro/Core/MCP/MCPConnectionBridge+Server.swift @@ -104,7 +104,7 @@ extension MCPConnectionBridge { ) } let execute: (String) async throws -> QueryResult = { sql in - try await driver.execute(query: sql) + try await driver.executeRead(query: sql) } var result: [String: JsonValue] = [:] var failures: [String: String] = [:] diff --git a/apple/TablePro/Core/MCP/Protocol/Tools/ServerTools.swift b/apple/TablePro/Core/MCP/Protocol/Tools/ServerTools.swift index e6681e220d..d96714e141 100644 --- a/apple/TablePro/Core/MCP/Protocol/Tools/ServerTools.swift +++ b/apple/TablePro/Core/MCP/Protocol/Tools/ServerTools.swift @@ -7,7 +7,7 @@ public struct ServerDashboardTool: MCPToolImplementation { public static let description = String( localized: """ Read the live server panels TablePro shows: active sessions, server metrics, and slow queries. \ - Available on PostgreSQL, MySQL, SQL Server, ClickHouse, and Typesense. + Available on every engine that has a Server Dashboard in TablePro. """ ) public static let requiredScopes: Set = [.toolsRead] diff --git a/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift b/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift index 935a842191..887a34829c 100644 --- a/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/apple/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -383,6 +383,13 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor return mapQueryResult(pluginResult) } + func executeRead(query: String) async throws -> QueryResult { + try StatementTextValidator.validate(query) + var context = PluginStatementContext() + context.readOnly = true + return mapQueryResult(try await pluginDriver.execute(query: query, context: context)) + } + func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult { try StatementTextValidator.validate(query) let cellParams: [PluginCellValue] = parameters.map(Self.cellValue(for:)) diff --git a/apple/TablePro/Core/ServerDashboard/Providers/MongoDBDashboardProvider.swift b/apple/TablePro/Core/ServerDashboard/Providers/MongoDBDashboardProvider.swift new file mode 100644 index 0000000000..145c43de25 --- /dev/null +++ b/apple/TablePro/Core/ServerDashboard/Providers/MongoDBDashboardProvider.swift @@ -0,0 +1,247 @@ +// +// MongoDBDashboardProvider.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// Each panel is a shell statement run read-only in the user's shell, whose globals an earlier +/// statement may have redefined, so it calls only commands the driver counts as reads and declares +/// no global. It returns documents of strings; `command` stays a document so the grid renders it. +struct MongoDBDashboardProvider: ServerDashboardQueryProvider { + private static let queryTextLimit = 1_000 + + let supportedPanels: Set = [.activeSessions, .serverMetrics, .slowQueries] + + /// Drops the session and routing fields a driver adds to every command. `$truncated` is what + /// `currentOp` leaves of a command over 1 KB, so it stays. + private static let commandCleanup = """ + function shown(command) { + if (!command) { return undefined; } + var kept = {}; + for (var key in command) { + var added = key.charAt(0) === "$" && key !== "$truncated"; + if (!added && key !== "lsid" && key !== "comment") { kept[key] = command[key]; } + } + return kept; + } + function text(value) { return value === undefined || value === null ? "" : String(value); } + """ + + /// Without `inprog` the server answers Unauthorized (13) and shows only the account's own + /// operations. Server threads carry no client address; an awaitable `hello` is driver monitoring. + static let sessionsStatement = """ + (function () { + \(commandCleanup) + function operations(ownOnly) { + var request = { + currentOp: 1, + $all: true, + "command.currentOp": { $exists: false }, + "command.maxAwaitTimeMS": { $exists: false }, + $or: [{ client: { $exists: true } }, { client_s: { $exists: true } }] + }; + if (ownOnly) { request.$ownOps = true; } + return db.adminCommand(request).inprog || []; + } + var found; + try { + found = operations(false); + } catch (error) { + if (error.code !== 13) { throw error; } + found = operations(true); + } + return found.map(function (op) { + var users = op.effectiveUsers || []; + return { + opid: text(op.opid), + connection: text(op.desc), + user: users.length > 0 ? text(users[0].user) : "", + ns: text(op.ns), + active: op.active ? "true" : "false", + secs: text(op.secs_running), + command: shown(op.command) + }; + }); + })() + """ + + static let metricsStatement = """ + (function () { + \(commandCleanup) + var status = db.adminCommand({ serverStatus: 1 }); + var connections = status.connections || {}; + var counters = status.opcounters || {}; + var memory = status.mem || {}; + return [{ + connections: text(connections.current), + available: text(connections.available), + uptime: text(Math.floor(Number(status.uptime))), + resident: text(memory.resident), + query: text(counters.query), + insert: text(counters.insert), + update: text(counters.update), + remove: text(counters["delete"]), + command: text(counters.command) + }]; + })() + """ + + /// The profiler writes to each database's own `system.profile`: this reads the one being browsed. + static let slowQueriesStatement = """ + (function () { + \(commandCleanup) + return db.getCollection("system.profile") + .find() + .sort({ ts: -1 }) + .limit(20) + .toArray() + .map(function (entry) { + return { + millis: text(entry.millis), + ns: text(entry.ns), + user: text(entry.user), + command: shown(entry.command) + }; + }); + })() + """ + + func fetchSessions(execute: (String) async throws -> QueryResult) async throws -> [DashboardSession] { + let result = try await execute(Self.sessionsStatement) + let col = columnIndex(from: result.columns) + return result.rows.compactMap { row in + let opid = value(row, at: col["opid"]) + let id = opid.isEmpty ? value(row, at: col["connection"]) : opid + guard !id.isEmpty else { return nil } + let isActive = value(row, at: col["active"]) == "true" + let secs = Int(value(row, at: col["secs"])) ?? 0 + return DashboardSession( + id: id, + user: value(row, at: col["user"]), + database: Self.database(ofNamespace: value(row, at: col["ns"])), + state: isActive ? "active" : "idle", + durationSeconds: secs, + duration: isActive ? DurationFormatting.string(seconds: secs) : "", + query: String(value(row, at: col["command"]).prefix(Self.queryTextLimit)), + canKill: false, + canCancel: isActive && !opid.isEmpty + ) + } + } + + func fetchMetrics(execute: (String) async throws -> QueryResult) async throws -> [DashboardMetric] { + let result = try await execute(Self.metricsStatement) + guard let row = result.rows.first else { return [] } + let col = columnIndex(from: result.columns) + func field(_ name: String) -> String { value(row, at: col[name]) } + + var metrics: [DashboardMetric] = [] + if !field("connections").isEmpty { + metrics.append(DashboardMetric( + id: "connections", + label: String(localized: "Connections"), + value: field("connections"), + unit: field("available").isEmpty + ? "" + : String(format: String(localized: "%@ available"), field("available")), + icon: "person.2" + )) + } + if let secs = Int(field("uptime")) { + metrics.append(DashboardMetric( + id: "uptime", + label: String(localized: "Uptime"), + value: DurationFormatting.string(seconds: secs), + unit: "", + icon: "clock" + )) + } + if let mebibytes = Int64(field("resident")) { + metrics.append(DashboardMetric( + id: "resident_memory", + label: String(localized: "Resident Memory"), + value: ByteSizeFormatting.string(bytes: mebibytes * 1_048_576), + unit: "", + icon: "memorychip" + )) + } + let counters: [(id: String, field: String, label: String, icon: String)] = [ + ("queries", "query", String(localized: "Queries"), "magnifyingglass"), + ("inserts", "insert", String(localized: "Inserts"), "plus.square"), + ("updates", "update", String(localized: "Updates"), "pencil"), + ("deletes", "remove", String(localized: "Deletes"), "trash"), + ("commands", "command", String(localized: "Commands"), "terminal"), + ] + for counter in counters where !field(counter.field).isEmpty { + metrics.append(DashboardMetric( + id: counter.id, + label: counter.label, + value: field(counter.field), + unit: "", + icon: counter.icon + )) + } + return metrics + } + + func fetchSlowQueries(execute: (String) async throws -> QueryResult) async throws -> [DashboardSlowQuery] { + let result = try await execute(Self.slowQueriesStatement) + let col = columnIndex(from: result.columns) + return result.rows.map { row in + let millis = Int(value(row, at: col["millis"])) ?? 0 + return DashboardSlowQuery( + duration: DurationFormatting.string(microseconds: millis * 1_000), + query: String(value(row, at: col["command"]).prefix(Self.queryTextLimit)), + user: value(row, at: col["user"]), + database: Self.database(ofNamespace: value(row, at: col["ns"])) + ) + } + } + + /// `killOp` stops an operation and leaves its connection open, which is Cancel Query. MongoDB has + /// no command that closes another client's connection. + func cancelQuerySQL(processId: String) -> String? { + guard acceptsProcessId(processId) else { return nil } + if Int(processId) != nil { + return "db.adminCommand({ killOp: 1, op: \(processId) })" + } + return "db.adminCommand({ killOp: 1, op: \"\(processId)\" })" + } + + /// A number on a `mongod`; `shard:number` on a `mongos`, where shard names keep to letters, + /// digits, `-`, `_` and `.`. + func acceptsProcessId(_ processId: String) -> Bool { + if Self.isDigits(processId[...]) { return true } + let parts = processId.split(separator: ":", omittingEmptySubsequences: false) + guard parts.count == 2, Self.isDigits(parts[1]), !parts[0].isEmpty else { return false } + return parts[0].allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber || "-_.".contains($0)) } + } + + static func database(ofNamespace namespace: String) -> String { + guard let dot = namespace.firstIndex(of: ".") else { return namespace } + return String(namespace[.. Bool { + !text.isEmpty && text.allSatisfy { $0.isASCII && $0.isNumber } + } +} + +// MARK: - Helpers + +private extension MongoDBDashboardProvider { + 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: [PluginCellValue], at index: Int?) -> String { + guard let index, index < row.count else { return "" } + return row[index].asText ?? "" + } +} diff --git a/apple/TablePro/Core/ServerDashboard/Providers/OracleDashboardProvider.swift b/apple/TablePro/Core/ServerDashboard/Providers/OracleDashboardProvider.swift new file mode 100644 index 0000000000..ae9c3c5f95 --- /dev/null +++ b/apple/TablePro/Core/ServerDashboard/Providers/OracleDashboardProvider.swift @@ -0,0 +1,217 @@ +// +// OracleDashboardProvider.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit + +/// Dynamic performance views are named `SYS.V_$...`, because the `V$` public synonyms resolve after +/// an object of the same name in the session's current schema. +struct OracleDashboardProvider: ServerDashboardQueryProvider { + private static let logger = Logger(subsystem: "com.TablePro", category: "OracleDashboardProvider") + + let supportedPanels: Set = [.activeSessions, .serverMetrics, .slowQueries] + + /// `ALTER SYSTEM CANCEL SQL` arrived in 18c. + let hasCancelSQL: Bool + + init(serverVersion: String?) { + hasCancelSQL = Self.release(from: serverVersion).map { $0 >= 18 } ?? true + } + + /// 23.26 calls itself "Oracle AI Database 26ai Free Release 23.26.3.0.0": the release number is + /// the one after `Release`, not the marketing name. + static func release(from banner: String?) -> Int? { + guard let banner, let range = banner.range(of: "Release ") else { return nil } + let digits = banner[range.upperBound...].prefix { $0.isASCII && $0.isNumber } + return Int(digits) + } + + static let sessionsQuery = """ + SELECT s.SID || ',' || s.SERIAL# AS SESSION_ID, s.USERNAME, s.SCHEMANAME, s.STATUS, s.LAST_CALL_ET, + NVL( + (SELECT q.SQL_TEXT FROM SYS.V_$SQL q + WHERE q.SQL_ID = s.SQL_ID AND q.CHILD_NUMBER = s.SQL_CHILD_NUMBER AND ROWNUM = 1), + (SELECT q.SQL_TEXT FROM SYS.V_$SQL q + WHERE q.SQL_ID = s.PREV_SQL_ID AND q.CHILD_NUMBER = s.PREV_CHILD_NUMBER AND ROWNUM = 1) + ) AS SQL_TEXT + FROM SYS.V_$SESSION s + WHERE s.TYPE = 'USER' AND s.SID <> SYS_CONTEXT('USERENV', 'SID') + ORDER BY s.LAST_CALL_ET DESC + """ + + static let slowQueriesQuery = """ + SELECT s.LAST_CALL_ET, s.USERNAME, s.SCHEMANAME, + (SELECT q.SQL_TEXT FROM SYS.V_$SQL q + WHERE q.SQL_ID = s.SQL_ID AND q.CHILD_NUMBER = s.SQL_CHILD_NUMBER AND ROWNUM = 1) AS SQL_TEXT + FROM SYS.V_$SESSION s + WHERE s.TYPE = 'USER' AND s.STATUS = 'ACTIVE' AND s.LAST_CALL_ET >= 1 + AND s.SID <> SYS_CONTEXT('USERENV', 'SID') + ORDER BY s.LAST_CALL_ET DESC + """ + + static let sessionCountQuery = "SELECT COUNT(*) FROM SYS.V_$SESSION WHERE TYPE = 'USER'" + + static let uptimeQuery = "SELECT FLOOR((SYSDATE - STARTUP_TIME) * 86400) FROM SYS.V_$INSTANCE" + + static let rateMetricNames = ["CPU Usage Per Sec", "Logical Reads Per Sec"] + + /// The instance-wide view holds a row per metric in the root container and in a non-CDB, and none + /// in a pluggable database, which reports its own figures in `V$CON_SYSMETRIC` (12.2 and later). + static let rateQueries: [String] = { + let names = rateMetricNames.map { "'\($0)'" }.joined(separator: ", ") + let select = "SELECT METRIC_NAME, TO_CHAR(ROUND(VALUE, 1), 'FM999999999990.0') AS VALUE" + return [ + "\(select) FROM SYS.V_$SYSMETRIC WHERE GROUP_ID = 2 AND METRIC_NAME IN (\(names))", + "\(select) FROM SYS.V_$CON_SYSMETRIC WHERE METRIC_NAME IN (\(names))", + ] + }() + + func fetchSessions(execute: (String) async throws -> QueryResult) async throws -> [DashboardSession] { + let result = try await execute(Self.sessionsQuery) + let col = columnIndex(from: result.columns) + return result.rows.map { row in + let secs = Int(value(row, at: col["last_call_et"])) ?? 0 + return DashboardSession( + id: value(row, at: col["session_id"]), + user: value(row, at: col["username"]), + database: value(row, at: col["schemaname"]), + state: value(row, at: col["status"]), + durationSeconds: secs, + duration: DurationFormatting.string(seconds: secs), + query: value(row, at: col["sql_text"]) + ) + } + } + + func fetchMetrics(execute: (String) async throws -> QueryResult) async throws -> [DashboardMetric] { + var metrics: [DashboardMetric] = [] + var firstFailure: Error? + + func attempt(_ id: String, _ body: () async throws -> Void) async throws { + do { + try await body() + } catch is CancellationError { + throw CancellationError() + } catch { + Self.logger.warning("Metric \(id, privacy: .public) failed: \(error.publicLogShape, privacy: .public)") + if firstFailure == nil { firstFailure = error } + } + } + + try await attempt("sessions") { + let result = try await execute(Self.sessionCountQuery) + guard let row = result.rows.first else { return } + metrics.append(DashboardMetric( + id: "sessions", + label: String(localized: "User Sessions"), + value: value(row, at: 0), + unit: "", + icon: "person.2" + )) + } + + try await attempt("rates") { + let rates = try await rateValues(execute: execute) + if let cpu = rates["CPU Usage Per Sec"] { + metrics.append(DashboardMetric( + id: "cpu", + label: String(localized: "Database CPU"), + value: cpu, + unit: "%", + icon: "cpu" + )) + } + if let reads = rates["Logical Reads Per Sec"] { + metrics.append(DashboardMetric( + id: "logical_reads", + label: String(localized: "Logical Reads"), + value: reads, + unit: String(localized: "per second"), + icon: "doc.text.magnifyingglass" + )) + } + } + + try await attempt("uptime") { + let result = try await execute(Self.uptimeQuery) + guard let row = result.rows.first, let secs = Int(value(row, at: 0)) else { return } + metrics.append(DashboardMetric( + id: "uptime", + label: String(localized: "Uptime"), + value: DurationFormatting.string(seconds: secs), + unit: "", + icon: "clock" + )) + } + + if metrics.isEmpty, let firstFailure { + throw firstFailure + } + return metrics + } + + func fetchSlowQueries(execute: (String) async throws -> QueryResult) async throws -> [DashboardSlowQuery] { + let result = try await execute(Self.slowQueriesQuery) + let col = columnIndex(from: result.columns) + return result.rows.map { row in + let secs = Int(value(row, at: col["last_call_et"])) ?? 0 + return DashboardSlowQuery( + duration: DurationFormatting.string(seconds: secs), + query: value(row, at: col["sql_text"]), + user: value(row, at: col["username"]), + database: value(row, at: col["schemaname"]) + ) + } + } + + func killSessionSQL(processId: String) -> String? { + guard acceptsProcessId(processId) else { return nil } + return "ALTER SYSTEM KILL SESSION '\(processId)' IMMEDIATE" + } + + func cancelQuerySQL(processId: String) -> String? { + guard hasCancelSQL, acceptsProcessId(processId) else { return nil } + return "ALTER SYSTEM CANCEL SQL '\(processId)'" + } + + /// `sid,serial#`: the serial number is what keeps a reused SID from naming the next session. + func acceptsProcessId(_ processId: String) -> Bool { + let parts = processId.split(separator: ",", omittingEmptySubsequences: false) + return parts.count == 2 && parts.allSatisfy { part in + !part.isEmpty && part.allSatisfy { $0.isASCII && $0.isNumber } + } + } +} + +// MARK: - Helpers + +private extension OracleDashboardProvider { + func rateValues(execute: (String) async throws -> QueryResult) async throws -> [String: String] { + var rates: [String: String] = [:] + for query in Self.rateQueries { + let result = try await execute(query) + for row in result.rows { + let name = value(row, at: 0) + if rates[name] == nil { rates[name] = value(row, at: 1) } + } + if !rates.isEmpty { break } + } + return rates + } + + 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: [PluginCellValue], at index: Int?) -> String { + guard let index, index < row.count else { return "" } + return row[index].asText ?? "" + } +} diff --git a/apple/TablePro/Core/ServerDashboard/Providers/RedisDashboardProvider.swift b/apple/TablePro/Core/ServerDashboard/Providers/RedisDashboardProvider.swift new file mode 100644 index 0000000000..df7726df38 --- /dev/null +++ b/apple/TablePro/Core/ServerDashboard/Providers/RedisDashboardProvider.swift @@ -0,0 +1,194 @@ +// +// RedisDashboardProvider.swift +// TablePro +// + +import Foundation +import TableProPluginKit +import TableProRedisCore + +/// Every command goes out as `OUTSIDEBLOCK`, held back instead of queued while the user has a MULTI +/// block open: queued, the reads would land in the user's EXEC, and a refused one would abort it. +/// On a cluster every command but SLOWLOG GET reaches the same primary, so the panels describe it. +struct RedisDashboardProvider: ServerDashboardQueryProvider { + static let slowLogLength = 20 + private static let queryTextLimit = 1_000 + + let supportedPanels: Set = [.activeSessions, .serverMetrics, .slowQueries] + + /// CLIENT UNBLOCK arrived in Redis 5.0. + let hasClientUnblock: Bool + + init(serverVersion: String?) { + let major = serverVersion?.split(separator: ".").first.flatMap { Int($0) } + hasClientUnblock = major.map { $0 >= 5 } ?? true + } + + func fetchSessions(execute: (String) async throws -> QueryResult) async throws -> [DashboardSession] { + // CLIENT ID arrived in Redis 5.0; before that the dashboard's own connection stays listed. + let ownId = try? await firstCell(of: execute("OUTSIDEBLOCK CLIENT ID")) + let listing = try await firstCell(of: execute("OUTSIDEBLOCK CLIENT LIST")) + return Self.clients(in: listing).compactMap { client in + guard let id = client["id"], !id.isEmpty, id != ownId else { return nil } + let idle = Int(client["idle"] ?? "") ?? 0 + let state = Self.state(flags: client["flags"] ?? "", idleSeconds: idle) + return DashboardSession( + id: id, + user: client["user"] ?? "", + database: client["db"].map { "db\($0)" } ?? "", + state: state, + durationSeconds: idle, + duration: DurationFormatting.string(seconds: idle), + query: client["cmd"] ?? "", + canCancel: hasClientUnblock && state == Self.blockedState + ) + } + } + + func fetchMetrics(execute: (String) async throws -> QueryResult) async throws -> [DashboardMetric] { + let info = try await firstCell(of: execute("OUTSIDEBLOCK INFO")) + func field(_ key: String) -> String? { RedisServerInfo.value(key, in: info) } + + var metrics: [DashboardMetric] = [] + if let clients = field("connected_clients") { + metrics.append(DashboardMetric( + id: "connected_clients", + label: String(localized: "Connected Clients"), + value: clients, + unit: "", + icon: "person.2" + )) + } + if let used = field("used_memory").flatMap(Int64.init) { + let limit = field("maxmemory").flatMap(Int64.init) ?? 0 + metrics.append(DashboardMetric( + id: "used_memory", + label: String(localized: "Used Memory"), + value: ByteSizeFormatting.string(bytes: used), + unit: limit > 0 ? Self.share(used, of: limit) : "", + icon: "memorychip" + )) + } + if let operations = field("instantaneous_ops_per_sec") { + metrics.append(DashboardMetric( + id: "operations", + label: String(localized: "Operations"), + value: operations, + unit: String(localized: "per second"), + icon: "arrow.left.arrow.right" + )) + } + if let hits = field("keyspace_hits").flatMap(Double.init), + let misses = field("keyspace_misses").flatMap(Double.init) { + let lookups = hits + misses + metrics.append(DashboardMetric( + id: "keyspace_hit_ratio", + label: String(localized: "Keyspace Hit Ratio"), + value: lookups > 0 ? String(format: "%.1f", hits / lookups * 100) : "0", + unit: "%", + icon: "bolt" + )) + } + if let uptime = field("uptime_in_seconds").flatMap(Int.init) { + metrics.append(DashboardMetric( + id: "uptime", + label: String(localized: "Uptime"), + value: DurationFormatting.string(seconds: uptime), + unit: "", + icon: "clock" + )) + } + return metrics + } + + func fetchSlowQueries(execute: (String) async throws -> QueryResult) async throws -> [DashboardSlowQuery] { + let result = try await execute("OUTSIDEBLOCK SLOWLOG GET \(Self.slowLogLength)") + let col = columnIndex(from: result.columns) + let entries = result.rows.map { row in + ( + time: Int(value(row, at: col[RedisSlowLogGrid.time.lowercased()])) ?? 0, + microseconds: Int(value(row, at: col[RedisSlowLogGrid.durationMicroseconds.lowercased()])) ?? 0, + command: value(row, at: col[RedisSlowLogGrid.command.lowercased()]), + client: value(row, at: col[RedisSlowLogGrid.client.lowercased()]) + ) + } + // A cluster answers with each node's log in turn, so the newest across them are picked here. + return entries + .sorted { $0.time > $1.time } + .prefix(Self.slowLogLength) + .map { entry in + DashboardSlowQuery( + duration: DurationFormatting.string(microseconds: entry.microseconds), + query: String(entry.command.prefix(Self.queryTextLimit)), + user: entry.client, + database: "" + ) + } + } + + func killSessionSQL(processId: String) -> String? { + guard acceptsProcessId(processId) else { return nil } + return "OUTSIDEBLOCK CLIENT KILL ID \(processId)" + } + + /// Only a client blocked in BLPOP, XREAD or the like has a command still running. ERROR ends the + /// wait with an error reply and leaves the connection open. + func cancelQuerySQL(processId: String) -> String? { + guard hasClientUnblock, acceptsProcessId(processId) else { return nil } + return "OUTSIDEBLOCK CLIENT UNBLOCK \(processId) ERROR" + } + + func acceptsProcessId(_ processId: String) -> Bool { + !processId.isEmpty && processId.allSatisfy { $0.isASCII && $0.isNumber } + } + + /// One `key=value` field per space, one client per line. Redis refuses a space in a client name. + static func clients(in listing: String) -> [[String: String]] { + listing.split(whereSeparator: \.isNewline).map { line in + var fields: [String: String] = [:] + for pair in line.split(separator: " ") { + let parts = pair.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2 else { continue } + fields[String(parts[0])] = String(parts[1]) + } + return fields + } + } + + static let blockedState = "blocked" + + static func state(flags: String, idleSeconds: Int) -> String { + if flags.contains("b") { return blockedState } + if flags.contains("x") { return "multi" } + if flags.contains("P") { return "pubsub" } + if flags.contains("S") { return "replica" } + if flags.contains("M") { return "master" } + return idleSeconds > 0 ? "idle" : "active" + } + + private static func share(_ used: Int64, of limit: Int64) -> String { + let share = (Double(used) / Double(limit)).formatted(.percent.precision(.fractionLength(0))) + return String(format: String(localized: "%@ used"), share) + } +} + +// MARK: - Helpers + +private extension RedisDashboardProvider { + func firstCell(of result: QueryResult) -> String { + result.rows.first?.first?.asText ?? "" + } + + 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: [PluginCellValue], at index: Int?) -> String { + guard let index, index < row.count else { return "" } + return row[index].asText ?? "" + } +} diff --git a/apple/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift b/apple/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift index 2c27c5fe2d..e79f232f0e 100644 --- a/apple/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift +++ b/apple/TablePro/Core/ServerDashboard/ServerDashboardQueryProviderFactory.swift @@ -42,6 +42,12 @@ enum ServerDashboardQueryProviderFactory { return ClickHouseDashboardProvider(serverVersion: serverVersion) case .typesense: return TypesenseDashboardProvider() + case .oracle: + return OracleDashboardProvider(serverVersion: serverVersion) + case .mongodb: + return MongoDBDashboardProvider() + case .redis: + return RedisDashboardProvider(serverVersion: serverVersion) } } } @@ -55,6 +61,9 @@ private enum DashboardEngine { case mssql case clickhouse case typesense + case oracle + case mongodb + case redis init?(_ databaseType: DatabaseType) { switch databaseType { @@ -72,6 +81,12 @@ private enum DashboardEngine { self = .clickhouse case .typesense: self = .typesense + case .oracle: + self = .oracle + case .mongodb: + self = .mongodb + case .redis: + self = .redis default: return nil } diff --git a/apple/TablePro/Core/Services/Formatting/ByteSizeFormatting.swift b/apple/TablePro/Core/Services/Formatting/ByteSizeFormatting.swift index a953653c2e..ed6a99141d 100644 --- a/apple/TablePro/Core/Services/Formatting/ByteSizeFormatting.swift +++ b/apple/TablePro/Core/Services/Formatting/ByteSizeFormatting.swift @@ -55,4 +55,17 @@ internal enum DurationFormatting { internal static func string(seconds: Double) -> String { string(seconds: Int(seconds.rounded())) } + + // Slow log entries mostly finish within a second, which the whole-second form shows as 0s. + internal static func string(microseconds: Int) -> String { + let microseconds = max(0, microseconds) + let milliseconds = (microseconds + 500) / 1_000 + if milliseconds >= 1_000 { + return string(seconds: Double(microseconds) / 1_000_000) + } + if milliseconds >= 1 { + return Duration.milliseconds(milliseconds).formatted(.units(allowed: [.milliseconds], width: .narrow)) + } + return Duration.microseconds(microseconds).formatted(.units(allowed: [.microseconds], width: .narrow)) + } } diff --git a/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift index b04a26a674..597a46c59d 100644 --- a/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/apple/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -743,7 +743,7 @@ private extension QueryClassifier { guard let arguments = RedisArgumentCodec.split(command) else { return QueryClassification(tier: .write, reachesFilesystemOrExecutesCode: false) } - let statement = redisCommandPastDatabasePrefix(arguments.map { String(bytes: $0, encoding: .utf8) ?? "" }) + let statement = redisCommandPastPrefixes(arguments.map { String(bytes: $0, encoding: .utf8) ?? "" }) guard let command = statement.first?.uppercased() else { return .safe } let touchesUnsafeSurface = redisCodeExecutionCommands.contains(command) @@ -768,14 +768,20 @@ private extension QueryClassifier { return QueryClassification(tier: .write, reachesFilesystemOrExecutesCode: touchesUnsafeSurface) } - /// `DB ` runs the command on the database it names, so the command decides - /// the tier: read as the bare `DB`, `DB 0 FLUSHDB` would pass as an ordinary write. A prefix - /// with nothing after its index is left whole, which classifies as a write. The words are read - /// the way the driver reads them, so a quoted `"FLUSHALL"` is the command it runs. - static func redisCommandPastDatabasePrefix(_ words: [String]) -> ArraySlice { + /// `DB ` and `OUTSIDEBLOCK ` run the command they wrap, so that + /// command decides the tier: read as the bare prefix, `DB 0 FLUSHDB` would pass as an ordinary + /// write. A prefix with nothing after it is left whole, which classifies as a write. The words are + /// read the way the driver reads them, so a quoted `"FLUSHALL"` is the command it runs. + static func redisCommandPastPrefixes(_ words: [String]) -> ArraySlice { var rest = words[...] - while rest.first?.uppercased() == "DB", rest.count > 2 { - rest = rest.dropFirst(2) + while let first = rest.first?.uppercased() { + if first == "DB", rest.count > 2 { + rest = rest.dropFirst(2) + } else if first == "OUTSIDEBLOCK", rest.count > 1 { + rest = rest.dropFirst() + } else { + break + } } return rest } diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index d1d10ab57d..f215186786 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -2587,6 +2587,9 @@ } } } + }, + "%@ available" : { + }, "%@ bytes" : { @@ -43877,6 +43880,9 @@ } } } + }, + "Commands" : { + }, "Commands for the current tab and connection" : { "localizations" : { @@ -57160,6 +57166,9 @@ } } } + }, + "Database CPU" : { + }, "Database default" : { "localizations" : { @@ -62013,6 +62022,9 @@ } } } + }, + "Deletes" : { + }, "Deletes %lld rows from %@" : { "localizations" : { @@ -93434,6 +93446,9 @@ } } } + }, + "Inserts" : { + }, "Inside an array" : { "localizations" : { @@ -97413,6 +97428,9 @@ } } } + }, + "Keyspace Hit Ratio" : { + }, "Keyword" : { "extractionState" : "manual", @@ -103815,6 +103833,9 @@ } } } + }, + "Logical Reads" : { + }, "Login Handshake Timed Out" : { "extractionState" : "manual", @@ -126698,6 +126719,9 @@ } } } + }, + "Operations" : { + }, "Operations, sorted by name" : { "localizations" : { @@ -140447,6 +140471,9 @@ } } } + }, + "Read the live server panels TablePro shows: active sessions, server metrics, and slow queries. Available on every engine that has a Server Dashboard in TablePro." : { + }, "Read the live server panels TablePro shows: active sessions, server metrics, and slow queries. Available on PostgreSQL, MySQL, SQL Server, ClickHouse, and Typesense." : { @@ -146788,6 +146815,9 @@ } } } + }, + "Resident Memory" : { + }, "Resolved on every connect. It stays on this Mac and never syncs, because it can name a command to run." : { "localizations" : { @@ -204227,6 +204257,9 @@ } } } + }, + "Updates" : { + }, "Updates when TablePro restarts" : { @@ -205313,6 +205346,9 @@ } } } + }, + "Used Memory" : { + }, "User" : { "localizations" : { diff --git a/apple/TablePro/ViewModels/ServerDashboardViewModel.swift b/apple/TablePro/ViewModels/ServerDashboardViewModel.swift index 7d19709fdb..5c1bc8b3d7 100644 --- a/apple/TablePro/ViewModels/ServerDashboardViewModel.swift +++ b/apple/TablePro/ViewModels/ServerDashboardViewModel.swift @@ -163,7 +163,7 @@ final class ServerDashboardViewModel: ObservableObject { String(localized: "No active connection") ) } - return try await driver.execute(query: query) + return try await driver.executeRead(query: query) } var newPanelErrors: [DashboardPanel: String] = [:] diff --git a/apple/TableProTests/Core/MCP/MCPServerDashboardPayloadTests.swift b/apple/TableProTests/Core/MCP/MCPServerDashboardPayloadTests.swift index fe2e942c84..a96b24e5cc 100644 --- a/apple/TableProTests/Core/MCP/MCPServerDashboardPayloadTests.swift +++ b/apple/TableProTests/Core/MCP/MCPServerDashboardPayloadTests.swift @@ -109,6 +109,13 @@ struct MCPSessionControlStatementTests { #expect(clickHouse.message.contains("kill")) } + @Test("Kill on MongoDB, which only cancels, names the mode that works") + func killOnMongoNamesCancel() throws { + let mongo = try #require(try refusal(.mongodb, processId: "31745", cancelOnly: false)) + #expect(mongo.code == .invalidArgument) + #expect(mongo.message.contains("cancel")) + } + @Test("An engine with no session control keeps saying so") func engineWithoutSessionControl() throws { let error = try #require(try refusal(.typesense, processId: "1", cancelOnly: true)) diff --git a/apple/TableProTests/Core/ServerDashboard/DashboardProviderSQLTests.swift b/apple/TableProTests/Core/ServerDashboard/DashboardProviderSQLTests.swift index 6ca5668d41..8e4282db8b 100644 --- a/apple/TableProTests/Core/ServerDashboard/DashboardProviderSQLTests.swift +++ b/apple/TableProTests/Core/ServerDashboard/DashboardProviderSQLTests.swift @@ -39,7 +39,10 @@ struct DashboardProviderSQLTests { ServerRelease(databaseType: .mysql, version: "4.1.22-standard") ] - private static let callerExclusions = ["pg_backend_pid()", "CONNECTION_ID()", "@@SPID", "queryID()"] + private static let callerExclusions = [ + "pg_backend_pid()", "CONNECTION_ID()", "@@SPID", "queryID()", "SYS_CONTEXT('USERENV', 'SID')", + "command.currentOp", "CLIENT ID", + ] private static func labeledProvider(_ databaseType: DatabaseType, serverVersion: String?) -> LabeledProvider? { ServerDashboardQueryProviderFactory.provider(for: databaseType, serverVersion: serverVersion).map { diff --git a/apple/TableProTests/Core/ServerDashboard/MongoDBDashboardProviderTests.swift b/apple/TableProTests/Core/ServerDashboard/MongoDBDashboardProviderTests.swift new file mode 100644 index 0000000000..3ae04ec534 --- /dev/null +++ b/apple/TableProTests/Core/ServerDashboard/MongoDBDashboardProviderTests.swift @@ -0,0 +1,135 @@ +// +// MongoDBDashboardProviderTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +struct MongoDBDashboardProviderTests { + private static func result(_ columns: [String], _ rows: [[String?]]) -> QueryResult { + QueryResult( + columns: columns, + columnTypes: [], + rows: rows.map { $0.map { PluginCellValue.fromOptional($0) } }, + rowsAffected: 0, + executionTime: 0, + error: nil + ) + } + + private static let sessionColumns = ["active", "command", "connection", "ns", "opid", "secs", "user"] + + @Test("A running operation can be cancelled; an idle connection is listed by name and cannot") + func sessionsMapOperationsAndIdleConnections() async throws { + let rows: [[String?]] = [ + ["true", #"{"find": "orders", "filter": {"a": 1}}"#, "conn12", "shop.orders", "31745", "9", "app"], + ["false", nil, "conn16", "", "", "", ""], + ] + let sessions = try await MongoDBDashboardProvider().fetchSessions { _ in + Self.result(Self.sessionColumns, rows) + } + #expect(sessions.count == 2) + + let running = try #require(sessions.first) + #expect(running.id == "31745") + #expect(running.user == "app") + #expect(running.database == "shop") + #expect(running.state == "active") + #expect(running.durationSeconds == 9) + #expect(running.query.contains("orders")) + #expect(running.canCancel) + #expect(!running.canKill) + + let idle = try #require(sessions.last) + #expect(idle.id == "conn16") + #expect(idle.state == "idle") + #expect(idle.duration.isEmpty) + #expect(!idle.canCancel) + } + + @Test("The sessions statement leaves out the dashboard's own operation and server threads") + func sessionsStatementFiltersItself() { + let statement = MongoDBDashboardProvider.sessionsStatement + #expect(statement.contains(#""command.currentOp": { $exists: false }"#)) + #expect(statement.contains("client: { $exists: true }")) + #expect(statement.contains("error.code !== 13")) + #expect(statement.contains("request.$ownOps = true")) + #expect(statement.contains(#"key !== "$truncated""#)) + } + + @Test("Every statement asks the server only through commands the driver lets a read run", arguments: [ + MongoDBDashboardProvider.sessionsStatement, + MongoDBDashboardProvider.metricsStatement, + MongoDBDashboardProvider.slowQueriesStatement, + ]) + func statementsStayWithinReads(statement: String) { + for call in ["aggregate", "runCommand", "killOp", "profile:", ".eval("] { + #expect(!statement.contains(call), "\(call)") + } + } + + @Test("killOp is Cancel Query, and nothing ends a MongoDB connection") + func cancelIsKillOp() { + let provider = MongoDBDashboardProvider() + #expect(provider.cancelQuerySQL(processId: "31745") == "db.adminCommand({ killOp: 1, op: 31745 })") + #expect(provider.cancelQuerySQL(processId: "shard01:31745") + == #"db.adminCommand({ killOp: 1, op: "shard01:31745" })"#) + #expect(provider.killSessionSQL(processId: "31745") == nil) + } + + @Test("Only an operation id reaches the shell", arguments: [ + ("31745", true), + ("shard-01.rs_a:7", true), + ("conn16", false), + ("1}); db.dropDatabase(); ({", false), + (#"shard":1"#, false), + (":12", false), + ("shard:", false), + ("", false), + ] as [(String, Bool)]) + func processIdIsAnOperationId(processId: String, accepted: Bool) { + let provider = MongoDBDashboardProvider() + #expect(provider.acceptsProcessId(processId) == accepted) + #expect((provider.cancelQuerySQL(processId: processId) != nil) == accepted) + } + + @Test("Metrics come from one serverStatus row") + func metricsMapServerStatus() async throws { + let columns = ["available", "command", "connections", "insert", "query", "remove", "resident", "update", "uptime"] + let metrics = try await MongoDBDashboardProvider().fetchMetrics { _ in + Self.result(columns, [["101548", "25", "5", "0", "3", "1", "136", "2", "225"]]) + } + #expect(metrics.map { $0.id } == [ + "connections", "uptime", "resident_memory", "queries", "inserts", "updates", "deletes", "commands", + ]) + #expect(metrics.first?.value == "5") + #expect(metrics.first?.unit.contains("101548") == true) + #expect(metrics.first { $0.id == "resident_memory" }?.value == ByteSizeFormatting.string(bytes: 136 * 1_048_576)) + #expect(metrics.first { $0.id == "deletes" }?.value == "1") + } + + @Test("A profiled operation keeps its milliseconds instead of rounding to zero seconds") + func slowQueriesMapProfilerEntries() async throws { + let slow = try await MongoDBDashboardProvider().fetchSlowQueries { _ in + Self.result(["command", "millis", "ns", "user"], [[#"{"find": "orders"}"#, "320", "shop.orders", "app@admin"]]) + } + let entry = try #require(slow.first) + #expect(entry.duration == DurationFormatting.string(microseconds: 320_000)) + #expect(entry.database == "shop") + #expect(entry.user == "app@admin") + #expect(entry.query.contains("orders")) + } + + @Test("The database is the namespace before its first dot", arguments: [ + ("shop.orders", "shop"), + ("admin.$cmd", "admin"), + ("shop.system.profile", "shop"), + ("", ""), + ] as [(String, String)]) + func databaseOfNamespace(namespace: String, database: String) { + #expect(MongoDBDashboardProvider.database(ofNamespace: namespace) == database) + } +} diff --git a/apple/TableProTests/Core/ServerDashboard/OracleDashboardProviderTests.swift b/apple/TableProTests/Core/ServerDashboard/OracleDashboardProviderTests.swift new file mode 100644 index 0000000000..51183a1ee4 --- /dev/null +++ b/apple/TableProTests/Core/ServerDashboard/OracleDashboardProviderTests.swift @@ -0,0 +1,154 @@ +// +// OracleDashboardProviderTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +struct OracleDashboardProviderTests { + private final class Server { + private(set) var statements: [String] = [] + private let answer: (String) throws -> QueryResult + + init(_ answer: @escaping (String) throws -> QueryResult) { + self.answer = answer + } + + func execute(_ sql: String) throws -> QueryResult { + statements.append(sql) + return try answer(sql) + } + } + + private struct Refusal: Error {} + + private static func result(_ columns: [String], _ rows: [[String]]) -> QueryResult { + QueryResult( + columns: columns, + columnTypes: [], + rows: rows.map { $0.map { PluginCellValue.text($0) } }, + rowsAffected: 0, + executionTime: 0, + error: nil + ) + } + + @Test("The release number comes after Release, whatever the banner calls the product", arguments: [ + ("Oracle Database 11g Express Edition Release 11.2.0.2.0 - 64bit Production", 11), + ("Oracle Database 12c Enterprise Edition Release 12.2.0.1.0 - 64bit Production", 12), + ("Oracle Database 18c Express Edition Release 18.0.0.0.0 - Production", 18), + ("Oracle AI Database 26ai Free Release 23.26.3.0.0 - Develop, Learn, and Run for Fr", 23), + ] as [(String, Int)]) + func readsTheRelease(banner: String, release: Int) { + #expect(OracleDashboardProvider.release(from: banner) == release) + } + + @Test("Cancel Query is offered from 18c, and when the banner could not be read") + func cancelNeeds18c() { + let old = OracleDashboardProvider(serverVersion: "Oracle Database 12c Enterprise Edition Release 12.2.0.1.0") + let current = OracleDashboardProvider(serverVersion: "Oracle Database 19c Enterprise Edition Release 19.0.0.0.0") + let unread = OracleDashboardProvider(serverVersion: nil) + #expect(old.cancelQuerySQL(processId: "12,345") == nil) + #expect(current.cancelQuerySQL(processId: "12,345") == "ALTER SYSTEM CANCEL SQL '12,345'") + #expect(unread.cancelQuerySQL(processId: "12,345") != nil) + #expect(old.killSessionSQL(processId: "12,345") == "ALTER SYSTEM KILL SESSION '12,345' IMMEDIATE") + } + + @Test("A session is named by SID and serial number, and nothing else reaches the statement", arguments: [ + ("12,345", true), + ("12", false), + ("12,", false), + (",345", false), + ("1,2,3", false), + ("12,345' IMMEDIATE --", false), + ("12,345,@1", false), + ] as [(String, Bool)]) + func processIdIsSidAndSerial(processId: String, accepted: Bool) { + let provider = OracleDashboardProvider(serverVersion: nil) + #expect(provider.acceptsProcessId(processId) == accepted) + #expect((provider.killSessionSQL(processId: processId) != nil) == accepted) + } + + @Test("Sessions read the upper-case columns Oracle returns") + func sessionsMapColumns() async throws { + let server = Server { _ in + Self.result( + ["SESSION_ID", "USERNAME", "SCHEMANAME", "STATUS", "LAST_CALL_ET", "SQL_TEXT"], + [["55,59756", "APP", "APP", "ACTIVE", "75", "begin dbms_session.sleep(100); end;"]] + ) + } + let sessions = try await OracleDashboardProvider(serverVersion: nil).fetchSessions { try server.execute($0) } + let session = try #require(sessions.first) + #expect(session.id == "55,59756") + #expect(session.user == "APP") + #expect(session.database == "APP") + #expect(session.state == "ACTIVE") + #expect(session.durationSeconds == 75) + #expect(session.query == "begin dbms_session.sleep(100); end;") + #expect(server.statements.first?.contains("s.SID <> SYS_CONTEXT('USERENV', 'SID')") == true) + } + + @Test("A pluggable database, whose instance metric view is empty, reads its own") + func metricsFallBackToTheContainerView() async throws { + let server = Server { sql in + if sql.contains("V_$SYSMETRIC") { return Self.result(["METRIC_NAME", "VALUE"], []) } + if sql.contains("V_$CON_SYSMETRIC") { + return Self.result(["METRIC_NAME", "VALUE"], [ + ["CPU Usage Per Sec", "6.8"], + ["Logical Reads Per Sec", "19.8"], + ]) + } + if sql.contains("COUNT(*)") { return Self.result(["COUNT(*)"], [["2"]]) } + return Self.result(["UPTIME"], [["1298"]]) + } + let metrics = try await OracleDashboardProvider(serverVersion: nil).fetchMetrics { try server.execute($0) } + #expect(metrics.map { $0.id } == ["sessions", "cpu", "logical_reads", "uptime"]) + #expect(metrics.first { $0.id == "cpu" }?.value == "6.8") + #expect(metrics.first { $0.id == "logical_reads" }?.value == "19.8") + #expect(metrics.first { $0.id == "sessions" }?.value == "2") + } + + @Test("The root container and a non-CDB stop at the instance view") + func metricsStopAtTheInstanceView() async throws { + let server = Server { sql in + if sql.contains("V_$SYSMETRIC") { + return Self.result(["METRIC_NAME", "VALUE"], [["CPU Usage Per Sec", "2.7"]]) + } + return Self.result(["X"], [["1"]]) + } + _ = try await OracleDashboardProvider(serverVersion: nil).fetchMetrics { try server.execute($0) } + #expect(!server.statements.contains { $0.contains("V_$CON_SYSMETRIC") }) + } + + @Test("A metric the account cannot read leaves the others, and only all of them failing is an error") + func metricFailuresAreIndependent() async throws { + let partial = Server { sql in + if sql.contains("V_$INSTANCE") { return Self.result(["UPTIME"], [["60"]]) } + throw Refusal() + } + let metrics = try await OracleDashboardProvider(serverVersion: nil).fetchMetrics { try partial.execute($0) } + #expect(metrics.map { $0.id } == ["uptime"]) + + let refused = Server { _ in throw Refusal() } + await #expect(throws: Refusal.self) { + _ = try await OracleDashboardProvider(serverVersion: nil).fetchMetrics { try refused.execute($0) } + } + } + + @Test("Every dictionary view is owner-qualified, so a current-schema object cannot stand in for it") + func viewsAreOwnerQualified() { + let statements = [ + OracleDashboardProvider.sessionsQuery, + OracleDashboardProvider.slowQueriesQuery, + OracleDashboardProvider.sessionCountQuery, + OracleDashboardProvider.uptimeQuery, + ] + OracleDashboardProvider.rateQueries + for sql in statements { + #expect(sql.range(of: #"(? QueryResult + + init(_ answer: @escaping (String) throws -> QueryResult) { + self.answer = answer + } + + func execute(_ command: String) throws -> QueryResult { + commands.append(command) + return try answer(command) + } + } + + private struct Refusal: Error {} + + private let provider = RedisDashboardProvider(serverVersion: "8.10.2") + + private static func result(_ columns: [String], _ rows: [[String?]]) -> QueryResult { + QueryResult( + columns: columns, + columnTypes: [], + rows: rows.map { $0.map { PluginCellValue.fromOptional($0) } }, + rowsAffected: 0, + executionTime: 0, + error: nil + ) + } + + private static func cell(_ text: String) -> QueryResult { + result(["result"], [[text]]) + } + + private static let listing = """ + id=15 addr=127.0.0.1:59686 laddr=127.0.0.1:6379 fd=16 name= age=1 idle=0 flags=N db=0 sub=0 psub=0 \ + multi=-1 cmd=client|list user=default redir=-1 resp=2 + id=21 addr=10.0.0.4:51022 laddr=127.0.0.1:6379 fd=17 name=worker age=900 idle=42 flags=b db=3 sub=0 \ + psub=0 multi=-1 cmd=blpop user=jobs redir=-1 resp=2 + id=22 addr=10.0.0.5:51023 laddr=127.0.0.1:6379 fd=18 name= age=10 idle=0 flags=N db=0 sub=0 psub=0 \ + multi=-1 cmd=get user=default redir=-1 resp=3 + + """ + + @Test("Clients come from CLIENT LIST without the dashboard's own connection") + func sessionsLeaveOutTheDashboard() async throws { + let server = Server { command in + command.hasSuffix("CLIENT ID") ? Self.cell("15") : Self.cell(Self.listing) + } + let sessions = try await provider.fetchSessions { try server.execute($0) } + #expect(sessions.map { $0.id } == ["21", "22"]) + #expect(server.commands == ["OUTSIDEBLOCK CLIENT ID", "OUTSIDEBLOCK CLIENT LIST"]) + + let blocked = try #require(sessions.first) + #expect(blocked.user == "jobs") + #expect(blocked.database == "db3") + #expect(blocked.state == "blocked") + #expect(blocked.durationSeconds == 42) + #expect(blocked.query == "blpop") + #expect(blocked.canKill) + #expect(blocked.canCancel) + #expect(sessions.last?.state == "active") + #expect(sessions.last?.canCancel == false) + } + + @Test("A server without CLIENT ID still lists its clients") + func sessionsWithoutClientId() async throws { + let server = Server { command in + if command.hasSuffix("CLIENT ID") { throw Refusal() } + return Self.cell(Self.listing) + } + let sessions = try await provider.fetchSessions { try server.execute($0) } + #expect(sessions.count == 3) + } + + @Test("The state names the flag that sets a client apart", arguments: [ + ("b", 5, "blocked"), + ("x", 0, "multi"), + ("P", 30, "pubsub"), + ("S", 0, "replica"), + ("M", 0, "master"), + ("N", 3, "idle"), + ("N", 0, "active"), + ] as [(String, Int, String)]) + func stateFollowsFlags(flags: String, idle: Int, state: String) { + #expect(RedisDashboardProvider.state(flags: flags, idleSeconds: idle) == state) + } + + @Test("Metrics read INFO fields by their whole name") + func metricsReadInfo() async throws { + let info = """ + # Server\r + redis_version:8.10.2\r + uptime_in_seconds:39738\r + # Clients\r + connected_clients:4\r + # Memory\r + used_memory_human:1.70M\r + used_memory:1778512\r + maxmemory:3557024\r + # Stats\r + instantaneous_ops_per_sec:3\r + keyspace_hits:30\r + keyspace_misses:10\r + + """ + let metrics = try await provider.fetchMetrics { _ in Self.result(["info"], [[info]]) } + #expect(metrics.map { $0.id } == ["connected_clients", "used_memory", "operations", "keyspace_hit_ratio", "uptime"]) + #expect(metrics.first { $0.id == "used_memory" }?.value == ByteSizeFormatting.string(bytes: Int64(1_778_512))) + #expect(metrics.first { $0.id == "used_memory" }?.unit.isEmpty == false) + #expect(metrics.first { $0.id == "keyspace_hit_ratio" }?.value == "75.0") + #expect(metrics.first { $0.id == "operations" }?.value == "3") + } + + @Test("Memory with no limit set carries no share of one") + func memoryWithoutLimit() async throws { + let info = "used_memory:1024\r\nmaxmemory:0\r\n" + let metrics = try await provider.fetchMetrics { _ in Self.result(["info"], [[info]]) } + #expect(metrics.first { $0.id == "used_memory" }?.unit == "") + } + + @Test("Slow log entries keep their sub-second durations and come newest first across nodes") + func slowQueriesFromTheSlowLog() async throws { + let rows: [[String?]] = [ + ["4", "1792537100", "626", #"SET foo "a b""#, "127.0.0.1:59713", ""], + ["9", "1792537207", "12345", "KEYS *", "10.0.0.4:51022", "worker"], + ] + let server = Server { _ in Self.result(RedisSlowLogGrid.columns, rows) } + let slow = try await provider.fetchSlowQueries { try server.execute($0) } + #expect(server.commands == ["OUTSIDEBLOCK SLOWLOG GET 20"]) + #expect(slow.map { $0.query } == ["KEYS *", #"SET foo "a b""#]) + #expect(slow.first?.duration == DurationFormatting.string(microseconds: 12_345)) + #expect(slow.first?.user == "10.0.0.4:51022") + #expect(slow.last?.duration == DurationFormatting.string(microseconds: 626)) + } + + @Test("Terminate is CLIENT KILL ID and Cancel is CLIENT UNBLOCK, for a numeric id only", arguments: [ + ("21", true), + ("21 SKIPME no", false), + ("-1", false), + ("", false), + ] as [(String, Bool)]) + func terminateAndCancelNameOneClient(processId: String, accepted: Bool) { + #expect(provider.killSessionSQL(processId: processId) + == (accepted ? "OUTSIDEBLOCK CLIENT KILL ID \(processId)" : nil)) + #expect(provider.cancelQuerySQL(processId: processId) + == (accepted ? "OUTSIDEBLOCK CLIENT UNBLOCK \(processId) ERROR" : nil)) + } + + @Test("Redis before 5.0 has no CLIENT UNBLOCK, so a blocked client there offers only Terminate", arguments: [ + ("4.0.14", false), ("5.0.0", true), ("7.2.4", true), (nil, true), + ] as [(String?, Bool)]) + func cancelNeedsRedis5(serverVersion: String?, cancellable: Bool) async throws { + let provider = RedisDashboardProvider(serverVersion: serverVersion) + #expect((provider.cancelQuerySQL(processId: "21") != nil) == cancellable) + let sessions = try await provider.fetchSessions { command in + command.hasSuffix("CLIENT ID") ? Self.cell("15") : Self.cell(Self.listing) + } + #expect(sessions.first { $0.id == "21" }?.canCancel == cancellable) + #expect(sessions.first { $0.id == "21" }?.canKill == true) + } + + @Test("Every command goes outside the user's MULTI block") + func everyCommandIsHeldOutsideTheBlock() async throws { + let server = Server { _ in .empty } + _ = try await provider.fetchSessions { try server.execute($0) } + _ = try await provider.fetchMetrics { try server.execute($0) } + _ = try await provider.fetchSlowQueries { try server.execute($0) } + #expect(server.commands.count == 4) + #expect(server.commands.allSatisfy { $0.hasPrefix("OUTSIDEBLOCK ") }) + } +} diff --git a/apple/TableProTests/Core/ServerDashboard/ServerDashboardQueryProviderFactoryTests.swift b/apple/TableProTests/Core/ServerDashboard/ServerDashboardQueryProviderFactoryTests.swift index c870012e01..dd219bb1c1 100644 --- a/apple/TableProTests/Core/ServerDashboard/ServerDashboardQueryProviderFactoryTests.swift +++ b/apple/TableProTests/Core/ServerDashboard/ServerDashboardQueryProviderFactoryTests.swift @@ -32,7 +32,10 @@ struct ServerDashboardQueryProviderFactoryTests { @Test("Server engines each have one") func serverEnginesHaveOne() { - for type in [DatabaseType.postgresql, .mysql, .mariadb, .redshift, .cockroachdb, .mssql, .clickhouse] { + let types: [DatabaseType] = [ + .postgresql, .mysql, .mariadb, .redshift, .cockroachdb, .mssql, .clickhouse, .oracle, .mongodb, .redis, + ] + for type in types { #expect(ServerDashboardQueryProviderFactory.supportsDashboard(for: type), "\(type.rawValue)") } } diff --git a/apple/TableProTests/Core/Services/Formatting/ByteSizeFormattingTests.swift b/apple/TableProTests/Core/Services/Formatting/ByteSizeFormattingTests.swift index 0fda505c05..932133db86 100644 --- a/apple/TableProTests/Core/Services/Formatting/ByteSizeFormattingTests.swift +++ b/apple/TableProTests/Core/Services/Formatting/ByteSizeFormattingTests.swift @@ -56,6 +56,17 @@ struct ByteSizeFormattingTests { #expect(!hours.isEmpty) } + @Test("A slow log duration under a second keeps its unit instead of reading 0s") + func subSecondDurationsKeepTheirUnit() { + let zero = DurationFormatting.string(seconds: 0) + #expect(DurationFormatting.string(microseconds: 626) != zero) + #expect(DurationFormatting.string(microseconds: 12_345) != zero) + #expect(DurationFormatting.string(microseconds: 12_345) != DurationFormatting.string(microseconds: 626)) + #expect(DurationFormatting.string(microseconds: 2_000_000) == DurationFormatting.string(seconds: 2)) + #expect(DurationFormatting.string(microseconds: 999_600) == DurationFormatting.string(seconds: 1)) + #expect(!DurationFormatting.string(microseconds: -5).contains("-")) + } + @Test("A negative uptime does not render as a negative duration") func negativeDurationIsClamped() { #expect(!DurationFormatting.string(seconds: -10).contains("-")) diff --git a/apple/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift b/apple/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift index 21a009d33a..01fc9c7bcb 100644 --- a/apple/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift +++ b/apple/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift @@ -450,6 +450,18 @@ struct QueryClassifierNonSqlTests { #expect(QueryClassifier.isWriteQuery("DB 3", databaseType: .redis)) } + /// `OUTSIDEBLOCK` is the Server Dashboard's prefix for a command held out of the user's MULTI + /// block. Read as the bare word, `OUTSIDEBLOCK FLUSHALL` passed as an ordinary write. + @Test("Redis OUTSIDEBLOCK prefix is classified by the command it wraps") + func redisOutsideBlockPrefix() { + #expect(QueryClassifier.classifyTier("OUTSIDEBLOCK FLUSHALL", databaseType: .redis) == .destructive) + #expect(QueryClassifier.classifyTier("outsideblock DB 0 FLUSHDB", databaseType: .redis) == .destructive) + #expect(QueryClassifier.classifyTier("DB 0 OUTSIDEBLOCK FLUSHDB", databaseType: .redis) == .destructive) + #expect(QueryClassifier.reachesFilesystemOrExecutesCode("OUTSIDEBLOCK EVAL \"return 1\" 0", databaseType: .redis)) + #expect(!QueryClassifier.isWriteQuery("OUTSIDEBLOCK GET key", databaseType: .redis)) + #expect(QueryClassifier.isWriteQuery("OUTSIDEBLOCK", databaseType: .redis)) + } + /// The driver strips redis-cli quoting before it sends, so a quoted command runs as the bare /// one. Read as written, `"FLUSHALL"` matched no set and passed as an ordinary write. @Test("A quoted Redis command is classified as the command the driver runs") diff --git a/apple/TableProTests/ViewModels/ServerDashboardViewModelTests.swift b/apple/TableProTests/ViewModels/ServerDashboardViewModelTests.swift index 91e45cb404..1c394ac653 100644 --- a/apple/TableProTests/ViewModels/ServerDashboardViewModelTests.swift +++ b/apple/TableProTests/ViewModels/ServerDashboardViewModelTests.swift @@ -91,11 +91,49 @@ struct ServerDashboardViewModelTests { #expect(!vm.isSupported) } - @Test("Redis returns no provider and an empty dashboard") - func redisHasNoDashboard() { + @Test("Oracle, MongoDB and Redis show sessions, metrics, and slow queries", arguments: [ + DatabaseType.oracle, .mongodb, .redis, + ]) + func documentAndKeyValueEnginesHaveADashboard(_ databaseType: DatabaseType) { + let vm = makeViewModel(databaseType: databaseType) + #expect(vm.supportedPanels == [.activeSessions, .serverMetrics, .slowQueries]) + #expect(vm.isSupported) + } + + @Test("Redis terminates any client and cancels only a blocked one", arguments: [("N", false), ("b", true)]) + func redisCancelsOnlyABlockedClient(flags: String, cancellable: Bool) async throws { let vm = makeViewModel(databaseType: .redis) - #expect(vm.supportedPanels.isEmpty) - #expect(!vm.isSupported) + let session = try await session( + from: vm, + columns: ["result"], + row: ["id=21 addr=10.0.0.4:51022 idle=4 flags=\(flags) db=0 cmd=blpop user=default"] + ) + #expect(vm.canKill(session)) + #expect(vm.canCancel(session) == cancellable) + } + + @Test("MongoDB cancels a running operation and has no connection to terminate") + func mongoCancelsOnly() async throws { + let vm = makeViewModel(databaseType: .mongodb) + let session = try await session( + from: vm, + columns: ["active", "connection", "opid", "secs"], + row: ["true", "conn12", "31745", "3"] + ) + #expect(!vm.canKill(session)) + #expect(vm.canCancel(session)) + } + + @Test("Oracle terminates and cancels a session named by SID and serial number") + func oracleKillsAndCancels() async throws { + let vm = makeViewModel(databaseType: .oracle) + let session = try await session( + from: vm, + columns: ["SESSION_ID", "USERNAME", "SCHEMANAME", "STATUS", "LAST_CALL_ET", "SQL_TEXT"], + row: ["55,59756", "APP", "APP", "ACTIVE", "3", "SELECT 1 FROM DUAL"] + ) + #expect(vm.canKill(session)) + #expect(vm.canCancel(session)) } @Test("MySQL supports both kill session and cancel query") diff --git a/apple/changelog.d/3732.added.md b/apple/changelog.d/3732.added.md new file mode 100644 index 0000000000..07dfce797e --- /dev/null +++ b/apple/changelog.d/3732.added.md @@ -0,0 +1 @@ +Server Dashboard for Oracle, MongoDB and Redis, with sessions, metrics, slow queries and Cancel or Terminate.