Improved progress display
Previously, the progress bar updated only after completing an entire table. For large tables such as LIBARC, this could make the application appear to have stopped responding. Phase 10 updates the interface while rows are being exported.
This commit is contained in:
@@ -2151,7 +2151,18 @@ enum WorkbenchAdminPage: String, CaseIterable, Identifiable {
|
||||
case .serverCounters:
|
||||
return "SHOW GLOBAL STATUS"
|
||||
case .serverStatus:
|
||||
return "SHOW GLOBAL STATUS"
|
||||
return """
|
||||
SHOW GLOBAL STATUS WHERE Variable_name IN (
|
||||
'Uptime', 'Connections', 'Max_used_connections',
|
||||
'Threads_connected', 'Threads_running', 'Threads_cached',
|
||||
'Queries', 'Questions', 'Slow_queries',
|
||||
'Bytes_received', 'Bytes_sent',
|
||||
'Aborted_clients', 'Aborted_connects',
|
||||
'Innodb_buffer_pool_read_requests', 'Innodb_buffer_pool_reads',
|
||||
'Innodb_data_reads', 'Innodb_data_writes',
|
||||
'Created_tmp_tables', 'Created_tmp_disk_tables'
|
||||
)
|
||||
"""
|
||||
|
||||
case .clientConnections:
|
||||
return "SHOW FULL PROCESSLIST"
|
||||
@@ -2535,48 +2546,32 @@ 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.
|
||||
// The live charts occupy a fixed region ABOVE an independently scrolling
|
||||
// counter table. They cannot disappear when the user scrolls the rows.
|
||||
@ViewBuilder
|
||||
private var resultContent: some View {
|
||||
switch page {
|
||||
case .serverCounters:
|
||||
WorkbenchServerCountersScreen(samples: diagnosticSamples, rows: filteredRows)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .serverStatus, .serverCounters, .connectionStatistics, .innodbMetrics:
|
||||
WorkbenchLiveMonitorScreen(
|
||||
page: page,
|
||||
samples: diagnosticSamples,
|
||||
rows: filteredRows,
|
||||
allRowCount: result.rows.count
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .dashboard:
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
WorkbenchInlinePerformanceDashboard(samples: diagnosticSamples)
|
||||
Text("Server statistics").font(.title2.bold())
|
||||
NativeResultGrid(columns: result.columns, rows: filteredRows)
|
||||
.frame(minHeight: 340)
|
||||
}
|
||||
.padding(18)
|
||||
}
|
||||
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)
|
||||
// Dashboard must not show the legacy, unformatted Variable_name table.
|
||||
Text("Server overview").font(.title2.bold())
|
||||
WorkbenchServerOverview(rows: filteredRows)
|
||||
}
|
||||
.padding(18)
|
||||
}
|
||||
default:
|
||||
// Status and System Variables and all other administration screens
|
||||
// retain their original searchable tabular presentation.
|
||||
// Status and System Variables is deliberately a distinct variable
|
||||
// inspector, never replaced by the monitoring screen.
|
||||
NativeResultGrid(columns: result.columns, rows: filteredRows)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
@@ -2632,6 +2627,7 @@ struct WorkbenchAdministrationView: View {
|
||||
return
|
||||
}
|
||||
|
||||
guard !loading else { return }
|
||||
loading = true
|
||||
errorMessage = nil
|
||||
|
||||
@@ -4155,7 +4151,7 @@ private struct WorkbenchInlinePerformanceDashboard: View {
|
||||
Text("\(samples.count) samples · refreshes every 5 seconds")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 410), spacing: 16)], spacing: 12) {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 285), spacing: 14)], spacing: 12) {
|
||||
panel("Queries per second", metric: "Queries", rate: true, tint: .blue)
|
||||
panel("Active connections", metric: "Threads_connected", rate: false, tint: .teal)
|
||||
panel("Running threads", metric: "Threads_running", rate: false, tint: .orange)
|
||||
@@ -4170,6 +4166,239 @@ private struct WorkbenchInlinePerformanceDashboard: View {
|
||||
}
|
||||
|
||||
// MARK: - Phase 34: Responsive server-counter workspace
|
||||
// MARK: - Phase 39: pinned, visible monitoring graphs
|
||||
// The charts and counters are siblings, not nested in one long ScrollView.
|
||||
// Single administration monitoring view. The chart region is deliberately outside
|
||||
// the counter ScrollView, so scrolling hundreds of counters cannot hide charts.
|
||||
private struct WorkbenchServerOverview: View {
|
||||
let rows: [[String]]
|
||||
private let names = ["Uptime", "Threads_connected", "Threads_running", "Queries", "Slow_queries", "Bytes_received", "Bytes_sent", "Connections"]
|
||||
var body: some View {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 175), spacing: 12)], spacing: 12) {
|
||||
ForEach(names, id: \.self) { name in
|
||||
let raw = rows.first(where: { $0.count >= 2 && $0[0].caseInsensitiveCompare(name) == .orderedSame })?.last ?? "—"
|
||||
let tint = WorkbenchCounterCharts.tint(for: name)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(name.replacingOccurrences(of: "_", with: " "))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Text(WorkbenchCounterCharts.displayValue(raw, for: name))
|
||||
.font(.title3.bold().monospacedDigit())
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14)
|
||||
.background(tint.opacity(0.07), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WorkbenchLiveMonitorScreen: View {
|
||||
let page: WorkbenchAdminPage
|
||||
let samples: [DiagnosticSample]
|
||||
let rows: [[String]]
|
||||
let allRowCount: Int
|
||||
|
||||
@State private var categorySelection = "All"
|
||||
@State private var chartGroup = "Activity"
|
||||
|
||||
private let categories = ["All", "Connections", "Queries", "InnoDB", "Network", "Errors", "Replication", "Other"]
|
||||
|
||||
private var pageTitle: String {
|
||||
switch page {
|
||||
case .serverStatus: return "Server Status"
|
||||
case .serverCounters: return "Server Counters"
|
||||
case .connectionStatistics: return "Connection Statistics"
|
||||
case .innodbMetrics: return "InnoDB Metrics"
|
||||
default: return "Server Monitoring"
|
||||
}
|
||||
}
|
||||
|
||||
private func category(for name: String) -> String {
|
||||
let n = name.lowercased()
|
||||
if n.hasPrefix("innodb_") { return "InnoDB" }
|
||||
if n.contains("replica") || n.contains("slave") || n.contains("binlog") || n.contains("relay_log") { return "Replication" }
|
||||
if n.contains("abort") || n.contains("error") || n.contains("failed") || n.contains("denied") { return "Errors" }
|
||||
if n.contains("bytes_") || n.contains("network") { return "Network" }
|
||||
if n.contains("connection") || n.contains("thread") || n.contains("client") { return "Connections" }
|
||||
if n.contains("quer") || n.contains("question") || n.hasPrefix("com_") || n.contains("select") { return "Queries" }
|
||||
return "Other"
|
||||
}
|
||||
|
||||
private var visibleRows: [[String]] {
|
||||
categorySelection == "All" ? rows : rows.filter { $0.count > 1 && category(for: $0[0]) == categorySelection }
|
||||
}
|
||||
|
||||
private var metrics: [String] {
|
||||
if page == .connectionStatistics {
|
||||
return ["Threads_connected", "Threads_running", "Connections", "Aborted_connects"]
|
||||
}
|
||||
if page == .innodbMetrics {
|
||||
return ["Innodb_buffer_pool_read_requests", "Innodb_buffer_pool_reads", "Innodb_data_reads", "Innodb_data_writes"]
|
||||
}
|
||||
switch chartGroup {
|
||||
case "Network": return ["Bytes_received", "Bytes_sent", "Connections", "Aborted_connects"]
|
||||
case "InnoDB": return ["Innodb_buffer_pool_read_requests", "Innodb_buffer_pool_reads", "Innodb_data_reads", "Innodb_data_writes"]
|
||||
default: return ["Queries", "Threads_connected", "Threads_running", "Slow_queries"]
|
||||
}
|
||||
}
|
||||
|
||||
private func value(_ metric: String, in sample: DiagnosticSample) -> Double? {
|
||||
guard let row = sample.rows.first(where: { $0.count > 1 && $0[0].caseInsensitiveCompare(metric) == .orderedSame }) else { return nil }
|
||||
return Double(row[1])
|
||||
}
|
||||
|
||||
private struct Reading: Identifiable {
|
||||
let id: Int
|
||||
let time: Date
|
||||
let value: Double
|
||||
}
|
||||
|
||||
private func readings(for metric: String) -> [Reading] {
|
||||
let gauge = metric == "Threads_connected" || metric == "Threads_running"
|
||||
return samples.indices.compactMap { index in
|
||||
guard let current = value(metric, in: samples[index]), current.isFinite else { return nil }
|
||||
if gauge { return Reading(id: index, time: samples[index].at, value: current) }
|
||||
guard index > 0, let previous = value(metric, in: samples[index - 1]), current >= previous else { return nil }
|
||||
let interval = samples[index].at.timeIntervalSince(samples[index - 1].at)
|
||||
guard interval > 0 else { return nil }
|
||||
return Reading(id: index, time: samples[index].at, value: (current - previous) / interval)
|
||||
}
|
||||
}
|
||||
|
||||
private func chart(_ metric: String) -> some View {
|
||||
let points = readings(for: metric)
|
||||
let tint = WorkbenchCounterCharts.tint(for: metric)
|
||||
let gauge = metric == "Threads_connected" || metric == "Threads_running"
|
||||
return VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(metric.replacingOccurrences(of: "_", with: " "))
|
||||
.font(.subheadline.weight(.semibold)).lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
Text(points.last.map { $0.value.formatted(.number.precision(.fractionLength(1))) } ?? "—")
|
||||
.font(.headline.monospacedDigit()).foregroundStyle(tint)
|
||||
Text(gauge ? "current" : "/sec")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
Chart(points) { point in
|
||||
AreaMark(x: .value("Time", point.time), y: .value("Value", point.value))
|
||||
.foregroundStyle(tint.opacity(0.16))
|
||||
LineMark(x: .value("Time", point.time), y: .value("Value", point.value))
|
||||
.foregroundStyle(tint).lineStyle(StrokeStyle(lineWidth: 2.5))
|
||||
}
|
||||
.chartYAxis { AxisMarks(position: .leading) }
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 90)
|
||||
.overlay {
|
||||
if points.isEmpty {
|
||||
Text("Waiting for samples…")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(9)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(tint.opacity(0.065), in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(tint.opacity(0.24)))
|
||||
}
|
||||
|
||||
private var statusSummary: some View {
|
||||
let names = ["Uptime", "Threads_connected", "Threads_running", "Queries", "Slow_queries", "Max_used_connections"]
|
||||
return HStack(spacing: 8) {
|
||||
ForEach(names, id: \.self) { name in
|
||||
let raw = rows.first(where: { $0.count > 1 && $0[0].caseInsensitiveCompare(name) == .orderedSame })?.last ?? "—"
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(name.replacingOccurrences(of: "_", with: " "))
|
||||
.font(.caption2).foregroundStyle(.secondary).lineLimit(1)
|
||||
Text(WorkbenchCounterCharts.displayValue(raw, for: name))
|
||||
.font(.headline.monospacedDigit()).lineLimit(1).minimumScaleFactor(0.7)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 45, alignment: .leading)
|
||||
.padding(9)
|
||||
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 9))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Charts and counters are sibling regions. Neither is wrapped in the
|
||||
// other region's ScrollView; the charts stay on-screen while rows scroll.
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let width = max(0, geometry.size.width - 32)
|
||||
let twoColumns = width >= 640
|
||||
let gridColumns = Array(repeating: GridItem(.flexible(minimum: 0), spacing: 12), count: twoColumns ? 2 : 1)
|
||||
let cardHeight: CGFloat = twoColumns ? 160 : 125
|
||||
let chartAreaHeight: CGFloat = twoColumns ? 334 : min(285, max(150, geometry.size.height * 0.40))
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
Label(pageTitle, systemImage: "chart.xyaxis.line")
|
||||
.font(.title3.bold())
|
||||
Spacer(minLength: 4)
|
||||
Text("Live · \(samples.count) samples · 5s")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
if page == .serverStatus || page == .serverCounters {
|
||||
Picker("Graphs", selection: $chartGroup) {
|
||||
Text("Activity").tag("Activity")
|
||||
Text("Network").tag("Network")
|
||||
Text("InnoDB").tag("InnoDB")
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 350)
|
||||
}
|
||||
|
||||
// A bounded graph viewport, with two complete columns on
|
||||
// desktop. On narrow windows, only this area scrolls.
|
||||
ScrollView(.vertical) {
|
||||
LazyVGrid(columns: gridColumns, spacing: 12) {
|
||||
ForEach(metrics, id: \.self) { metric in
|
||||
chart(metric)
|
||||
.frame(height: cardHeight)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.frame(height: min(chartAreaHeight, max(135, geometry.size.height * 0.54)))
|
||||
.accessibilityIdentifier("monitor-graphs-visible")
|
||||
|
||||
Divider()
|
||||
if page == .serverStatus {
|
||||
Text("Server overview")
|
||||
.font(.headline)
|
||||
ScrollView(.vertical) {
|
||||
WorkbenchServerOverview(rows: rows)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
HStack {
|
||||
Text(page == .connectionStatistics ? "Connection statistics" : page == .innodbMetrics ? "InnoDB statistics" : "All MySQL server counters")
|
||||
.font(.headline)
|
||||
Spacer(minLength: 6)
|
||||
if page == .serverCounters {
|
||||
Picker("Category", selection: $categorySelection) {
|
||||
ForEach(categories, id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
.frame(width: 155)
|
||||
}
|
||||
Text("\(visibleRows.count) counters")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
WorkbenchColouredCounterTable(columns: ["Metric", "Value"], rows: visibleRows)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .topLeading)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct WorkbenchServerCountersScreen: View {
|
||||
let samples: [DiagnosticSample]
|
||||
let rows: [[String]]
|
||||
@@ -4459,7 +4688,7 @@ private struct WorkbenchCounterCharts: View {
|
||||
Text("\(samples.count) samples · 5-second refresh")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 410), spacing: 16)], spacing: 12) {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 285), spacing: 14)], spacing: 12) {
|
||||
ForEach(metrics, id: \.self) { metric in graph(metric) }
|
||||
}
|
||||
Text("Live MySQL data • refreshes every five seconds. Rates appear after two successful samples; thread counts show current values.")
|
||||
|
||||
Reference in New Issue
Block a user