diff --git a/system_monitor.go b/system_monitor.go new file mode 100644 index 0000000..52f2ff2 --- /dev/null +++ b/system_monitor.go @@ -0,0 +1,76 @@ +package mujica + +import ( + "fmt" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// MonitorMetric is one sampled resource; Samples line up with the shared times. +type MonitorMetric struct { + Name string + Unit string + Samples []float64 +} + +// SystemMonitorOptions configure a SystemMonitor. +type SystemMonitorOptions struct { + // Height of each chart; zero means 110. + Height float32 + Status DataStatus + Error string +} + +// SystemMonitorResult reports this frame's events. +type SystemMonitorResult struct { + Element *ui.Element + Retry bool +} + +// SystemMonitor stacks a live line chart per metric over one time axis, sampled by the caller. +func SystemMonitor(c *ui.Context, times []time.Time, metrics []MonitorMetric, opts SystemMonitorOptions) SystemMonitorResult { + if opts.Height < 0 { + panic("mujica: SystemMonitor Height is negative") + } + for i, m := range metrics { + if m.Name == "" || len(m.Samples) != len(times) { + panic(fmt.Sprintf("mujica: SystemMonitor metric %d needs a Name and one sample per time", i)) + } + } + h := opts.Height + if h == 0 { + h = 110 + } + k := Tokens(c) + u := Density(c).Unit() + var r SystemMonitorResult + r.Element = ui.Column(c).Shrink(0).Gap(u * 3) + r.Element.Children(func() { + for _, m := range metrics { + ui.Column(c).Key(m.Name).Gap(u).Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, m.Name).FontWeight(600).Grow(1) + last := math.NaN() + if n := len(m.Samples); n > 0 { + last = m.Samples[n-1] + } + if !math.IsNaN(last) && opts.Status == DataReady { + ui.Text(c, fmt.Sprintf("%s %s", sandboxNum(last), m.Unit)).FontFeatures("tnum"). + FontSize(FontSize(c, theme.CaptionSize+1)).TextColor(k.Text).Role(ui.RoleStatus) + } + }) + unit := m.Unit + res := LineChart(c, []ChartSeries{{Name: m.Name, Values: m.Samples}}, LineChartOptions{ + Frame: ChartFrame{Label: m.Name, Height: h, Status: opts.Status, Error: opts.Error}, + Times: times, Format: func(v float64) string { return sandboxNum(v) + " " + unit }, + }) + r.Retry = r.Retry || res.Retried() + }) + } + }) + return r +} diff --git a/system_monitor_test.go b/system_monitor_test.go new file mode 100644 index 0000000..cbc7edf --- /dev/null +++ b/system_monitor_test.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "math" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func monitorSample(n int) ([]time.Time, []MonitorMetric) { + t0 := time.Date(2026, 10, 5, 21, 0, 0, 0, time.UTC) + times := make([]time.Time, n) + cpu, mem := make([]float64, n), make([]float64, n) + for i := range n { + times[i] = t0.Add(time.Duration(i) * 5 * time.Second) + cpu[i] = 40 + 20*math.Sin(float64(i)/4) + mem[i] = 3.2 + float64(i)*0.02 + } + return times, []MonitorMetric{{Name: "CPU", Unit: "%", Samples: cpu}, {Name: "Memory", Unit: "GB", Samples: mem}} +} + +func TestSystemMonitorLiveAndKeyboard(t *testing.T) { + times, metrics := monitorSample(20) + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SystemMonitor(c, times, metrics, SystemMonitorOptions{}) + }, 600, 420) + if !tt.HasText("3.6 GB") || !tt.HasText("20.0 %") { + t.Fatalf("latest samples missing: %q", tt.Texts()) + } + times, metrics = monitorSample(21) + tt.Frame() + if !tt.HasText("20.8 %") || tt.HasText("20.0 %") { + t.Fatalf("new sample: %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) // the legend key + tt.Key(0, ui.KeyTab) // the plot + tt.Key(0, ui.KeyEnd) + found := false + for _, s := range tt.Texts() { + found = found || strings.Contains(s, "21:01:40") + } + if !found { + t.Fatalf("keyboard readout: %q", tt.Texts()) + } +} + +func TestSystemMonitorStatesAndRules(t *testing.T) { + times, metrics := monitorSample(5) + retries := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if SystemMonitor(c, times, metrics[:1], SystemMonitorOptions{Status: DataFailed, Error: "agent offline"}).Retry { + retries++ + } + }, 600, 300) + if !tt.HasText("agent offline") { + t.Fatalf("error: %q", tt.Texts()) + } + _ = tt.Click("Retry") + if retries != 1 { + t.Fatal("retry not reported") + } + defer func() { + if recover() == nil { + t.Fatal("mismatched samples did not panic") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SystemMonitor(c, times, []MonitorMetric{{Name: "x", Samples: []float64{1}}}, SystemMonitorOptions{}) + }, 200, 200) +} + +func TestSystemMonitorSnapshot(t *testing.T) { + times, metrics := monitorSample(40) + snapshot(t, "system_monitor", 560, 380, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { SystemMonitor(c, times, metrics, SystemMonitorOptions{}) }) + }) +} diff --git a/testdata/snapshots/darwin/system_monitor-dark.png b/testdata/snapshots/darwin/system_monitor-dark.png new file mode 100644 index 0000000..8123bcf Binary files /dev/null and b/testdata/snapshots/darwin/system_monitor-dark.png differ diff --git a/testdata/snapshots/darwin/system_monitor-light.png b/testdata/snapshots/darwin/system_monitor-light.png new file mode 100644 index 0000000..f8eabe1 Binary files /dev/null and b/testdata/snapshots/darwin/system_monitor-light.png differ