diff --git a/PHASE15-NOTES.md b/PHASE15-NOTES.md new file mode 100644 index 0000000..abbac7f --- /dev/null +++ b/PHASE15-NOTES.md @@ -0,0 +1,9 @@ +# Phase 15 — Live MySQL Session Management + +Adds session control to Administration → Client Connections. Select a session ID from `SHOW FULL PROCESSLIST`, then choose **Stop Query** (`KILL QUERY`) or **Disconnect Session** (`KILL CONNECTION`). Both actions require a destructive confirmation. The app validates the session ID as a positive UInt64, rejects terminating its own active MySQL connection, reports errors and refreshes the process list afterward. Search and five-second auto-refresh from Phase 13 remain available. + +**Permissions:** MySQL privileges determine which sessions can be seen or terminated. Session IDs may be reused after a connection exits; always verify the selected session before confirming. A race remains possible between selecting an ID and execution. Terminating connections may interrupt work or roll back uncommitted transactions. There is no undo. + +**Scope:** This phase focuses on session management. Advanced server configuration editing and performance diagnostics are not implemented here. + +**Validation:** Source patch and ZIP integrity checked. No macOS Xcode compiler or live MySQL server available in this environment; test on a disposable MySQL instance before production use. diff --git a/WorkbenchReplacement/MainView.swift b/WorkbenchReplacement/MainView.swift index 07bbafb..8839189 100644 --- a/WorkbenchReplacement/MainView.swift +++ b/WorkbenchReplacement/MainView.swift @@ -1355,6 +1355,11 @@ enum WorkbenchAdminPage: String, CaseIterable, Identifiable { } } +private enum WorkbenchProcessAction: String { + case query = "Stop query for" + case connection = "Disconnect" +} + struct WorkbenchAdministrationView: View { let page: WorkbenchAdminPage let profile: ConnectionProfile? @@ -1371,6 +1376,11 @@ struct WorkbenchAdministrationView: View { @State private var grantsError: String? = nil @State private var showingGrants = false @State private var showingAccountManager = false + @State private var selectedProcessID: String? = nil + @State private var pendingProcessAction: WorkbenchProcessAction? = nil + @State private var processActionError: String? = nil + @State private var processActionStatus: String? = nil + @State private var terminatingProcess = false private var filteredRows: [[String]] { guard !searchText.isEmpty else { return result.rows } @@ -1418,6 +1428,10 @@ struct WorkbenchAdministrationView: View { resultContent } + if page == .clientConnections { + processControls + } + if page == .users && !result.rows.isEmpty { HStack { Picker("Account", selection: $selectedAccount) { @@ -1463,6 +1477,24 @@ struct WorkbenchAdministrationView: View { await refresh() } } + .confirmationDialog( + "Terminate MySQL session?", + isPresented: Binding( + get: { pendingProcessAction != nil }, + set: { if !$0 { pendingProcessAction = nil } } + ), + titleVisibility: .visible + ) { + if let action = pendingProcessAction, let id = selectedProcessID { + Button("\(action.rawValue) session \(id)", role: .destructive) { + pendingProcessAction = nil + Task { await terminateProcess(id: id, action: action) } + } + } + Button("Cancel", role: .cancel) { pendingProcessAction = nil } + } message: { + Text("This operation affects a live MySQL session. Terminating a connection can interrupt transactions and roll back uncommitted work. Verify the session ID before proceeding.") + } .sheet(isPresented: $showingAccountManager, onDismiss: { Task { await refresh() } }) { @@ -1494,6 +1526,75 @@ struct WorkbenchAdministrationView: View { } } + private var processControls: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 12) { + Picker("Session", selection: $selectedProcessID) { + Text("Select a session").tag(String?.none) + ForEach(processIDs, id: \.self) { id in + Text("ID \(id)").tag(Optional(id)) + } + } + .frame(maxWidth: 250) + Button("Stop Query") { pendingProcessAction = .query } + .disabled(selectedProcessID == nil || terminatingProcess) + Button("Disconnect Session") { pendingProcessAction = .connection } + .disabled(selectedProcessID == nil || terminatingProcess) + Spacer() + if terminatingProcess { ProgressView().controlSize(.small) } + } + if let processActionError { + Text(processActionError).foregroundStyle(.red).textSelection(.enabled) + } + if let processActionStatus { + Text(processActionStatus).foregroundStyle(.secondary) + } + Text("Requires PROCESS privilege to see other users' sessions and appropriate privileges to terminate them.") + .font(.caption).foregroundStyle(.secondary) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + private var processIDs: [String] { + guard let index = result.columns.firstIndex(where: { $0.caseInsensitiveCompare("Id") == .orderedSame }) else { + return [] + } + return result.rows.compactMap { row in + guard row.indices.contains(index), let id = UInt64(row[index]), id > 0 else { return nil } + return String(id) + } + } + + @MainActor + private func terminateProcess(id: String, action: WorkbenchProcessAction) async { + guard let profile, let numericID = UInt64(id), numericID > 0 else { return } + terminatingProcess = true + processActionError = nil + processActionStatus = nil + defer { terminatingProcess = false } + do { + let own = try await NativeMySQLRunner.run( + profile: profile, database: "", + password: PasswordStore.read(profile.id), sql: "SELECT CONNECTION_ID()" + ) + if own.rows.first?.first == id { + processActionError = "Cannot terminate this application's own MySQL session." + return + } + let sql = "KILL \(action == .query ? "QUERY" : "CONNECTION") \(numericID)" + _ = try await NativeMySQLRunner.run( + profile: profile, database: "", + password: PasswordStore.read(profile.id), sql: sql + ) + processActionStatus = "\(action.rawValue) requested for session \(id)." + selectedProcessID = nil + await refresh() + } catch { + processActionError = error.localizedDescription + } + } + private var header: some View { HStack(spacing: 14) { Image(systemName: page.icon) @@ -1669,6 +1770,11 @@ struct WorkbenchAdministrationView: View { ) result = response + if page == .clientConnections, + let selectedProcessID, + !processIDs.contains(selectedProcessID) { + self.selectedProcessID = nil + } lastUpdated = Date() } catch { errorMessage = error.localizedDescription diff --git a/buildServer.json b/buildServer.json new file mode 100644 index 0000000..03f014a --- /dev/null +++ b/buildServer.json @@ -0,0 +1,19 @@ +{ + "name": "xcode build server", + "version": "1.3.0", + "bspVersion": "2.2.0", + "languages": [ + "c", + "cpp", + "objective-c", + "objective-cpp", + "swift" + ], + "argv": [ + "/opt/homebrew/bin/xcode-build-server" + ], + "workspace": "/Volumes/Developer/Applications/WorkbenchReplacement/WorkbenchReplacement.xcodeproj/project.xcworkspace", + "build_root": "/Users/markgregory/Library/Developer/Xcode/DerivedData/WorkbenchReplacement-cckfuhwqasossyecgsucncuxddvg", + "scheme": "WorkbenchReplacement", + "kind": "xcode" +} \ No newline at end of file