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 10:49:47 +01:00
parent 939522d98c
commit 9cd4a3c443
21 changed files with 345 additions and 181 deletions
+121
View File
@@ -2211,6 +2211,14 @@ struct WorkbenchAdministrationView: View {
@State private var processActionError: String? = nil
@State private var processActionStatus: String? = nil
@State private var terminatingProcess = false
// Phase 25/26: bounded, in-session samples. No credentials or SQL persisted.
@State private var diagnosticSamples: [DiagnosticSample] = []
@State private var showingTrends = false
private var supportsTrends: Bool {
page == .slowQueries || page == .connectionStatistics ||
page == .innodbMetrics || page == .dashboard || page == .serverStatus
}
private var diagnosticGuidance: String? {
switch page {
@@ -2284,6 +2292,20 @@ struct WorkbenchAdministrationView: View {
.padding(.vertical, 8)
}
if supportsTrends && !diagnosticSamples.isEmpty {
HStack(spacing: 12) {
Label("\(diagnosticSamples.count) samples", systemImage: "chart.xyaxis.line")
.foregroundStyle(.secondary)
Button("View Trends…") { showingTrends = true }
Button("Clear Samples") { diagnosticSamples.removeAll() }
Spacer()
Text("Samples are kept in memory for this screen only")
.font(.caption).foregroundStyle(.secondary)
}
.padding(.horizontal, 16)
.padding(.vertical, 6)
}
if page == .clientConnections {
processControls
}
@@ -2361,6 +2383,9 @@ struct WorkbenchAdministrationView: View {
)
}
}
.sheet(isPresented: $showingTrends) {
WorkbenchDiagnosticTrendsView(page: page, samples: diagnosticSamples)
}
.sheet(isPresented: $showingGrants) {
VStack(alignment: .leading, spacing: 12) {
Text("Grants: \(selectedAccount ?? "")")
@@ -2626,6 +2651,12 @@ struct WorkbenchAdministrationView: View {
)
result = response
if supportsTrends {
diagnosticSamples.append(DiagnosticSample(at: Date(), columns: response.columns, rows: response.rows))
if diagnosticSamples.count > 60 {
diagnosticSamples.removeFirst(diagnosticSamples.count - 60)
}
}
if page == .clientConnections,
let selectedProcessID,
!processIDs.contains(selectedProcessID) {
@@ -4055,3 +4086,93 @@ private struct WorkbenchAccountManagerView: View {
}
}
}
// MARK: - Phase 25/26: Live performance samples and counter rates
private struct DiagnosticSample: Identifiable {
let id = UUID()
let at: Date
let columns: [String]
let rows: [[String]]
}
private struct WorkbenchDiagnosticTrendsView: View {
let page: WorkbenchAdminPage
let samples: [DiagnosticSample]
@Environment(\.dismiss) private var dismiss
private func metricMap(_ sample: DiagnosticSample) -> [String: Double] {
var values: [String: Double] = [:]
if page == .slowQueries {
guard let name = sample.columns.firstIndex(of: "DIGEST_TEXT"),
let count = sample.columns.firstIndex(of: "COUNT_STAR") else { return values }
for row in sample.rows where row.indices.contains(name) && row.indices.contains(count) {
if let value = Double(row[count]) { values[row[name]] = value }
}
} else {
for row in sample.rows where row.count >= 2 {
if let value = Double(row[1]) { values[row[0]] = value }
}
}
return values
}
private var comparisons: [(String, Double, Double, Double?)] {
guard let latest = samples.last else { return [] }
let current = metricMap(latest)
let prior = samples.count > 1 ? metricMap(samples[samples.count - 2]) : [:]
let seconds = samples.count > 1
? latest.at.timeIntervalSince(samples[samples.count - 2].at) : 0
return current.keys.sorted().map { key in
let value = current[key] ?? 0
let before = prior[key] ?? value
let rate: Double? = seconds > 0 && prior[key] != nil && value >= before
? (value - before) / seconds : nil
return (key, value, before, rate)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("\(page.rawValue) — Trends").font(.title2.bold())
Spacer()
Button("Close") { dismiss() }
}
Text("\(samples.count) samples. Rates compare the two latest successful refreshes; cumulative counters can reset when MySQL restarts or statistics are cleared.")
.font(.caption).foregroundStyle(.secondary)
if samples.count < 2 {
ContentUnavailableView("More samples needed", systemImage: "chart.xyaxis.line",
description: Text("Refresh this screen twice, or enable auto-refresh."))
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
HStack {
Text("Metric").frame(maxWidth: .infinity, alignment: .leading)
Text("Current").frame(width: 110, alignment: .trailing)
Text("Previous").frame(width: 110, alignment: .trailing)
Text("Change/sec").frame(width: 110, alignment: .trailing)
}.font(.headline)
Divider()
ForEach(comparisons.indices, id: \.self) { index in
let item = comparisons[index]
HStack {
Text(item.0).lineLimit(2).frame(maxWidth: .infinity, alignment: .leading)
Text(item.1.formatted()).frame(width: 110, alignment: .trailing)
Text(item.2.formatted()).frame(width: 110, alignment: .trailing)
Text(item.3.map { $0.formatted(.number.precision(.fractionLength(2))) } ?? "—")
.frame(width: 110, alignment: .trailing)
}.font(.system(.caption, design: .monospaced))
Divider()
}
}
}
}
Text("Query digest counts are grouped by SQL fingerprint. This is a sampled comparison, not a durable monitoring database.")
.font(.caption).foregroundStyle(.secondary)
}
.padding(20)
.frame(minWidth: 820, minHeight: 480)
}
}