From 7b2176b31b2bee0bd2f7b5bbaddd8b56d94a1609 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 19 Oct 2026 11:55:36 +0100 Subject: [PATCH] connection updates --- DBSyn/MainView.swift | 107 +++++++++++++++++++++++++++++++++++++++++++ docs/PHASE-74.6.md | 5 ++ 2 files changed, 112 insertions(+) create mode 100644 docs/PHASE-74.6.md diff --git a/DBSyn/MainView.swift b/DBSyn/MainView.swift index b8bf362..df4faf0 100644 --- a/DBSyn/MainView.swift +++ b/DBSyn/MainView.swift @@ -3007,6 +3007,102 @@ private struct DBSynOptionsInspector: View { } } +// Resource limits are per MySQL account (username + host), not per saved connection. +private struct DBSynAccountLimitsEditor: View { + let profile: ConnectionProfile + let account: [String] + @Environment(\.dismiss) private var dismiss + @State private var queries = "" + @State private var updates = "" + @State private var connections = "" + @State private var concurrent = "" + @State private var applying = false + @State private var error: String? + @State private var completed = false + + private func quoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "''") + "'" + } + private var values: [String] { [queries, updates, connections, concurrent] } + private var valid: Bool { + values.allSatisfy { value in + guard let number = UInt32(value) else { return false } + return String(number) == value + } + } + private var changed: Bool { + account.count >= 8 && values != Array(account[4...7]) + } + private var statement: String { + guard account.count >= 8, valid else { return "" } + return "ALTER USER \(quoted(account[0]))@\(quoted(account[1])) WITH MAX_QUERIES_PER_HOUR \(queries) MAX_UPDATES_PER_HOUR \(updates) MAX_CONNECTIONS_PER_HOUR \(connections) MAX_USER_CONNECTIONS \(concurrent)" + } + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Account Limits").font(.title2.bold()) + Text("\(quoted(account[0]))@\(quoted(account[1]))") + .font(.system(.body, design: .monospaced)).textSelection(.enabled) + Form { + TextField("Maximum queries per hour", text: $queries) + TextField("Maximum updates per hour", text: $updates) + TextField("Maximum connections per hour", text: $connections) + TextField("Maximum concurrent connections", text: $concurrent) + } + .textFieldStyle(.roundedBorder) + Text("Enter non-negative whole numbers (0 = unlimited). Requires ALTER USER permission. Changes take effect on the MySQL server.") + .font(.caption).foregroundStyle(.secondary) + Text("SQL preview").font(.headline) + ScrollView { + Text(valid ? statement + ";" : "Enter valid whole numbers in all four fields.") + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: 78).padding(10) + .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8)) + if let error { + Text(error).foregroundStyle(.red).textSelection(.enabled) + } + if completed { Text("Account limits updated.").foregroundStyle(.green) } + HStack { + Button("Revert") { loadOriginal(); error = nil; completed = false } + .disabled(applying || !changed) + Spacer() + Button("Close") { dismiss() } + Button("Apply") { Task { await apply() } } + .buttonStyle(.borderedProminent) + .disabled(!valid || !changed || applying) + } + } + .padding(24) + .frame(minWidth: 640, minHeight: 440) + .onAppear { loadOriginal() } + } + private func loadOriginal() { + guard account.count >= 8 else { return } + queries = account[4]; updates = account[5] + connections = account[6]; concurrent = account[7] + } + @MainActor + private func apply() async { + guard valid, changed, !applying else { return } + applying = true + error = nil + completed = false + defer { applying = false } + do { + _ = try await NativeMySQLRunner.run( + profile: profile, database: "", + password: PasswordStore.read(profile.id), sql: statement + ) + completed = true + dismiss() + } catch { + self.error = error.localizedDescription + } + } +} + struct WorkbenchAdministrationView: View { let page: WorkbenchAdminPage let profile: ConnectionProfile? @@ -3025,6 +3121,7 @@ struct WorkbenchAdministrationView: View { @State private var grantsError: String? = nil @State private var showingGrants = false @State private var showingAccountManager = false + @State private var editingAccountLimits = false @State private var selectedProcessID: String? = nil @State private var pendingProcessAction: WorkbenchProcessAction? = nil @State private var processActionError: String? = nil @@ -3278,6 +3375,14 @@ struct WorkbenchAdministrationView: View { ) } } + .sheet(isPresented: $editingAccountLimits, onDismiss: { + Task { await refresh() } + }) { + if let profile, let selectedAccount, + let row = result.rows.first(where: { $0.count >= 8 && "\($0[0])\u{001f}\($0[1])" == selectedAccount }) { + DBSynAccountLimitsEditor(profile: profile, account: row) + } + } .sheet(isPresented: $showingTrends) { WorkbenchDiagnosticTrendsView(page: page, samples: diagnosticSamples) } @@ -3666,6 +3771,8 @@ struct WorkbenchAdministrationView: View { accountLimit("Concurrent Connections", row: row, index: 7) Text("0 means unlimited. Values are read from mysql.user.") .font(.caption).foregroundStyle(.secondary) + Button("Edit Account Limits…") { editingAccountLimits = true } + .disabled(profile == nil) }.frame(maxWidth: .infinity, alignment: .leading).padding(10) } case .roles: diff --git a/docs/PHASE-74.6.md b/docs/PHASE-74.6.md new file mode 100644 index 0000000..96eeece --- /dev/null +++ b/docs/PHASE-74.6.md @@ -0,0 +1,5 @@ +# Phase 74.6 — Editable account resource limits + +Administration → Users and Privileges → Account Limits → Edit Account Limits… opens an editor for four MySQL resource limits. The editor validates unsigned 32-bit whole numbers, previews the ALTER USER SQL, supports Revert, and applies via the native MySQL runner. It refreshes the account list on dismissal. Zero means unlimited. Requires appropriate MySQL permissions. + +Validation: Swift syntax parsing passed on Linux. Xcode compilation and live MySQL tests have not been run.