connection updates

This commit is contained in:
Mark Gregory
2026-10-19 11:37:17 +01:00
parent c6504b3d1b
commit a34faa3b29
2 changed files with 181 additions and 19 deletions
+171 -19
View File
@@ -2787,6 +2787,7 @@ enum WorkbenchAdminPage: String, CaseIterable, Identifiable {
case serverCounters = "Server Counters"
case clientConnections = "Client Connections"
case users = "Users and Privileges"
case optionsFile = "Options File"
case variables = "Status and System Variables"
case dashboard = "Dashboard"
case performanceReports = "Performance Reports"
@@ -2812,6 +2813,7 @@ enum WorkbenchAdminPage: String, CaseIterable, Identifiable {
case .serverCounters: return "chart.xyaxis.line"
case .clientConnections: return "person.2"
case .users: return "person.crop.circle.badge.checkmark"
case .optionsFile: return "doc.text"
case .variables: return "slider.horizontal.3"
case .dashboard: return "chart.xyaxis.line"
case .performanceReports: return "chart.bar.xaxis"
@@ -2891,11 +2893,14 @@ enum WorkbenchAdminPage: String, CaseIterable, Identifiable {
case .users:
return """
SELECT User, Host, plugin, account_locked
SELECT User, Host, plugin, account_locked, max_questions, max_updates, max_connections, max_user_connections
FROM mysql.user
ORDER BY User, Host
"""
case .optionsFile:
return "SHOW GLOBAL VARIABLES"
case .variables:
return "SHOW GLOBAL VARIABLES"
@@ -2938,6 +2943,70 @@ private enum WorkbenchProcessAction: String {
case connection = "Disconnect"
}
private enum DBSynAccountTab: String, CaseIterable, Identifiable {
case login = "Login"
case limits = "Account Limits"
case roles = "Administrative Roles"
case schemas = "Schema Privileges"
var id: String { rawValue }
}
private struct DBSynOptionsInspector: View {
let columns: [String]
let rows: [[String]]
@State private var category = "General"
@State private var query = ""
private let categories: [(String, [String])] = [
("General", ["autocommit", "event_scheduler", "server_id", "version", "basedir", "datadir"]),
("Logging", ["log_error", "general_log", "slow_query_log", "long_query_time", "log_bin"]),
("InnoDB", ["innodb_", "transaction_isolation"]),
("Networking", ["port", "bind_address", "max_connections", "connect_timeout", "wait_timeout"]),
("Advanced", ["sql_mode", "max_allowed_packet", "table_open_cache", "thread_cache_size"]),
("Security", ["require_secure_transport", "tls_version", "ssl_", "default_authentication_plugin"]),
("Replication", ["server_id", "log_bin", "replica_", "slave_"]),
("MyISAM", ["myisam_", "key_buffer_size"]),
("Performance", ["performance_schema", "query_cache", "tmp_table_size", "sort_buffer_size"])
]
private var displayed: [[String]] {
let patterns = categories.first(where: { $0.0 == category })?.1 ?? []
return rows.filter { row in
guard row.count > 1 else { return false }
let key = row[0].lowercased()
return patterns.contains(where: { key == $0 || ($0.hasSuffix("_") && key.hasPrefix($0)) })
&& (query.isEmpty || row.joined(separator: " ").localizedCaseInsensitiveContains(query))
}
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("MySQL Server Options").font(.title2.bold())
Spacer()
TextField("Locate option", text: $query).textFieldStyle(.roundedBorder).frame(width: 230)
}
Picker("Category", selection: $category) {
ForEach(categories.map { $0.0 }, id: \.self) { group in Text(group).tag(group) }
}.pickerStyle(.segmented)
Text("Live server variables (read-only). These are not the contents of my.cnf. Changing configuration files requires server filesystem access and a separate reviewed workflow.")
.font(.caption).foregroundStyle(.secondary)
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(displayed.indices, id: \.self) { index in
let row = displayed[index]
HStack(alignment: .top) {
Text(row[0]).font(.system(.body, design: .monospaced)).frame(width: 260, alignment: .leading)
Text(row[1]).font(.system(.body, design: .monospaced))
.textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading)
}
.padding(10)
Divider()
}
}
}
}.padding(18)
}
}
struct WorkbenchAdministrationView: View {
let page: WorkbenchAdminPage
let profile: ConnectionProfile?
@@ -2950,6 +3019,8 @@ struct WorkbenchAdministrationView: View {
@State private var searchText = ""
@State private var autoRefresh = true
@State private var selectedAccount: String? = nil
@State private var accountTab: DBSynAccountTab = .login
@State private var selectedSchema: String = ""
@State private var grants: [String] = []
@State private var grantsError: String? = nil
@State private var showingGrants = false
@@ -3480,6 +3551,9 @@ struct WorkbenchAdministrationView: View {
allRowCount: result.rows.count
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .optionsFile:
DBSynOptionsInspector(columns: result.columns, rows: filteredRows)
.frame(maxWidth: .infinity, maxHeight: .infinity)
default:
// Status and System Variables is deliberately a distinct variable
// inspector, never replaced by the monitoring screen.
@@ -3519,6 +3593,7 @@ struct WorkbenchAdministrationView: View {
}
}
.listStyle(.sidebar)
.onChange(of: selectedAccount) { _, _ in grants = []; grantsError = nil; accountTab = .login }
Divider()
HStack {
Button {
@@ -3559,26 +3634,80 @@ struct WorkbenchAdministrationView: View {
}
.padding(18)
Divider()
Form {
Section("Login") {
LabeledContent("User", value: row[0])
LabeledContent("Host", value: row[1])
if row.count > 2 { LabeledContent("Authentication plugin", value: row[2]) }
if row.count > 3 {
LabeledContent("Account status", value: row[3] == "Y" ? "Locked" : "Active")
}
}
Section("Administrative Actions") {
HStack {
Button("View Grants…") { Task { await showGrants() } }
Button("Manage Privileges…") { showingAccountManager = true }
Button("Change Password…") { showingAccountManager = true }
}
Text("Changes require MySQL administrative privileges and explicit confirmation.")
.font(.caption).foregroundStyle(.secondary)
Picker("Account section", selection: $accountTab) {
ForEach(DBSynAccountTab.allCases) { tab in
Text(tab.rawValue).tag(tab)
}
}
.formStyle(.grouped)
.pickerStyle(.segmented)
.padding(.horizontal, 18)
.padding(.vertical, 12)
ScrollView {
VStack(alignment: .leading, spacing: 18) {
switch accountTab {
case .login:
GroupBox("Login") {
VStack(alignment: .leading, spacing: 12) {
LabeledContent("Login name", value: row[0])
LabeledContent("Limit to hosts matching", value: row[1])
if row.count > 2 { LabeledContent("Authentication type", value: row[2]) }
if row.count > 3 { LabeledContent("Account status", value: row[3] == "Y" ? "Locked" : "Active") }
Text("Passwords cannot be read from MySQL. Use Edit Account to set a new password.")
.font(.caption).foregroundStyle(.secondary)
Button("Change Password…") { showingAccountManager = true }
}.frame(maxWidth: .infinity, alignment: .leading).padding(10)
}
case .limits:
GroupBox("Account Limits") {
VStack(alignment: .leading, spacing: 12) {
accountLimit("Max. Queries / hour", row: row, index: 4)
accountLimit("Max. Updates / hour", row: row, index: 5)
accountLimit("Max. Connections / hour", row: row, index: 6)
accountLimit("Concurrent Connections", row: row, index: 7)
Text("0 means unlimited. Values are read from mysql.user.")
.font(.caption).foregroundStyle(.secondary)
}.frame(maxWidth: .infinity, alignment: .leading).padding(10)
}
case .roles:
GroupBox("Administrative Roles and Global Privileges") {
VStack(alignment: .leading, spacing: 12) {
Text("MySQL grants and roles are authoritative. Workbench-style role presets are not MySQL server roles.")
.font(.caption).foregroundStyle(.secondary)
Button("Load Current Grants") { Task { await loadAccountGrants() } }
if let grantsError { Text(grantsError).foregroundStyle(.red).textSelection(.enabled) }
ForEach(grants, id: \.self) { grant in
Label(grant, systemImage: "checkmark.shield")
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
}
Button("Manage Privileges…") { showingAccountManager = true }
}.frame(maxWidth: .infinity, alignment: .leading).padding(10)
}
case .schemas:
GroupBox("Schema Privileges") {
VStack(alignment: .leading, spacing: 12) {
Text("Schema-level GRANT statements for this account")
.foregroundStyle(.secondary)
Button("Load Schema Grants") { Task { await loadAccountGrants() } }
ForEach(grants.filter { $0.uppercased().contains(" ON ") && !$0.contains("*.*") }, id: \.self) { grant in
Text(grant).font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
}
if let grantsError { Text(grantsError).foregroundStyle(.red).textSelection(.enabled) }
Button("Add / Revoke Schema Privilege…") { showingAccountManager = true }
}.frame(maxWidth: .infinity, alignment: .leading).padding(10)
}
}
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
}
Divider()
HStack {
Button("View All Grants…") { Task { await showGrants() } }
Spacer()
Button("Edit Account…") { showingAccountManager = true }
}.padding(14)
Spacer(minLength: 0)
} else {
ContentUnavailableView("Select a MySQL Account", systemImage: "person.crop.circle.badge.checkmark", description: Text("Choose an account on the left to inspect its login, authentication and privileges."))
@@ -3620,6 +3749,29 @@ struct WorkbenchAdministrationView: View {
.frame(height: 30)
}
private func accountLimit(_ label: String, row: [String], index: Int) -> some View {
LabeledContent(label, value: index < row.count ? row[index] : "Unavailable")
}
@MainActor
private func loadAccountGrants() async {
guard let profile, let selectedAccount else { return }
let components = selectedAccount.split(separator: "\u{001f}", omittingEmptySubsequences: false)
guard components.count == 2 else { return }
func quote(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "''") + "'" }
grants = []
grantsError = nil
do {
let sql = "SHOW GRANTS FOR \(quote(String(components[0])))@\(quote(String(components[1])))"
let response = try await NativeMySQLRunner.run(profile: profile, database: "", password: PasswordStore.read(profile.id), sql: sql)
guard self.selectedAccount == selectedAccount else { return }
grants = response.rows.compactMap { $0.first }
} catch {
guard self.selectedAccount == selectedAccount else { return }
grantsError = error.localizedDescription
}
}
@MainActor
private func showGrants() async {
guard let profile, let selectedAccount else { return }
+10
View File
@@ -0,0 +1,10 @@
# Phase 74.4 — Account and Server Options Inspector
## Users and Privileges
The account inspector now provides Login, Account Limits, Administrative Roles, and Schema Privileges tabs. Account limits are read from mysql.user; grant information is loaded on demand with SHOW GRANTS. Editing continues through the existing SQL-review and confirmation sheet.
## Options File
The Options File navigation page groups live `SHOW GLOBAL VARIABLES` by category, with filtering and selectable values. **This is a read-only server-variable inspector, not an editor of my.cnf**. Actual configuration-file editing would require secure server filesystem access, explicit target selection, backups, validation, and restart handling.
## Limitations
MySQL Workbench role presets and a complete schema privilege matrix are not implemented. Account-limit editing is not implemented. Native macOS compilation and database integration testing were not available in this environment.