diff --git a/.idea/WorkbenchReplacement.iml b/.idea/WorkbenchReplacement.iml index 7e873e8..57c2a66 100644 --- a/.idea/WorkbenchReplacement.iml +++ b/.idea/WorkbenchReplacement.iml @@ -2,6 +2,7 @@ + diff --git a/WorkbenchReplacement/MainView.swift b/WorkbenchReplacement/MainView.swift index b956ac0..f951174 100644 --- a/WorkbenchReplacement/MainView.swift +++ b/WorkbenchReplacement/MainView.swift @@ -323,6 +323,7 @@ struct MainView: View { "MANAGEMENT", pages: [ .serverStatus, + .serverCounters, .clientConnections, .users, .variables, @@ -2060,6 +2061,7 @@ struct NativeResultGrid: NSViewRepresentable { enum WorkbenchAdminPage: String, CaseIterable, Identifiable { case serverStatus = "Server Status" + case serverCounters = "Server Counters" case clientConnections = "Client Connections" case users = "Users and Privileges" case variables = "Status and System Variables" @@ -2084,6 +2086,7 @@ enum WorkbenchAdminPage: String, CaseIterable, Identifiable { case .connectionStatistics: return "network" case .serverLogs: return "text.alignleft" case .serverStatus: return "server.rack" + case .serverCounters: return "chart.xyaxis.line" case .clientConnections: return "person.2" case .users: return "person.crop.circle.badge.checkmark" case .variables: return "slider.horizontal.3" @@ -2144,19 +2147,10 @@ enum WorkbenchAdminPage: String, CaseIterable, Identifiable { """ case .serverLogs: return "SELECT LOGGED, PRIO, ERROR_CODE, SUBSYSTEM, DATA FROM performance_schema.error_log ORDER BY LOGGED DESC LIMIT 500" + case .serverCounters: + return "SHOW GLOBAL STATUS" case .serverStatus: - return """ - SHOW GLOBAL STATUS WHERE Variable_name IN ( - 'Uptime', - 'Threads_connected', - 'Threads_running', - 'Questions', - 'Queries', - 'Connections', - 'Bytes_received', - 'Bytes_sent' - ) - """ + return "SHOW GLOBAL STATUS" case .clientConnections: return "SHOW FULL PROCESSLIST" @@ -2237,7 +2231,7 @@ struct WorkbenchAdministrationView: View { private var supportsTrends: Bool { page == .slowQueries || page == .connectionStatistics || - page == .innodbMetrics || page == .dashboard || page == .serverStatus || page == .variables + page == .innodbMetrics || page == .dashboard || page == .serverStatus || page == .serverCounters } private var diagnosticGuidance: String? { @@ -2514,7 +2508,7 @@ struct WorkbenchAdministrationView: View { Spacer() - if page != .dashboard && page != .serverStatus { + if page != .dashboard { TextField("Filter results", text: $searchText) .textFieldStyle(.roundedBorder) .frame(width: 175) @@ -2540,78 +2534,73 @@ struct WorkbenchAdministrationView: View { .padding(18) } + // Keep the administration pages distinct: the complete server-status table and + // system-variable grid must never be replaced by a dashboard of metric cards. @ViewBuilder private var resultContent: some View { - if page == .dashboard || page == .serverStatus || page == .connectionStatistics || page == .innodbMetrics || page == .variables { + switch page { + case .serverCounters: + // Keep live charts permanently visible while the complete status table scrolls. + // Previously both were inside a single ScrollView, so scrolling to the + // counters made it appear as though the graphs had disappeared. + VStack(alignment: .leading, spacing: 10) { + HStack { + Label("Live Server Counters", systemImage: "chart.xyaxis.line") + .font(.title2.bold()) + Spacer() + Text("\(diagnosticSamples.count) samples · updates every 5 seconds") + .font(.caption).foregroundStyle(.secondary) + } + WorkbenchPinnedCounterGraphs(samples: diagnosticSamples) + .frame(height: 222) + HStack { + Text("All MySQL server statistics") + .font(.headline) + Spacer() + Text("\(filteredRows.count) counters") + .font(.caption).foregroundStyle(.secondary) + } + WorkbenchColouredCounterTable(columns: result.columns, rows: filteredRows) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .padding(14) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + case .dashboard: ScrollView { VStack(alignment: .leading, spacing: 18) { - if page == .dashboard || page == .serverStatus { - WorkbenchInlinePerformanceDashboard(samples: diagnosticSamples) - } else { - WorkbenchCounterCharts(page: page, samples: diagnosticSamples) - } - Text(page == .innodbMetrics ? "InnoDB counters" : page == .variables ? "Server variables and counters" : "Server counters") - .font(.headline) - LazyVGrid( - columns: [ - GridItem(.adaptive(minimum: 180), spacing: 12) - ], - spacing: 12 - ) { - ForEach(result.rows.indices, id: \.self) { index in - let row = result.rows[index] - - if row.count >= 2 { - VStack(alignment: .leading, spacing: 10) { - Text( - row[0] - .replacingOccurrences( - of: "_", - with: " " - ) - ) - .font(.caption) - .foregroundStyle(.secondary) - - Text(row[1]) - .foregroundStyle(WorkbenchCounterCharts.tint(for: row[0])) - .font( - .system( - size: 23, - weight: .semibold, - design: .rounded - ) - ) - .lineLimit(1) - .minimumScaleFactor(0.65) - } - .frame( - maxWidth: .infinity, - minHeight: 90, - alignment: .leading - ) - .padding(14) - .background( - WorkbenchCounterCharts.tint(for: row[0]).opacity(0.09) - ) - .clipShape( - RoundedRectangle(cornerRadius: 10) - ) - } - } - } + WorkbenchInlinePerformanceDashboard(samples: diagnosticSamples) + Text("Server statistics").font(.title2.bold()) + NativeResultGrid(columns: result.columns, rows: filteredRows) + .frame(minHeight: 340) } .padding(18) } - } else { - NativeResultGrid( - columns: result.columns, - rows: filteredRows - ) - .frame( - maxWidth: .infinity, - maxHeight: .infinity - ) + case .serverStatus: + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Text("Server Status").font(.title2.bold()) + WorkbenchInlinePerformanceDashboard(samples: diagnosticSamples) + Text("Server statistics").font(.title2.bold()) + NativeResultGrid(columns: result.columns, rows: filteredRows) + .frame(minHeight: 420) + } + .padding(18) + } + case .connectionStatistics, .innodbMetrics: + ScrollView { + VStack(alignment: .leading, spacing: 18) { + WorkbenchCounterCharts(page: page, samples: diagnosticSamples) + Text("All statistics").font(.title2.bold()) + NativeResultGrid(columns: result.columns, rows: filteredRows) + .frame(minHeight: 440) + } + .padding(18) + } + default: + // Status and System Variables and all other administration screens + // retain their original searchable tabular presentation. + NativeResultGrid(columns: result.columns, rows: filteredRows) + .frame(maxWidth: .infinity, maxHeight: .infinity) } } @@ -4203,6 +4192,122 @@ private struct WorkbenchInlinePerformanceDashboard: View { } // MARK: - Phase 29: Inline coloured counter graphs +// MARK: - Phase 33: pinned server graphs and complete coloured statistics +private struct WorkbenchPinnedCounterGraphs: View { + let samples: [DiagnosticSample] + private let metrics = ["Queries", "Questions", "Threads_connected", "Threads_running", "Slow_queries", "Bytes_received", "Bytes_sent", "Innodb_buffer_pool_reads", "Innodb_data_reads"] + + private func sampleValue(_ sample: DiagnosticSample, metric: String) -> Double? { + guard let row = sample.rows.first(where: { $0.count > 1 && $0[0].caseInsensitiveCompare(metric) == .orderedSame }) else { return nil } + return Double(row[1]) + } + + private func values(for metric: String) -> [CounterGraphPoint] { + samples.indices.compactMap { index in + guard let current = sampleValue(samples[index], metric: metric) else { return nil } + if metric.hasPrefix("Threads_") { + return CounterGraphPoint(time: samples[index].at, value: current) + } + guard index > 0, let prior = sampleValue(samples[index - 1], metric: metric), current >= prior else { return nil } + let seconds = samples[index].at.timeIntervalSince(samples[index - 1].at) + guard seconds > 0 else { return nil } + return CounterGraphPoint(time: samples[index].at, value: (current - prior) / seconds) + } + } + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 12) { + ForEach(metrics, id: \.self) { metric in + let points = values(for: metric) + let colour = WorkbenchCounterCharts.tint(for: metric) + VStack(alignment: .leading, spacing: 8) { + Text(metric.replacingOccurrences(of: "_", with: " ")) + .font(.subheadline.bold()) + HStack(alignment: .firstTextBaseline) { + Text(points.last.map { $0.value.formatted(.number.precision(.fractionLength(1))) } ?? "—") + .font(.title2.bold()).foregroundStyle(colour) + Text(metric.hasPrefix("Threads_") ? "current" : "/sec") + .font(.caption).foregroundStyle(.secondary) + } + Chart(points) { point in + AreaMark(x: .value("Time", point.time), y: .value("Value", point.value)) + .foregroundStyle(colour.opacity(0.18)) + LineMark(x: .value("Time", point.time), y: .value("Value", point.value)) + .foregroundStyle(colour) + .lineStyle(StrokeStyle(lineWidth: 2.5)) + } + .chartYAxis { AxisMarks(position: .leading) } + .frame(height: 105) + if points.isEmpty { + Text("Waiting for samples") + .font(.caption2).foregroundStyle(.secondary) + } + } + .padding(12) + .frame(width: 290, height: 200, alignment: .topLeading) + .background(colour.opacity(0.09)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + .padding(.horizontal, 2) + } + } +} + +private struct CounterGraphPoint: Identifiable { + let id = UUID() + let time: Date + let value: Double +} + +private struct WorkbenchColouredCounterTable: View { + let columns: [String] + let rows: [[String]] + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Variable name").frame(maxWidth: .infinity, alignment: .leading) + Text("Value").frame(width: 210, alignment: .trailing) + } + .font(.subheadline.bold()) + .padding(.horizontal, 12).padding(.vertical, 10) + .background(Color.secondary.opacity(0.09)) + Divider() + ScrollView { + LazyVStack(spacing: 0) { + ForEach(rows.indices, id: \.self) { index in + let row = rows[index] + if row.count >= 2 { + let colour = WorkbenchCounterCharts.tint(for: row[0]) + HStack(spacing: 10) { + RoundedRectangle(cornerRadius: 2) + .fill(colour).frame(width: 4, height: 22) + Text(row[0]) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + .help(row[0]) + Spacer(minLength: 8) + Text(WorkbenchCounterCharts.displayValue(row[1], for: row[0])) + .font(.system(.body, design: .monospaced).weight(.semibold)) + .foregroundStyle(colour) + .lineLimit(1) + .help(row[1]) + } + .padding(.horizontal, 12).padding(.vertical, 7) + .background(index.isMultiple(of: 2) ? Color.secondary.opacity(0.045) : Color.clear) + Divider().opacity(0.35) + } + } + } + } + } + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.18))) + } +} + private struct WorkbenchCounterCharts: View { let page: WorkbenchAdminPage let samples: [DiagnosticSample] @@ -4223,12 +4328,30 @@ private struct WorkbenchCounterCharts: View { return .blue } + static func displayValue(_ raw: String, for name: String) -> String { + guard let number = Double(raw), number.isFinite else { return raw } + let key = name.lowercased() + if key == "uptime" { + let seconds = max(0, Int(number)) + let days = seconds / 86400 + let hours = (seconds % 86400) / 3600 + let minutes = (seconds % 3600) / 60 + return days > 0 ? "\(days)d \(hours)h \(minutes)m" : "\(hours)h \(minutes)m" + } + if key.contains("bytes") { + return ByteCountFormatter.string(fromByteCount: Int64(number), countStyle: .binary) + } + return number.formatted(.number.grouping(.automatic).precision(.fractionLength(0...2))) + } + private var metrics: [String] { switch page { case .innodbMetrics: return ["Innodb_buffer_pool_reads", "Innodb_buffer_pool_read_requests", "Innodb_data_reads", "Innodb_data_writes", "Innodb_rows_read", "Innodb_row_lock_waits"] case .connectionStatistics: return ["Threads_connected", "Threads_running", "Connections", "Queries", "Slow_queries", "Aborted_connects"] + case .serverCounters: + return ["Queries", "Questions", "Threads_connected", "Threads_running", "Slow_queries", "Bytes_received", "Bytes_sent", "Innodb_buffer_pool_reads", "Innodb_data_reads"] case .variables: return ["Queries", "Threads_connected", "Threads_running", "Connections", "Slow_queries", "Bytes_sent"] default: @@ -4287,7 +4410,7 @@ private struct WorkbenchCounterCharts: View { var body: some View { VStack(alignment: .leading, spacing: 12) { HStack { - Label("Live counter graphs", systemImage: "chart.xyaxis.line") + Label(page == .serverCounters ? "Server Counters · Live Graphs" : "Live counter graphs", systemImage: "chart.xyaxis.line") .font(.title2.bold()) Spacer() Text("\(samples.count) samples · 5-second refresh") diff --git a/docs/PHASE30-NOTES.md b/docs/PHASE30-NOTES.md new file mode 100644 index 0000000..d6917b0 --- /dev/null +++ b/docs/PHASE30-NOTES.md @@ -0,0 +1,11 @@ +# Phase 30 — Server Counters graphs fix + +- Adds a dedicated **Server Counters** item under **Administration → Management**. +- Retrieves all actual server counters using `SHOW GLOBAL STATUS` (not `SHOW GLOBAL VARIABLES`). +- Places nine colour-coded Swift Charts above the counters, directly on the Server Counters screen. +- Graphs show current thread counts and per-second rates for cumulative counters. +- Counter cards remain visible beneath the charts; the filter field filters the cards. +- Samples are collected every five seconds when Auto-refresh is enabled. The first rate point requires two successful samples. +- Existing dashboard, SQL editor and schema editor are preserved. + +This release was not compiled on macOS or tested against a live MySQL server. diff --git a/docs/PHASE31-NOTES.md b/docs/PHASE31-NOTES.md new file mode 100644 index 0000000..3bc03c3 --- /dev/null +++ b/docs/PHASE31-NOTES.md @@ -0,0 +1,7 @@ +# Phase 31 — Server Counters Layout + +- Server counter cards appear first, with a compact responsive grid. +- Live graphs appear directly below the counter cards in the same scroll view. +- Bytes counters are displayed in readable units; uptime in days/hours/minutes; numeric counters use separators. +- Existing graph collection, automatic refresh and schema editing are retained. +- Requires macOS/Xcode compilation and live MySQL verification. diff --git a/docs/PHASE32-NOTES.md b/docs/PHASE32-NOTES.md new file mode 100644 index 0000000..41ebcdc --- /dev/null +++ b/docs/PHASE32-NOTES.md @@ -0,0 +1,8 @@ +# Phase 32 — Administration View Restoration + +- Server Counters: inline coloured graphs followed by the complete searchable MySQL status grid (no omitted counters). +- Server Status: full SHOW GLOBAL STATUS data restored below the performance overview, with search. +- Status and System Variables: restored original native table/grid presentation; no inappropriate charts or counter tiles. +- Connection Statistics and InnoDB: retain graphs and complete tabular results. +- No MySQL mutations; monitoring remains read-only. +- Xcode build and live MySQL validation must be performed on macOS. diff --git a/docs/PHASE33-NOTES.md b/docs/PHASE33-NOTES.md new file mode 100644 index 0000000..f9e9693 --- /dev/null +++ b/docs/PHASE33-NOTES.md @@ -0,0 +1 @@ +Phase 33: Server Counters fixed split layout. Nine real MySQL sample charts pinned in horizontal scroll strip above independently scrolling full coloured SHOW GLOBAL STATUS table. All counters retained. Auto-refresh does not blank existing data. No Xcode build performed.