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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user