diff --git a/DBSyn/MainView.swift b/DBSyn/MainView.swift index 7b68793..2bd8c61 100644 --- a/DBSyn/MainView.swift +++ b/DBSyn/MainView.swift @@ -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 } diff --git a/docs/PHASE-74.4.md b/docs/PHASE-74.4.md new file mode 100644 index 0000000..01de745 --- /dev/null +++ b/docs/PHASE-74.4.md @@ -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.