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:
Mark Gregory
2026-10-17 15:26:46 +01:00
parent d441aea02c
commit 283fa76162
37 changed files with 294 additions and 363 deletions
+263 -34
View File
@@ -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.")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 530 KiB

After

Width:  |  Height:  |  Size: 460 KiB