connection updates
This commit is contained in:
+171
-19
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user