Skip to content

Commit b416645

Browse files
authored
v3.1.1: Clickable dashboard cards and scrubbable history charts (#7)
CPU/Memory/Containers cards click through to the container list pre-sorted by that metric; history charts support hover/drag scrubbing with per-moment values via chartXSelection.
1 parent 7cf5959 commit b416645

2 files changed

Lines changed: 138 additions & 57 deletions

File tree

‎Sources/Portside/Support/AppVersion.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Foundation
33
/// Single source of truth for the app version.
44
/// `scripts/make_app.sh` extracts this value to stamp Info.plist and name the DMG.
55
enum AppVersion {
6-
static let marketing = "3.1.0"
6+
static let marketing = "3.1.1"
77

88
/// Prefers the bundle version when running from a built .app, falls back to
99
/// the compiled-in constant when running via `swift run`.

‎Sources/Portside/Views/DashboardView.swift‎

Lines changed: 137 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -47,21 +47,30 @@ struct DashboardView: View {
4747

4848
private var running: [ContainerSummary] { appState.containers.filter(\.isRunning) }
4949

50+
/// Jumps to the container list pre-sorted by the tapped metric.
51+
private func openContainers(sortedBy key: String) {
52+
UserDefaults.standard.set(key, forKey: "containerSortKey")
53+
UserDefaults.standard.set(false, forKey: "containerSortAscending")
54+
appState.page = .containers
55+
}
56+
5057
private var metricsRow: some View {
5158
HStack(spacing: 12) {
5259
MetricRingCard(
5360
title: "CPU",
5461
value: String(format: "%.1f%%", appState.hostCPU),
5562
subtitle: "\(appState.systemInfo?.NCPU ?? 0) cores · \(running.count) active",
5663
percent: appState.hostCPU,
57-
colorByLoad: true
64+
colorByLoad: true,
65+
action: { openContainers(sortedBy: "cpu") }
5866
)
5967
MetricRingCard(
6068
title: "Memory",
6169
value: Format.bytes(appState.hostMemUsed),
6270
subtitle: "of \(Format.bytes(appState.systemInfo?.MemTotal ?? 0))",
6371
percent: appState.hostMemPercent,
64-
colorByLoad: true
72+
colorByLoad: true,
73+
action: { openContainers(sortedBy: "memory") }
6574
)
6675
MetricRingCard(
6776
title: "Containers",
@@ -70,7 +79,8 @@ struct DashboardView: View {
7079
percent: appState.containers.isEmpty
7180
? 0 : Double(running.count) / Double(appState.containers.count) * 100,
7281
ringLabel: "\(running.count)/\(appState.containers.count)",
73-
colorByLoad: false
82+
colorByLoad: false,
83+
action: { openContainers(sortedBy: "state") }
7484
)
7585
MetricPlainCard(
7686
title: "Network",
@@ -152,70 +162,115 @@ struct DashboardView: View {
152162
title: String, now: String, points: [ChartPoint],
153163
percentDomain: Bool, colors: [String: Color]
154164
) -> some View {
155-
VStack(alignment: .leading, spacing: 6) {
156-
HStack {
157-
Text(title)
158-
.font(.system(size: 11, weight: .semibold))
159-
.foregroundStyle(.secondary)
160-
Spacer()
161-
Text(now)
162-
.font(.system(size: 11, weight: .medium, design: .monospaced))
165+
HistoryChartCard(
166+
title: title, now: now, points: points,
167+
percentDomain: percentDomain, colors: colors
168+
)
169+
}
170+
171+
/// One history chart with scrubbing: hover or drag shows the value at
172+
/// that point in time in the header, with a rule mark on the plot.
173+
private struct HistoryChartCard: View {
174+
var title: String
175+
var now: String
176+
var points: [ChartPoint]
177+
var percentDomain: Bool
178+
var colors: [String: Color]
179+
180+
@State private var selectedTime: Date?
181+
182+
var body: some View {
183+
VStack(alignment: .leading, spacing: 6) {
184+
HStack {
185+
Text(title)
186+
.font(.system(size: 11, weight: .semibold))
187+
.foregroundStyle(.secondary)
188+
Spacer()
189+
Text(headerValue)
190+
.font(.system(size: 11, weight: .medium, design: .monospaced))
191+
.foregroundStyle(selectedTime == nil ? AnyShapeStyle(.primary) : AnyShapeStyle(Color.accentColor))
192+
}
193+
chartBody
194+
.frame(height: 120)
163195
}
164-
baseChart(points: points, colors: colors, percentDomain: percentDomain)
165-
.frame(height: 120)
196+
.frame(maxWidth: .infinity)
166197
}
167-
.frame(maxWidth: .infinity)
168-
}
169198

170-
@ViewBuilder
171-
private func baseChart(points: [ChartPoint], colors: [String: Color], percentDomain: Bool) -> some View {
172-
let chart = Chart(points) { point in
173-
LineMark(
174-
x: .value("Time", point.time),
175-
y: .value("Value", point.value),
176-
series: .value("Series", point.series)
177-
)
178-
.foregroundStyle(colors[point.series] ?? .gray)
179-
.interpolationMethod(.monotone)
180-
.lineStyle(StrokeStyle(lineWidth: 1.5))
181-
182-
AreaMark(
183-
x: .value("Time", point.time),
184-
y: .value("Value", point.value),
185-
series: .value("Series", point.series),
186-
stacking: .unstacked
187-
)
188-
.foregroundStyle(colors[point.series] ?? .gray)
189-
.interpolationMethod(.monotone)
190-
.opacity(0.14)
199+
/// Values of every series at the scrubbed time, or the live reading.
200+
private var headerValue: String {
201+
guard let selectedTime else { return now }
202+
let bySeries = Dictionary(grouping: points, by: \.series)
203+
let parts: [String] = bySeries.keys.sorted().compactMap { series in
204+
guard let nearest = bySeries[series]?.min(by: {
205+
abs($0.time.timeIntervalSince(selectedTime)) < abs($1.time.timeIntervalSince(selectedTime))
206+
}) else { return nil }
207+
let value = percentDomain
208+
? String(format: "%.1f%%", nearest.value)
209+
: Format.rate(nearest.value)
210+
return bySeries.count > 1 ? "\(series == "Down" ? "↓" : "↑")\(value)" : value
211+
}
212+
let time = selectedTime.formatted(date: .omitted, time: .shortened)
213+
return "\(time) · \(parts.joined(separator: " "))"
191214
}
192-
.chartLegend(.hidden)
193-
.chartYAxis {
194-
AxisMarks(position: .leading, values: .automatic(desiredCount: 4)) { value in
195-
AxisGridLine().foregroundStyle(.quaternary)
196-
AxisValueLabel {
197-
if let number = value.as(Double.self) {
198-
Text(percentDomain ? "\(Int(number))%" : Format.rate(number))
199-
.font(.system(size: 8))
215+
216+
@ViewBuilder
217+
private var chartBody: some View {
218+
let chart = Chart {
219+
ForEach(points) { point in
220+
LineMark(
221+
x: .value("Time", point.time),
222+
y: .value("Value", point.value),
223+
series: .value("Series", point.series)
224+
)
225+
.foregroundStyle(colors[point.series] ?? .gray)
226+
.interpolationMethod(.monotone)
227+
.lineStyle(StrokeStyle(lineWidth: 1.5))
228+
229+
AreaMark(
230+
x: .value("Time", point.time),
231+
y: .value("Value", point.value),
232+
series: .value("Series", point.series),
233+
stacking: .unstacked
234+
)
235+
.foregroundStyle(colors[point.series] ?? .gray)
236+
.interpolationMethod(.monotone)
237+
.opacity(0.14)
238+
}
239+
if let selectedTime {
240+
RuleMark(x: .value("Time", selectedTime))
241+
.foregroundStyle(.secondary.opacity(0.55))
242+
.lineStyle(StrokeStyle(lineWidth: 1))
243+
}
244+
}
245+
.chartXSelection(value: $selectedTime)
246+
.chartLegend(.hidden)
247+
.chartYAxis {
248+
AxisMarks(position: .leading, values: .automatic(desiredCount: 4)) { value in
249+
AxisGridLine().foregroundStyle(.quaternary)
250+
AxisValueLabel {
251+
if let number = value.as(Double.self) {
252+
Text(percentDomain ? "\(Int(number))%" : Format.rate(number))
253+
.font(.system(size: 8))
254+
}
200255
}
201256
}
202257
}
203-
}
204-
.chartXAxis {
205-
AxisMarks(values: .automatic(desiredCount: 3)) { value in
206-
AxisValueLabel {
207-
if let date = value.as(Date.self) {
208-
Text(date, format: .dateTime.hour().minute())
209-
.font(.system(size: 8))
258+
.chartXAxis {
259+
AxisMarks(values: .automatic(desiredCount: 3)) { value in
260+
AxisValueLabel {
261+
if let date = value.as(Date.self) {
262+
Text(date, format: .dateTime.hour().minute())
263+
.font(.system(size: 8))
264+
}
210265
}
211266
}
212267
}
213-
}
214268

215-
if percentDomain {
216-
chart.chartYScale(domain: 0...100)
217-
} else {
218-
chart
269+
if percentDomain {
270+
chart.chartYScale(domain: 0...100)
271+
} else {
272+
chart
273+
}
219274
}
220275
}
221276

@@ -277,6 +332,22 @@ struct MetricRingCard: View {
277332
var percent: Double
278333
var ringLabel: String?
279334
var colorByLoad: Bool
335+
var action: (() -> Void)?
336+
337+
@State private var hovering = false
338+
339+
init(
340+
title: String, value: String, subtitle: String, percent: Double,
341+
ringLabel: String? = nil, colorByLoad: Bool, action: (() -> Void)? = nil
342+
) {
343+
self.title = title
344+
self.value = value
345+
self.subtitle = subtitle
346+
self.percent = percent
347+
self.ringLabel = ringLabel
348+
self.colorByLoad = colorByLoad
349+
self.action = action
350+
}
280351

281352
private var ringColor: Color {
282353
guard colorByLoad else { return .green }
@@ -313,8 +384,18 @@ struct MetricRingCard: View {
313384
.lineLimit(1)
314385
}
315386
Spacer(minLength: 0)
387+
if action != nil {
388+
Image(systemName: "chevron.right")
389+
.font(.system(size: 10, weight: .semibold))
390+
.foregroundStyle(.tertiary)
391+
.opacity(hovering ? 1 : 0.3)
392+
}
316393
}
317394
.glassCard(padding: 14)
395+
.contentShape(Rectangle())
396+
.onHover { hovering = $0 }
397+
.onTapGesture { action?() }
398+
.help(action != nil ? "Open the container list sorted by \(title.lowercased())" : "")
318399
}
319400
}
320401

0 commit comments

Comments
 (0)