From ff40ddd1a99dfab39c25ce9758cbb4a863414628 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Fri, 16 Oct 2026 22:24:53 +0100 Subject: [PATCH] Improved progress display Previously, the progress bar updated only after completing an entire table. For large tables such as LIBARC, this could make the application appear to have stopped responding. Phase 10 updates the interface while rows are being exported. --- PHASE15-NOTES.md | 9 +++ WorkbenchReplacement/MainView.swift | 106 ++++++++++++++++++++++++++++ buildServer.json | 19 +++++ 3 files changed, 134 insertions(+) create mode 100644 PHASE15-NOTES.md create mode 100644 buildServer.json 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