Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,20 @@ are no component-level AGENTS.md files.
measurements of the same kind are not two directions of one flow. A group
that declares a direction must declare its opposite too, checked against the
real registry in `MonitorSourcesTests`.
- **A metric declares whether it is a slice of a whole.**
`MetricDescriptor.composition` is `.part`, `.aggregate` or nil. Memory (app,
wired, compressed, cached, free) and CPU (user, system) are slices; Memory
Used and CPU Total are the sums of them; Memory Swap is neither, being on
disk. **An aggregate must never be stacked** — Used is app plus wired plus
compressed, so a band for it counts those three twice and puts the top of the
card at nearly twice the machine's RAM. It keeps a line, which lands on top of
the bands it sums. Lines on a stacked card are **dashed**, with a hollow
legend swatch, because a solid stroke among bands reads as one more slice. A
card stacks only when it draws two or more slices, and its y-axis is bounded
by the **summed** height rather than the tallest band. `MonitorSourcesTests`
holds the claim against the real machine: the slices account for 85–102% of
physical RAM, so they do not overlap. Nothing is both a slice and a direction,
and a registry test proves it.
- **Paired charts can be mirrored, and only the picture flips.** Inbound draws
above the baseline and outbound below it when **Mirror paired charts** is on
in the Charts tab. The stored sample stays positive — a rate is never
Expand Down
47 changes: 44 additions & 3 deletions Sources/MonitorCore/ChartPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,28 @@ public enum ChartMirror {
}
}

/// Which cards are a whole divided into slices.
///
/// No table here either. A metric declares its own `composition`, so a card can
/// be stacked when it draws two or more slices — and a source added later gets
/// it without this file being told it exists.
public enum ChartStack {
/// The slices a card draws, in the order it draws them, or empty when it
/// has nothing to stack.
///
/// **Two or more.** A single band is an area chart with extra steps, and it
/// says nothing a line does not.
///
/// Everything else on the card keeps its line — an aggregate especially.
/// Memory Used is app plus wired plus compressed, so stacking it would
/// count those three twice; drawn as a line it lands exactly on top of the
/// bands it sums, which reads as a check rather than a contradiction.
public static func parts(of descriptors: [MetricDescriptor]) -> [MetricID] {
let parts = descriptors.filter { $0.composition == .part }
return parts.count >= 2 ? parts.map(\.id) : []
}
}

/// How the charts are drawn, as opposed to which ones there are.
///
/// Its own type beside `LayoutPreferences` rather than a field on it, because
Expand All @@ -60,8 +82,15 @@ public struct ChartPreferences: Codable, Equatable, Sendable {
/// they have to switch on.
public var mirrorsPairs: Bool

public init(mirrorsPairs: Bool = false) {
/// Draw a card's slices as stacked bands rather than as lines from zero.
///
/// Off by default, for the same reason mirroring is: a chart that changes
/// shape under somebody on upgrade is worse than one they switch on.
public var stacksParts: Bool

public init(mirrorsPairs: Bool = false, stacksParts: Bool = false) {
self.mirrorsPairs = mirrorsPairs
self.stacksParts = stacksParts
}

public static let `default` = ChartPreferences()
Expand All @@ -73,15 +102,27 @@ public struct ChartPreferences: Codable, Equatable, Sendable {
return ChartMirror.pair(for: descriptors)
}

/// The slices this card should stack, given the setting: empty when
/// stacking is off, and empty when the card has nothing to stack.
public func stack(for descriptors: [MetricDescriptor]) -> [MetricID] {
guard stacksParts else { return [] }
return ChartStack.parts(of: descriptors)
}

private enum CodingKeys: String, CodingKey {
case mirrorsPairs
case stacksParts
}

/// `decodeIfPresent`, so a value written before a setting existed still
/// decodes rather than resetting the whole struct to its defaults.
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let stored = try container.decodeIfPresent(Bool.self, forKey: .mirrorsPairs)
self.init(mirrorsPairs: stored ?? ChartPreferences.default.mirrorsPairs)
let mirrors = try container.decodeIfPresent(Bool.self, forKey: .mirrorsPairs)
let stacks = try container.decodeIfPresent(Bool.self, forKey: .stacksParts)
self.init(
mirrorsPairs: mirrors ?? ChartPreferences.default.mirrorsPairs,
stacksParts: stacks ?? ChartPreferences.default.stacksParts
)
}
}
28 changes: 27 additions & 1 deletion Sources/MonitorCore/Metric.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,27 @@ public enum MetricDirection: String, Sendable, Codable {
case outbound
}

/// How a metric relates to the others in its group.
///
/// Most have no relation: two temperature sensors are two readings, and neither
/// is a slice or a sum of the other. Some groups are a whole divided up —
/// memory is app, wired, compressed, cached and free — and there stacking the
/// slices shows the total as well as the split, which lines drawn from zero
/// cannot.
///
/// A fact about the metric, like `direction`. Whether the parts are then
/// *drawn* stacked is a preference in `ChartPreferences`.
public enum MetricComposition: String, Sendable, Codable {
/// One slice of the group's whole. Slices do not overlap, so they can be
/// stacked.
case part
/// A sum of other metrics in the same group: memory Used is app plus wired
/// plus compressed, CPU Total is user plus system. **Never stacked** — it
/// would count its own parts a second time. Drawn as a line over the bands,
/// where it lands exactly on top of the slices it sums.
case aggregate
}

/// Everything the UI needs to draw a series without knowing where it came from.
public struct MetricDescriptor: Hashable, Sendable, Codable {
public let id: MetricID
Expand All @@ -78,6 +99,9 @@ public struct MetricDescriptor: Hashable, Sendable, Codable {
/// Which way this one runs, when it is one direction of a flow. Nil for
/// everything that is not.
public let direction: MetricDirection?
/// Whether this one is a slice of its group's whole, or a sum of those
/// slices. Nil for the metrics that are neither.
public let composition: MetricComposition?

public init(
id: MetricID,
Expand All @@ -86,7 +110,8 @@ public struct MetricDescriptor: Hashable, Sendable, Codable {
unit: MetricUnit,
kind: MetricKind = .gauge,
nominalMaximum: Double? = nil,
direction: MetricDirection? = nil
direction: MetricDirection? = nil,
composition: MetricComposition? = nil
) {
self.id = id
self.name = name
Expand All @@ -95,5 +120,6 @@ public struct MetricDescriptor: Hashable, Sendable, Codable {
self.kind = kind
self.nominalMaximum = nominalMaximum
self.direction = direction
self.composition = composition
}
}
7 changes: 4 additions & 3 deletions Sources/MonitorSources/CPUSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,15 @@ public final class CPUSource: MetricSource, @unchecked Sendable {
var result = [
MetricDescriptor(
id: Self.total, name: "Total", group: "CPU", unit: .fraction,
nominalMaximum: 1
nominalMaximum: 1, composition: .aggregate
),
MetricDescriptor(
id: Self.user, name: "User", group: "CPU", unit: .fraction, nominalMaximum: 1
id: Self.user, name: "User", group: "CPU", unit: .fraction, nominalMaximum: 1,
composition: .part
),
MetricDescriptor(
id: Self.system, name: "System", group: "CPU", unit: .fraction,
nominalMaximum: 1
nominalMaximum: 1, composition: .part
),
]
for cluster in clusters {
Expand Down
12 changes: 6 additions & 6 deletions Sources/MonitorSources/MemorySource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,27 +34,27 @@ public final class MemorySource: MetricSource, @unchecked Sendable {
return [
MetricDescriptor(
id: Self.used, name: "Used", group: "Memory", unit: .bytes,
nominalMaximum: ceiling
nominalMaximum: ceiling, composition: .aggregate
),
MetricDescriptor(
id: Self.app, name: "App", group: "Memory", unit: .bytes,
nominalMaximum: ceiling
nominalMaximum: ceiling, composition: .part
),
MetricDescriptor(
id: Self.wired, name: "Wired", group: "Memory", unit: .bytes,
nominalMaximum: ceiling
nominalMaximum: ceiling, composition: .part
),
MetricDescriptor(
id: Self.compressed, name: "Compressed", group: "Memory", unit: .bytes,
nominalMaximum: ceiling
nominalMaximum: ceiling, composition: .part
),
MetricDescriptor(
id: Self.cached, name: "Cached", group: "Memory", unit: .bytes,
nominalMaximum: ceiling
nominalMaximum: ceiling, composition: .part
),
MetricDescriptor(
id: Self.free, name: "Free", group: "Memory", unit: .bytes,
nominalMaximum: ceiling
nominalMaximum: ceiling, composition: .part
),
MetricDescriptor(id: Self.swapUsed, name: "Swap", group: "Memory", unit: .bytes),
MetricDescriptor(
Expand Down
89 changes: 79 additions & 10 deletions Sources/MonitorUI/ChartCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,32 @@ public struct ChartCard: View {
/// Passed in rather than worked out here, because whether to mirror is a
/// preference and a card does not read preferences.
public var mirror: MetricPair?
/// The series to draw as stacked bands rather than as lines. Empty is the
/// ordinary card.
///
/// Passed in rather than worked out here, for the same reason `mirror` is:
/// whether to stack is a preference, and a card does not read preferences.
public var stacked: [MetricID] = []

public init(
title: String,
series: [(descriptor: MetricDescriptor, points: [Sample])],
window: TimeInterval,
isUnavailable: Bool = false,
plotHeight: Double = Theme.Layout.chartMinHeight,
mirror: MetricPair? = nil
mirror: MetricPair? = nil,
stacked: [MetricID] = []
) {
self.title = title
self.series = series
self.window = window
self.isUnavailable = isUnavailable
self.plotHeight = plotHeight
self.mirror = mirror
// A card is one or the other. Two directions of a flow are not slices
// of a whole, so nothing declares both — but drawing bands below a
// baseline would be nonsense, so it cannot happen by accident either.
self.stacked = mirror == nil ? stacked : []
}

public var body: some View {
Expand Down Expand Up @@ -131,9 +142,16 @@ public struct ChartCard: View {
_ descriptor: MetricDescriptor, index: Int, latest: Sample
) -> some View {
HStack(spacing: 4) {
Circle()
.fill(Theme.seriesColor(index))
.frame(width: 7, height: 7)
// Filled for a band, hollow for a line — the same distinction the
// chart makes, so the key does not have to be guessed at.
Group {
if stacked.isEmpty || isBand(descriptor) {
Circle().fill(Theme.seriesColor(index))
} else {
Circle().strokeBorder(Theme.seriesColor(index), lineWidth: 1.5)
}
}
.frame(width: 7, height: 7)
Text(descriptor.name)
.foregroundStyle(Theme.label)
.lineLimit(1)
Expand Down Expand Up @@ -197,12 +215,26 @@ public struct ChartCard: View {
}
ForEach(Array(entries.enumerated()), id: \.offset) { index, entry in
ForEach(entry.points, id: \.timestamp) { sample in
LineMark(
x: .value("Time", Date(timeIntervalSince1970: sample.timestamp)),
y: .value("Value", plotted(sample.value, of: entry.descriptor))
let x = PlottableValue.value(
"Time", Date(timeIntervalSince1970: sample.timestamp)
)
let y = PlottableValue.value(
"Value", plotted(sample.value, of: entry.descriptor)
)
.foregroundStyle(Theme.seriesColor(index))
.interpolationMethod(.monotone)
// Bands for the slices, lines for everything else on the
// card. Swift Charts stacks area marks that share an x and
// differ by series, and leaves the line marks alone, so the
// two kinds sit on one chart without arguing.
if stacked.contains(entry.descriptor.id) {
AreaMark(x: x, y: y)
.foregroundStyle(Theme.seriesColor(index).opacity(0.85))
.interpolationMethod(.monotone)
} else {
LineMark(x: x, y: y)
.foregroundStyle(Theme.seriesColor(index))
.interpolationMethod(.monotone)
.lineStyle(lineStyle)
}
}
.foregroundStyle(by: .value("Series", entry.descriptor.name))
}
Expand Down Expand Up @@ -330,7 +362,44 @@ public struct ChartCard: View {
// Scaled to what is on screen, not to the whole buffer. Otherwise a
// spike eight minutes off the left of a two-minute window flattens
// everything you can actually see.
let peak = visible.flatMap { $0.points.map(\.value) }.max() ?? 1
//
// A stack is measured by its total height, not by its tallest band. Its
// top is what the eye reads, and scaling to one band would push the
// stack out through the top of the card.
let peak = max(
visible.flatMap { $0.points.map(\.value) }.max() ?? 1, stackedPeak ?? 0
)
return max(peak * 1.15, .leastNonzeroMagnitude)
}

/// Solid on an ordinary card, dashed on a stacked one.
///
/// A line among bands is a different kind of statement and has to look like
/// one. Memory Used is a *sum* of three of the bands under it and Swap is
/// not in the machine's RAM at all, so drawing them in the same solid
/// stroke the card uses elsewhere invites reading them as one more slice.
private var lineStyle: StrokeStyle {
stacked.isEmpty
? StrokeStyle(lineWidth: 2)
: StrokeStyle(lineWidth: 2, dash: [4, 3])
}

/// Whether this series is drawn as a band. The legend asks so its swatch
/// can say the same thing the chart does.
private func isBand(_ descriptor: MetricDescriptor) -> Bool {
stacked.contains(descriptor.id)
}

/// The tallest the stack gets inside the window: the parts summed per
/// timestamp, then the largest of those sums.
private var stackedPeak: Double? {
guard !stacked.isEmpty else { return nil }
var totals: [TimeInterval: Double] = [:]
for entry in visible where stacked.contains(entry.descriptor.id) {
for sample in entry.points {
totals[sample.timestamp, default: 0] += sample.value
}
}
return totals.values.max()
}
}
3 changes: 2 additions & 1 deletion Sources/MonitorUI/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,8 @@ public struct DashboardView: View {
// The metrics the card is *drawing*, not the group's whole
// membership: switch one direction off and the card stops
// being a pair.
mirror: model.charts.mirror(for: series.map(\.descriptor))
mirror: model.charts.mirror(for: series.map(\.descriptor)),
stacked: model.charts.stack(for: series.map(\.descriptor))
)
card
.copyable {
Expand Down
17 changes: 17 additions & 0 deletions Sources/MonitorUI/PreferencesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,23 @@ struct ChartsTab: View {

var body: some View {
Form {
Section {
Toggle("Stack the parts of a whole", isOn: $model.charts.stacksParts)
} footer: {
Text(
"Memory is app, wired, compressed, cached and free — five "
+ "slices of one machine's RAM — and CPU is user plus "
+ "system. Stacked, the bands show the split and the "
+ "total at once, which lines all climbing from zero "
+ "cannot. A total such as Memory Used keeps its line "
+ "and lands on top of the bands it sums, because "
+ "stacking it would count them twice."
)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}

Section {
Toggle("Mirror paired charts", isOn: $model.charts.mirrorsPairs)
} footer: {
Expand Down
Loading
Loading