@@ -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