Merge branch 'worktree-agent-ae66cd6d621c16060'

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 15:45:29 -04:00
commit 2a70035674
67 files changed
+3393

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
package mujica
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// AlertLevel is an alert's level. The zero value is AlertInfo.
type AlertLevel int
const (
AlertInfo AlertLevel = iota
AlertWarning
AlertCritical
)
// AlertItem is one firing alert; the caller owns it.
type AlertItem struct {
ID string
Title string
Level AlertLevel
Source string
At time.Time
Acked bool
Silence time.Time // silenced until; zero is not silenced
}
// AlertListOptions configure an AlertList.
type AlertListOptions struct {
// MaxHeight caps the scrolling list; zero means 360.
MaxHeight float32
Disabled bool
}
// AlertListResult reports this frame's request.
type AlertListResult struct {
Element *ui.Element
// Ack and Silence are the IDs to acknowledge or silence for an hour, else "".
Ack, Silence string
}
// AlertList lists alerts by level, source and time with acknowledge and silence.
func AlertList(c *ui.Context, alerts []AlertItem, opts AlertListOptions) AlertListResult {
if opts.MaxHeight < 0 {
panic("mujica: AlertList MaxHeight is negative")
}
seen := map[string]bool{}
for i, a := range alerts {
if a.ID == "" || a.Title == "" || seen[a.ID] || a.Level < AlertInfo || a.Level > AlertCritical {
panic(fmt.Sprintf("mujica: AlertList alert %d needs a unique ID, a Title and a known Level", i))
}
seen[a.ID] = true
}
maxH := opts.MaxHeight
if maxH == 0 {
maxH = 360
}
k := Tokens(c)
var r AlertListResult
r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled)
locked := r.Element.IsDisabled()
r.Element.Children(func() {
if len(alerts) == 0 {
agentCaption(c, msg(c, "alert_list.empty"))
return
}
agentRows(c, len(alerts), func(i int) {
ack, silence := alertRow(c, alerts[i], locked)
if ack && !locked {
r.Ack = alerts[i].ID
}
if silence && !locked {
r.Silence = alerts[i].ID
}
}).Shrink(1).MaxHeight(maxH).Background(k.Surface).Border(theme.BorderWidth, k.Border).
Radius(theme.CardRadius).Dividers(theme.BorderWidth, k.Border)
})
if r.Ack != "" || r.Silence != "" {
slog.Debug("mujica: alert_list action", "ack", r.Ack, "silence", r.Silence)
}
return r
}
// alertRow shows one alert and reports its acknowledge and silence presses.
func alertRow(c *ui.Context, a AlertItem, locked bool) (ack, silence bool) {
k := Tokens(c)
d := Density(c)
u := d.Unit()
sev := [...]Severity{SeverityInfo, SeverityWarning, SeverityError}[a.Level]
word := msg(c, [...]string{"alert_list.info", "alert_list.warning", "alert_list.critical"}[a.Level])
silenced := a.Silence.After(c.Now())
row := ui.Row(c).Key(a.ID).Gap(u * 3).AlignItems(ui.Center).MinHeight(d.ControlHeight() + u*2).Padding(u*2, u*3).
Label(word + ", " + a.Title)
if tone, _ := severityLook(k, sev); a.Level == AlertCritical && !a.Acked {
row.DrawOver(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y, W: 3, H: r.H}, tone, 0) })
}
row.Children(func() {
ui.Box(c).Width(96).Shrink(0).Children(func() { devtoolsSeverity(c, sev, word) })
ui.Column(c).Grow(1).Shrink(1).Gap(u / 2).Children(func() {
t := ui.Text(c, a.Title).FontWeight(600)
if a.Acked || silenced {
t.TextColor(k.TextMuted)
}
line := a.Source
if !a.At.IsZero() {
line += " · " + devtoolsAgo(c, a.At)
}
agentCaption(c, line)
})
switch {
case silenced:
agentCaption(c, fmt.Sprintf(msg(c, "alert_list.silenced"), a.Silence.Format("15:04")))
case a.Acked:
agentCaption(c, msg(c, "alert_list.acked"))
default:
ack = Button(c, msg(c, "alert_list.ack"), ButtonOptions{Variant: Secondary, Disabled: locked}).Clicked()
}
if !silenced {
silence = agentToggle(c, msg(c, "alert_list.silence"), "bell", locked)
}
})
return ack, silence
}
+83
View File
@@ -0,0 +1,83 @@
package mujica
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func alertSample() []AlertItem {
now := time.Now()
return []AlertItem{
{ID: "a", Title: "Disk 92% full", Level: AlertCritical, Source: "db-1", At: now.Add(-5 * time.Minute)},
{ID: "b", Title: "Latency above 500 ms", Level: AlertWarning, Source: "api", At: now.Add(-time.Hour), Acked: true},
{ID: "c", Title: "Deploy finished", Level: AlertInfo, Source: "ci", Silence: now.Add(time.Hour)},
}
}
func alertTester(alerts []AlertItem, opts AlertListOptions, got *[]AlertListResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := AlertList(c, alerts, opts)
if r.Ack != "" || r.Silence != "" {
*got = append(*got, r)
}
}, 640, 400)
}
func TestAlertListLevelsAndActions(t *testing.T) {
var got []AlertListResult
tt := alertTester(alertSample(), AlertListOptions{}, &got)
for _, s := range []string{"Critical", "Warning", "Info", "db-1 · 5 m 00 s ago", "Acknowledged"} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %q", s, tt.Texts())
}
}
_ = tt.Click("Acknowledge")
silences, _ := tt.Find("Silence 1 h")
tt.ClickAt(silences.X+4, silences.Y+4)
if len(got) != 2 || got[0].Ack != "a" || got[1].Silence != "a" {
t.Fatalf("actions %+v", got)
}
tt = alertTester(alertSample(), AlertListOptions{}, &got)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
if len(got) != 3 || got[2].Ack != "a" {
t.Fatalf("keyboard ack: %+v", got)
}
}
func TestAlertListDisabledEmptyMany(t *testing.T) {
var got []AlertListResult
tt := alertTester(alertSample(), AlertListOptions{Disabled: true}, &got)
_ = tt.Click("Acknowledge")
_ = tt.Click("Silence 1 h")
if len(got) != 0 {
t.Fatalf("disabled list acted: %+v", got)
}
tt = alertTester(nil, AlertListOptions{}, &got)
if !tt.HasText("No alerts") {
t.Fatal("empty text missing")
}
many := make([]AlertItem, 100000)
for i := range many {
many[i] = AlertItem{ID: fmt.Sprint(i), Title: fmt.Sprintf("alert %d", i)}
}
tt = alertTester(many, AlertListOptions{}, &got)
if n := len(tt.Texts()); n > 300 {
t.Fatalf("%d texts for 100000 alerts", n)
}
}
func TestAlertListSnapshot(t *testing.T) {
snapshot(t, "alert_list", 640, 260, func(c *ui.Context) {
alerts := alertSample()
for i := range alerts {
alerts[i].At = time.Time{}
}
alerts[2].Silence = time.Time{}
ui.Column(c).Padding(16).Children(func() { AlertList(c, alerts, AlertListOptions{}) })
})
}
+124
View File
@@ -0,0 +1,124 @@
package mujica
import (
"fmt"
"log/slog"
"net/url"
"slices"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// ApiRequest is an HTTP request being built; the caller sends it.
type ApiRequest struct {
// Method is GET when empty.
Method string
URL string
Params []KeyValue
Headers []KeyValue
Body string
}
// ApiRequestBuilderOptions configure an ApiRequestBuilder.
type ApiRequestBuilderOptions struct {
// Sending shows the request in flight and blocks another send.
Sending bool
Disabled bool
}
// ApiRequestResult reports this frame's events.
type ApiRequestResult struct {
Element *ui.Element
// Send asks the caller to send the request.
Send bool
Changed bool
}
type apiRequestState struct{ tab int }
var apiMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
// ApiRequestBuilder edits method, URL, query parameters, headers and body, and reports a send.
func ApiRequestBuilder(c *ui.Context, req *ApiRequest, opts ApiRequestBuilderOptions) ApiRequestResult {
if req == nil {
panic("mujica: ApiRequestBuilder needs a request")
}
if req.Method == "" {
req.Method = "GET"
}
if !slices.Contains(apiMethods, req.Method) {
panic("mujica: ApiRequestBuilder method " + req.Method + " is not offered")
}
u := Density(c).Unit()
var r ApiRequestResult
r.Element = ui.Column(c).Shrink(0).Gap(u * 3).Disabled(opts.Disabled)
st := ui.Local(r.Element, "mujica.apirequest", func() apiRequestState { return apiRequestState{} })
locked := r.Element.IsDisabled()
full, urlErr := apiRequestURL(*req)
r.Element.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() {
methods := make([]SelectOption[string], len(apiMethods))
for i, m := range apiMethods {
methods[i] = SelectOption[string]{Value: m, Label: m}
}
r.Changed = Select(c, &req.Method, methods, SelectOptions{Label: msg(c, "api_request_builder.method"), Disabled: locked}).Changed()
shownErr := ""
if req.URL != "" && urlErr != nil {
shownErr = urlErr.Error()
}
in := TextInput(c, &req.URL, TextInputOptions{Placeholder: "https://", Label: msg(c, "api_request_builder.url"), Disabled: locked, Error: shownErr})
in.Element.Grow(1)
r.Changed = r.Changed || in.Changed()
ready := urlErr == nil && !opts.Sending && !locked
send := Button(c, msg(c, "api_request_builder.send"), ButtonOptions{Icon: icons.Must("upload"), Loading: opts.Sending, Disabled: locked || urlErr != nil})
r.Send = ready && (send.Clicked() || in.Submitted())
})
if urlErr == nil {
codeMono(c, req.Method+" "+full).TextColor(Tokens(c).TextMuted)
}
tabs := []Tab{
{Label: fmt.Sprintf(msg(c, "api_request_builder.params"), len(keyValueEnabled(req.Params))), Panel: func() {
r.Changed = KeyValueInput(c, &req.Params, KeyValueInputOptions{Label: msg(c, "api_request_builder.params_label"), Disabled: locked}).Changed() || r.Changed
}},
{Label: fmt.Sprintf(msg(c, "api_request_builder.headers"), len(keyValueEnabled(req.Headers))), Panel: func() {
r.Changed = KeyValueInput(c, &req.Headers, KeyValueInputOptions{Label: msg(c, "api_request_builder.headers_label"), Disabled: locked}).Changed() || r.Changed
}},
{Label: msg(c, "api_request_builder.body"), Panel: func() {
if req.Method == "GET" || req.Method == "HEAD" {
agentCaption(c, msg(c, "api_request_builder.no_body"))
}
body := TextArea(c, &req.Body, TextAreaOptions{Label: msg(c, "api_request_builder.body"), MinHeight: 120, MaxHeight: 320, Disabled: locked})
body.Input.Font(theme.MonoFont)
r.Changed = r.Changed || body.Changed()
}},
}
Tabs(c, &st.tab, tabs, TabsOptions{Label: msg(c, "api_request_builder.parts")})
})
if r.Send {
slog.Debug("mujica: api_request_builder send", "method", req.Method) // never the URL: it may hold a token
}
return r
}
// apiRequestURL joins the URL with its enabled query parameters, or says why it is invalid.
func apiRequestURL(req ApiRequest) (string, error) {
if req.URL == "" {
return "", fmt.Errorf("empty URL")
}
u, err := url.Parse(req.URL)
if err != nil {
return "", err
}
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", fmt.Errorf("the URL needs http:// or https:// and a host")
}
q := u.Query()
for _, kv := range keyValueEnabled(req.Params) {
q.Add(kv.Key, kv.Value)
}
u.RawQuery = q.Encode()
return u.String(), nil
}
+93
View File
@@ -0,0 +1,93 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func apiTester(req *ApiRequest, opts *ApiRequestBuilderOptions, sends *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(8).Children(func() {
if ApiRequestBuilder(c, req, *opts).Send {
*sends++
}
})
}, 760, 520)
}
func TestApiRequestBuilderBuildAndSend(t *testing.T) {
var req ApiRequest
opts := ApiRequestBuilderOptions{}
sends := 0
tt := apiTester(&req, &opts, &sends)
if req.Method != "GET" {
t.Fatalf("default method %q", req.Method)
}
_ = tt.Click("Send") // disabled without a URL
_ = tt.Click("Request URL")
tt.Type("ftp://court")
if !tt.HasText("the URL needs http:// or https:// and a host") || sends != 0 {
t.Fatalf("invalid URL: %q", tt.Texts())
}
req.URL = ""
tt.Frame()
_ = tt.Click("Request URL")
tt.Type("https://court.example/guests")
_ = tt.Click("Add row")
_ = tt.Click("Row 1 Key")
tt.Type("page")
tt.Key(0, ui.KeyTab)
tt.Type("2")
if !tt.HasText("GET https://court.example/guests?page=2") || !tt.HasText("Params (1)") {
t.Fatalf("preview: %q", tt.Texts())
}
_ = tt.Click("Request URL")
tt.Key(0, ui.KeyEnter)
_ = tt.Click("Send")
if sends != 2 {
t.Fatalf("sends %d", sends)
}
_ = tt.Click("Method")
_ = tt.Click("POST")
_ = tt.Click("Body")
if req.Method != "POST" || tt.HasText("GET and HEAD requests usually carry no body.") {
t.Fatalf("method %q", req.Method)
}
}
func TestApiRequestBuilderSendingAndDisabled(t *testing.T) {
req := ApiRequest{URL: "https://court.example"}
opts := ApiRequestBuilderOptions{Sending: true}
sends := 0
tt := apiTester(&req, &opts, &sends)
_ = tt.Click("Request URL")
tt.Key(0, ui.KeyEnter)
if sends != 0 {
t.Fatal("sent while sending")
}
opts = ApiRequestBuilderOptions{Disabled: true}
tt.Frame()
_ = tt.Click("Send")
_ = tt.Click("Request URL")
tt.Type("x")
if sends != 0 || req.URL != "https://court.example" {
t.Fatalf("disabled: %q sends %d", req.URL, sends)
}
defer func() {
if recover() == nil {
t.Fatal("unknown method did not panic")
}
}()
apiTester(&ApiRequest{Method: "BREW"}, &ApiRequestBuilderOptions{}, &sends)
}
func TestApiRequestBuilderSnapshot(t *testing.T) {
snapshot(t, "api_request_builder", 720, 330, func(c *ui.Context) {
req := ApiRequest{Method: "POST", URL: "https://court.example/guests",
Params: []KeyValue{{Key: "notify", Value: "true", Enabled: true}},
Headers: []KeyValue{{Key: "Accept", Value: "application/json", Enabled: true}}}
ui.Column(c).Padding(16).Children(func() { ApiRequestBuilder(c, &req, ApiRequestBuilderOptions{}) })
})
}
+95
View File
@@ -0,0 +1,95 @@
package mujica
import (
"fmt"
"log/slog"
"slices"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
)
// DashboardFilters is what a dashboard is filtered by; the zero value filters nothing.
type DashboardFilters struct {
// Range is how far back to look; zero means the caller's default.
Range time.Duration
Env string
Tags []string
}
// DashboardFilterBarOptions configure a DashboardFilterBar.
type DashboardFilterBarOptions struct {
// Ranges are the offered look-backs; nil uses 15 min, 1 h, 24 h and 7 d.
Ranges []time.Duration
// Envs and Tags are the offered environments and tags.
Envs, Tags []string
Disabled bool
}
var dashboardRanges = []time.Duration{15 * time.Minute, time.Hour, 24 * time.Hour, 7 * 24 * time.Hour}
// DashboardFilterBar filters by time range, environment and tags, with a clear button.
func DashboardFilterBar(c *ui.Context, f *DashboardFilters, opts DashboardFilterBarOptions) ChoiceResult {
if f == nil {
panic("mujica: DashboardFilterBar needs filters")
}
ranges := opts.Ranges
if ranges == nil {
ranges = dashboardRanges
}
if f.Env != "" && !slices.Contains(opts.Envs, f.Env) {
panic("mujica: DashboardFilterBar Env is not offered")
}
u := Density(c).Unit()
var r ChoiceResult
r.Element = ui.Row(c).Shrink(0).Wrap().Gap(u * 3).AlignItems(ui.Center).Disabled(opts.Disabled).
Label(msg(c, "dashboard_filter_bar.label"))
locked := r.Element.IsDisabled()
r.Element.Children(func() {
rs := []SelectOption[time.Duration]{{Value: 0, Label: msg(c, "dashboard_filter_bar.default_range")}}
for _, d := range ranges {
rs = append(rs, SelectOption[time.Duration]{Value: d, Label: fmt.Sprintf(msg(c, "dashboard_filter_bar.last"), dashboardRangeLabel(c, d))})
}
if f.Range != 0 && !slices.Contains(ranges, f.Range) {
panic("mujica: DashboardFilterBar Range is not offered")
}
r.changed = Select(c, &f.Range, rs, SelectOptions{Label: msg(c, "dashboard_filter_bar.range"), Disabled: locked}).Changed()
envs := []SelectOption[string]{{Value: "", Label: msg(c, "dashboard_filter_bar.all_envs")}}
for _, e := range opts.Envs {
envs = append(envs, SelectOption[string]{Value: e, Label: e})
}
r.changed = Select(c, &f.Env, envs, SelectOptions{Label: msg(c, "dashboard_filter_bar.env"), Disabled: locked}).Changed() || r.changed
if len(opts.Tags) > 0 {
tags := make([]SelectOption[string], len(opts.Tags))
for i, t := range opts.Tags {
tags[i] = SelectOption[string]{Value: t, Label: t}
}
r.changed = MultiSelect(c, &f.Tags, tags, MultiSelectOptions{Placeholder: msg(c, "dashboard_filter_bar.tags"),
Label: msg(c, "dashboard_filter_bar.tags"), Disabled: locked}).Changed() || r.changed
}
empty := f.Range == 0 && f.Env == "" && len(f.Tags) == 0
if Button(c, msg(c, "dashboard_filter_bar.clear"), ButtonOptions{Variant: Ghost, Icon: icons.Must("x"), Disabled: locked || empty}).Clicked() && !locked {
*f, r.changed = DashboardFilters{}, true
}
})
if r.changed {
slog.Debug("mujica: dashboard_filter_bar change", "range", f.Range, "env", f.Env, "tags", len(f.Tags))
}
return r
}
// dashboardRangeLabel names a look-back, as "15 min" or "7 d".
func dashboardRangeLabel(c *ui.Context, d time.Duration) string {
switch {
case d <= 0:
case d%(24*time.Hour) == 0:
return fmt.Sprintf(msg(c, "dashboard_filter_bar.days"), int(d/(24*time.Hour)))
case d%time.Hour == 0:
return fmt.Sprintf(msg(c, "dashboard_filter_bar.hours"), int(d/time.Hour))
case d%time.Minute == 0:
return fmt.Sprintf(msg(c, "dashboard_filter_bar.minutes"), int(d/time.Minute))
}
panic("mujica: DashboardFilterBar ranges must be positive whole minutes")
}
+73
View File
@@ -0,0 +1,73 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func filterTester(f *DashboardFilters, opts DashboardFilterBarOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(8).Children(func() {
if DashboardFilterBar(c, f, opts).Changed() {
*changes++
}
})
}, 900, 420)
}
var filterOpts = DashboardFilterBarOptions{Envs: []string{"production", "staging"}, Tags: []string{"api", "web", "db"}}
func TestDashboardFilterBarChooseAndClear(t *testing.T) {
var f DashboardFilters
n := 0
tt := filterTester(&f, filterOpts, &n)
_ = tt.Click("Time range")
_ = tt.Click("Last 1 d")
if f.Range != 24*time.Hour || n != 1 {
t.Fatalf("range %v changes %d", f.Range, n)
}
_ = tt.Click("Environment")
_ = tt.Click("staging")
if f.Env != "staging" {
t.Fatalf("env %q", f.Env)
}
_ = tt.Click("Tags")
_ = tt.Click("db")
if len(f.Tags) != 1 || f.Tags[0] != "db" {
t.Fatalf("tags %v", f.Tags)
}
tt.Key(0, ui.KeyEscape)
_ = tt.Click("Clear filters")
if f.Range != 0 || f.Env != "" || len(f.Tags) != 0 || !tt.HasText("All environments") {
t.Fatalf("clear: %+v", f)
}
}
func TestDashboardFilterBarDisabledAndRules(t *testing.T) {
f := DashboardFilters{Env: "production"}
n := 0
tt := filterTester(&f, DashboardFilterBarOptions{Envs: filterOpts.Envs, Disabled: true}, &n)
_ = tt.Click("Clear filters")
_ = tt.Click("Time range")
_ = tt.Click("Last 1 h")
if n != 0 || f.Env != "production" || f.Range != 0 {
t.Fatalf("disabled changed: %+v", f)
}
defer func() {
if recover() == nil {
t.Fatal("unoffered env did not panic")
}
}()
bad := DashboardFilters{Env: "moon"}
filterTester(&bad, filterOpts, &n)
}
func TestDashboardFilterBarSnapshot(t *testing.T) {
snapshot(t, "dashboard_filter_bar", 860, 90, func(c *ui.Context) {
f := DashboardFilters{Range: time.Hour, Env: "production", Tags: []string{"api"}}
ui.Column(c).Padding(16).Children(func() { DashboardFilterBar(c, &f, filterOpts) })
})
}
+30
View File
@@ -0,0 +1,30 @@
package mujica
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// devtoolsAgo formats how long before now t was, as "5 m ago".
func devtoolsAgo(c *ui.Context, t time.Time) string {
d := max(c.Now().Sub(t), 0).Truncate(time.Second)
return fmt.Sprintf(msg(c, "devtools.ago"), agentDuration(d))
}
// devtoolsSeverity shows a severity by icon and word, never color alone.
func devtoolsSeverity(c *ui.Context, s Severity, word string) {
tone, icon := severityLook(Tokens(c), s)
ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).Shrink(0).Children(func() {
ui.Icon(c, icon).Size(14, 14).TextColor(tone)
ui.Text(c, word).TextColor(tone).FontSize(FontSize(c, theme.CaptionSize+1)).FontWeight(600).SingleLine()
})
}
// devtoolsTitle is a serif panel title.
func devtoolsTitle(c *ui.Context, s string) *ui.Element {
return ui.Text(c, s).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600)
}
+114
View File
@@ -0,0 +1,114 @@
package mujica
import (
"fmt"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// IncidentStatus is where an incident stands. The zero value is IncidentInvestigating.
type IncidentStatus int
const (
IncidentInvestigating IncidentStatus = iota
IncidentIdentified
IncidentMonitoring
IncidentResolved
)
// IncidentImpact is how badly users are affected. The zero value is ImpactNone.
type IncidentImpact int
const (
ImpactNone IncidentImpact = iota
ImpactMinor
ImpactMajor
ImpactCritical
)
// IncidentUpdate is one timeline entry, oldest first.
type IncidentUpdate struct {
At time.Time
Status IncidentStatus
Text string
}
// Incident is what the caller reports about an incident.
type Incident struct {
Title string
Status IncidentStatus
Impact IncidentImpact
Started time.Time
Updates []IncidentUpdate
}
// IncidentCard shows an incident's title, status, impact and update timeline.
func IncidentCard(c *ui.Context, inc Incident) *ui.Element {
if inc.Title == "" {
panic("mujica: IncidentCard needs a Title")
}
status := incidentStatusWord(c, inc.Status)
if inc.Impact < ImpactNone || inc.Impact > ImpactCritical {
panic(fmt.Sprintf("mujica: unknown IncidentImpact %d", int(inc.Impact)))
}
k := Tokens(c)
u := Density(c).Unit()
sev := [...]Severity{SeverityInfo, SeverityWarning, SeverityError, SeverityError}[inc.Impact]
impact := msg(c, [...]string{"incident_card.none", "incident_card.minor", "incident_card.major", "incident_card.critical"}[inc.Impact])
card := agentCard(c).Label(inc.Title + ", " + status)
card.Children(func() {
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
devtoolsTitle(c, inc.Title).Grow(1).Shrink(1)
if inc.Status == IncidentResolved {
devtoolsSeverity(c, SeveritySuccess, status)
} else {
devtoolsSeverity(c, SeverityWarning, status)
}
})
ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Children(func() {
devtoolsSeverity(c, sev, fmt.Sprintf(msg(c, "incident_card.impact"), impact))
if !inc.Started.IsZero() {
agentCaption(c, fmt.Sprintf(msg(c, "incident_card.started"), inc.Started.Format("2006-01-02 15:04")))
}
})
for i, up := range inc.Updates {
incidentUpdate(c, k, up, i == len(inc.Updates)-1)
}
})
return card
}
// incidentUpdate draws one timeline entry on a diamond rail.
func incidentUpdate(c *ui.Context, k theme.Tokens, up IncidentUpdate, last bool) {
u := Density(c).Unit()
ui.Row(c).Gap(u * 3).AlignItems(ui.Stretch).Children(func() {
ui.Box(c).Width(12).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
cx := internal.Snap(p, r.X+r.W/2)
cy := internal.Snap(p, r.Y+9)
if !last {
p.Line(cx, cy+5, cx, r.Y+r.H, 1, k.ControlBorder)
}
p.FillPath(internal.Diamond(cx, cy, 9), k.Ornament)
})
ui.Column(c).Grow(1).Shrink(1).Gap(u / 2).Padding(0, 0, u*2, 0).Children(func() {
ui.Row(c).Gap(u * 2).Children(func() {
ui.Text(c, incidentStatusWord(c, up.Status)).FontWeight(600)
if !up.At.IsZero() {
agentCaption(c, up.At.Format("15:04"))
}
})
ui.Text(c, up.Text).TextColor(k.TextMuted)
})
})
}
func incidentStatusWord(c *ui.Context, s IncidentStatus) string {
if s < IncidentInvestigating || s > IncidentResolved {
panic(fmt.Sprintf("mujica: unknown IncidentStatus %d", int(s)))
}
return msg(c, [...]string{"incident_card.investigating", "incident_card.identified", "incident_card.monitoring", "incident_card.resolved"}[s])
}
+55
View File
@@ -0,0 +1,55 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func incidentSample() Incident {
t0 := time.Date(2026, 10, 5, 21, 4, 0, 0, time.UTC)
return Incident{Title: "Checkout errors in eu-west", Status: IncidentMonitoring, Impact: ImpactMajor, Started: t0,
Updates: []IncidentUpdate{
{At: t0, Status: IncidentInvestigating, Text: "Error rate rose to 8%."},
{At: t0.Add(12 * time.Minute), Status: IncidentIdentified, Text: "A bad config push to the payment proxy."},
{At: t0.Add(30 * time.Minute), Status: IncidentMonitoring, Text: "Rolled back; errors are falling."},
}}
}
func TestIncidentCardShowsStatusImpactTimeline(t *testing.T) {
inc := incidentSample()
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
IncidentCard(c, inc)
}, 560, 500)
for _, s := range []string{"Checkout errors in eu-west", "Monitoring", "Impact: major", "Started 2026-10-05 21:04",
"Investigating", "21:16", "Rolled back; errors are falling."} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %q", s, tt.Texts())
}
}
inc.Status = IncidentResolved
tt.Frame()
if !tt.HasText("Resolved") {
t.Fatal("status did not update")
}
}
func TestIncidentCardRejectsUnknownStatus(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
IncidentCard(c, Incident{Title: "x", Status: 9})
}, 200, 200)
}
func TestIncidentCardSnapshot(t *testing.T) {
snapshot(t, "incident_card", 540, 360, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { IncidentCard(c, incidentSample()) })
})
}
+146
View File
@@ -0,0 +1,146 @@
package mujica
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// JsonEditorOptions configure a JsonEditor.
type JsonEditorOptions struct {
// Label names the editor; empty uses "JSON".
Label string
// MinHeight and MaxHeight bound the text; zero means 160 and 400.
MinHeight, MaxHeight float32
Disabled bool
}
// JsonEditorResult reports this frame's events and the parse state.
type JsonEditorResult struct {
Element *ui.Element
Changed bool
// Valid reports *text parses as one JSON value.
Valid bool
// Line and Column locate a parse error, 1-based; 0 when valid.
Line, Column int
}
type jsonEditorCheck struct {
src string
err string
line, column int
}
// JsonEditor edits JSON text, formats it and points at the line and column of a parse error.
func JsonEditor(c *ui.Context, text *string, opts JsonEditorOptions) JsonEditorResult {
if text == nil || opts.MinHeight < 0 || opts.MaxHeight < 0 {
panic("mujica: JsonEditor needs text and non-negative heights")
}
label := opts.Label
if label == "" {
label = msg(c, "json_editor.label")
}
minH, maxH := opts.MinHeight, opts.MaxHeight
if minH == 0 {
minH = 160
}
if maxH == 0 {
maxH = 400
}
k := Tokens(c)
u := Density(c).Unit()
var r JsonEditorResult
r.Element = ui.Column(c).Shrink(0).Gap(u * 2).Disabled(opts.Disabled)
chk := ui.Local(r.Element, "mujica.jsoneditor", func() jsonEditorCheck { return jsonEditorCheck{src: "\x00"} })
locked := r.Element.IsDisabled()
r.Element.Children(func() {
jsonEditorValidate(chk, *text)
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
ui.Text(c, label).FontWeight(600).Grow(1)
if Button(c, msg(c, "json_editor.format"), ButtonOptions{Variant: Secondary, Disabled: locked || chk.err != ""}).Clicked() && !locked {
var b bytes.Buffer
if json.Indent(&b, []byte(*text), "", " ") == nil && b.String() != *text {
*text, r.Changed = b.String(), true
slog.Debug("mujica: json_editor format")
}
}
})
in := TextArea(c, text, TextAreaOptions{Label: label, MinHeight: minH, MaxHeight: maxH, Disabled: locked, Error: chk.err})
in.Input.Font(theme.MonoFont).FontSize(FontSize(c, codeSize))
r.Changed = r.Changed || in.Changed()
jsonEditorValidate(chk, *text)
if chk.err != "" {
lines := strings.Split(*text, "\n")
if chk.line >= 1 && chk.line <= len(lines) {
jsonEditorExcerpt(c, lines[chk.line-1], chk.line, chk.column)
}
return
}
ui.Row(c).Gap(u).AlignItems(ui.Center).Role(ui.RoleStatus).Children(func() {
ui.Icon(c, icons.Must("circle-check")).Size(14, 14).TextColor(k.Success)
ui.Text(c, msg(c, "json_editor.valid")).TextColor(k.Success).FontSize(FontSize(c, theme.CaptionSize+1))
})
})
r.Valid, r.Line, r.Column = chk.err == "", chk.line, chk.column
return r
}
// jsonEditorValidate parses text once per change and locates any error.
func jsonEditorValidate(chk *jsonEditorCheck, text string) {
if chk.src == text {
return
}
chk.src, chk.err, chk.line, chk.column = text, "", 0, 0
dec := json.NewDecoder(strings.NewReader(text))
var v any
err := dec.Decode(&v)
offset := dec.InputOffset()
if err == nil && dec.More() {
err = errors.New("unexpected data after the value")
}
if err == nil {
return
}
var syn *json.SyntaxError
var typ *json.UnmarshalTypeError
switch {
case errors.As(err, &syn):
offset = syn.Offset
case errors.As(err, &typ):
offset = typ.Offset
case strings.TrimSpace(text) == "":
err, offset = errors.New("empty document"), 0
default:
offset = int64(len(text))
}
before := text[:min(int(offset), len(text))]
chk.line = strings.Count(before, "\n") + 1
chk.column = len([]rune(before[strings.LastIndex(before, "\n")+1:]))
chk.column = max(chk.column, 1)
chk.err = fmt.Sprintf("%d:%d %v", chk.line, chk.column, err)
}
// jsonEditorExcerpt shows the error line with a caret under the column.
func jsonEditorExcerpt(c *ui.Context, line string, n, col int) {
k := Tokens(c)
u := Density(c).Unit()
ui.Column(c).Padding(u*2, u*3).Radius(theme.ControlRadius).Background(k.Background).Border(theme.BorderWidth, k.Border).
Label(fmt.Sprintf(msg(c, "json_editor.at"), n, col)).Children(func() {
spans := []ui.Span{{Text: fmt.Sprintf("%4d | ", n), Color: k.TextMuted}}
toks, _ := internal.Highlight("json", line, false)
for _, t := range toks {
spans = append(spans, ui.Span{Text: t.Text, Color: codeTokenColor(k, t.Kind)})
}
ui.RichText(c, spans...).Font(theme.MonoFont).FontSize(FontSize(c, codeSize)).SingleLine()
codeMono(c, strings.Repeat(" ", 7+col-1)+"^").TextColor(k.Danger)
})
}
+75
View File
@@ -0,0 +1,75 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func jsonEditorTester(text *string, opts JsonEditorOptions, last *JsonEditorResult) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
*last = JsonEditor(c, text, opts)
}, 600, 460)
}
func TestJsonEditorFormatAndValid(t *testing.T) {
text := `{"hall":"east","guests":[1,2]}`
var r JsonEditorResult
tt := jsonEditorTester(&text, JsonEditorOptions{}, &r)
if !r.Valid || !tt.HasText("Valid JSON") {
t.Fatalf("valid: %+v", r)
}
_ = tt.Click("Format")
if text != "{\n \"hall\": \"east\",\n \"guests\": [\n 1,\n 2\n ]\n}" {
t.Fatalf("formatted %q", text)
}
}
func TestJsonEditorErrorLocation(t *testing.T) {
text := "{\n \"hall\": \"east\",\n \"guests\": [1, 2,]\n}"
var r JsonEditorResult
tt := jsonEditorTester(&text, JsonEditorOptions{}, &r)
if r.Valid || r.Line != 3 || r.Column != 19 {
t.Fatalf("location %+v", r)
}
if !tt.HasText(" 3 | ") || !tt.HasText(" ^") {
t.Fatalf("excerpt: %q", tt.Texts())
}
_ = tt.Click("Format") // disabled while invalid
if r.Changed {
t.Fatal("formatted invalid JSON")
}
text = "{} {}"
tt.Frame()
if r.Valid || r.Line != 1 {
t.Fatalf("trailing value: %+v", r)
}
}
func TestJsonEditorTypingAndDisabled(t *testing.T) {
text := ""
var r JsonEditorResult
tt := jsonEditorTester(&text, JsonEditorOptions{}, &r)
if r.Valid {
t.Fatal("empty text is valid")
}
tt.Key(0, ui.KeyTab) // Format is disabled while invalid
tt.Type(`[true]`)
if !r.Valid || text != "[true]" {
t.Fatalf("typed %q valid %v", text, r.Valid)
}
tt = jsonEditorTester(&text, JsonEditorOptions{Disabled: true}, &r)
tt.Key(0, ui.KeyTab)
tt.Type("x")
if text != "[true]" {
t.Fatalf("disabled edited: %q", text)
}
}
func TestJsonEditorSnapshot(t *testing.T) {
snapshot(t, "json_editor", 560, 360, func(c *ui.Context) {
text := "{\n \"hall\": \"east wing\",\n \"guests\": [1, 2,]\n}"
ui.Column(c).Padding(16).Children(func() { JsonEditor(c, &text, JsonEditorOptions{MinHeight: 120}) })
})
}
+93
View File
@@ -0,0 +1,93 @@
package mujica
import (
"bytes"
"encoding/json"
"fmt"
"io"
"regexp"
"strconv"
)
// jsonNode is one value of a parsed document, in document order.
type jsonNode struct {
key, path string
// value is a scalar's JSON text, or "{}" / "[]" for containers.
value string
depth int
parent int
// end is the index after this node's subtree; count is its child count.
end, count int
}
func (n jsonNode) container() bool { return n.value == "{}" || n.value == "[]" }
func (n jsonNode) label() string {
if n.key == "" {
return n.value
}
return n.key + ": " + n.value
}
// jsonTree is a document flattened depth-first.
type jsonTree struct{ nodes []jsonNode }
var jsonIdent = regexp.MustCompile(`^[A-Za-z_$][A-Za-z0-9_$]*$`)
// parseJSONTree flattens data keeping key order; it fails on invalid or trailing JSON.
func parseJSONTree(data string) (jsonTree, error) {
dec := json.NewDecoder(bytes.NewReader([]byte(data)))
dec.UseNumber()
var t jsonTree
if err := jsonParseValue(dec, &t, "", "$", 0, -1); err != nil {
return jsonTree{}, err
}
if _, err := dec.Token(); err != io.EOF {
return jsonTree{}, fmt.Errorf("unexpected data after the value")
}
return t, nil
}
func jsonParseValue(dec *json.Decoder, t *jsonTree, key, path string, depth, parent int) error {
tok, err := dec.Token()
if err != nil {
return err
}
at := len(t.nodes)
t.nodes = append(t.nodes, jsonNode{key: key, path: path, depth: depth, parent: parent})
switch v := tok.(type) {
case json.Delim:
t.nodes[at].value = map[json.Delim]string{'{': "{}", '[': "[]"}[v]
for i := 0; dec.More(); i++ {
childKey, childPath := "", fmt.Sprintf("%s[%d]", path, i)
if v == '{' {
kt, err := dec.Token()
if err != nil {
return err
}
childKey = kt.(string)
childPath = path + "." + childKey
if !jsonIdent.MatchString(childKey) {
childPath = path + "[" + strconv.Quote(childKey) + "]"
}
}
if err := jsonParseValue(dec, t, childKey, childPath, depth+1, at); err != nil {
return err
}
t.nodes[at].count++
}
if _, err := dec.Token(); err != nil { // the closing delimiter
return err
}
case string:
t.nodes[at].value = strconv.Quote(v)
case json.Number:
t.nodes[at].value = v.String()
case bool:
t.nodes[at].value = strconv.FormatBool(v)
case nil:
t.nodes[at].value = "null"
}
t.nodes[at].end = len(t.nodes)
return nil
}
+212
View File
@@ -0,0 +1,212 @@
package mujica
import (
"fmt"
"log/slog"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// JsonViewerState is a JsonViewer's open nodes, search and choice.
type JsonViewerState struct {
// Query finds keys and values; matches open their ancestors.
Query string
// Current is the chosen match.
Current int
open map[string]bool
tree jsonTree
src string
err error
rows []int // visible node indexes
stale bool // rows need rebuilding
searched string
matches []int
sel int
list ui.ListState
}
// JsonViewerOptions configure a JsonViewer.
type JsonViewerOptions struct {
// Height of the tree; zero means 360.
Height float32
// Expanded opens every node at first.
Expanded bool
}
// JsonViewerResult reports this frame's events.
type JsonViewerResult struct {
Element *ui.Element
// Copied is the path written to the clipboard, else "".
Copied string
}
// JsonViewer shows JSON as a tree with folding, path copy and search, virtualized.
func JsonViewer(c *ui.Context, data string, s *JsonViewerState, opts JsonViewerOptions) JsonViewerResult {
if s == nil || opts.Height < 0 {
panic("mujica: JsonViewer needs a state and Height >= 0")
}
h := opts.Height
if h == 0 {
h = 360
}
if s.open == nil || data != s.src {
s.src, s.open, s.stale, s.searched = data, map[string]bool{"$": true}, true, "\x00"
s.tree, s.err = parseJSONTree(data)
if opts.Expanded {
for _, n := range s.tree.nodes {
s.open[n.path] = true
}
}
}
u := Density(c).Unit()
var r JsonViewerResult
r.Element = codePanel(c).Height(h)
r.Element.Children(func() {
if s.err != nil {
ui.Row(c).Padding(u * 3).Gap(u * 2).TextColor(Tokens(c).Danger).Role(ui.RoleStatus).Children(func() {
ui.Icon(c, icons.Must("circle-alert")).Size(16, 16)
ui.Text(c, fmt.Sprintf(msg(c, "json_viewer.invalid"), s.err))
})
return
}
jsonViewerSearch(s)
codeHeader(c, func() {
moved := SearchInput(c, &s.Query, SearchInputOptions{Find: true, Matches: len(s.matches), Current: &s.Current}).Moved()
if moved && len(s.matches) > 0 {
s.sel = s.matches[s.Current]
s.stale = true
}
path := ""
if s.sel >= 0 && s.sel < len(s.tree.nodes) {
path = s.tree.nodes[s.sel].path
}
codeMono(c, path).Grow(1).MinWidth(0).SingleLine()
if Button(c, msg(c, "json_viewer.copy"), ButtonOptions{Variant: Ghost, Icon: icons.Must("copy"), Disabled: path == ""}).Clicked() && path != "" {
c.WriteClipboard(path)
r.Copied = path
slog.Debug("mujica: json_viewer copy path", "path", path)
}
})
jsonViewerRows(s)
at := -1
for i, n := range s.rows {
if n == s.sel {
at = i
}
}
was := at
s.list.Selected = &at
s.list.Label = func(i int) string { return s.tree.nodes[s.rows[i]].label() }
list := codeRows(c, &s.list, len(s.rows), func(i int) { jsonViewerRow(c, s, s.rows[i]) })
if at != was && at >= 0 {
s.sel = s.rows[at]
}
if at >= 0 && at < len(s.rows) {
n := s.tree.nodes[s.rows[at]]
if n.container() && (list.Shortcut(0, ui.KeyRight) && !s.open[n.path] || list.Shortcut(0, ui.KeyLeft) && s.open[n.path] || list.Submitted()) {
s.open[n.path] = !s.open[n.path]
s.stale = true
}
}
})
return r
}
// jsonViewerSearch finds matches and opens their ancestors when the query changes.
func jsonViewerSearch(s *JsonViewerState) {
if s.searched == s.Query {
return
}
s.searched = s.Query
s.matches = s.matches[:0]
q := strings.ToLower(s.Query)
if q != "" {
for i, n := range s.tree.nodes {
if strings.Contains(strings.ToLower(n.key), q) || strings.Contains(strings.ToLower(n.value), q) {
s.matches = append(s.matches, i)
for p := n.parent; p >= 0; p = s.tree.nodes[p].parent {
s.open[s.tree.nodes[p].path] = true
}
}
}
}
s.Current = max(0, min(s.Current, len(s.matches)-1))
if len(s.matches) > 0 {
s.sel = s.matches[s.Current]
}
s.stale = true
}
// jsonViewerRows lists the visible nodes, skipping closed subtrees, when anything changed.
func jsonViewerRows(s *JsonViewerState) {
if !s.stale {
return
}
s.stale = false
s.rows = s.rows[:0]
for i := 0; i < len(s.tree.nodes); {
n := s.tree.nodes[i]
s.rows = append(s.rows, i)
if n.container() && !s.open[n.path] {
i = n.end
continue
}
i++
}
if i := s.list.Selected; i != nil && *i >= 0 {
for at, n := range s.rows {
if n == s.sel {
s.list.ScrollIntoView(at)
}
}
}
}
// jsonViewerRow shows one node: fold mark, key, and value or size.
func jsonViewerRow(c *ui.Context, s *JsonViewerState, i int) {
k := Tokens(c)
n := s.tree.nodes[i]
ui.Box(c).Width(float32(n.depth) * 16).Shrink(0)
mark := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Center().Shrink(0)
if n.container() {
name := "chevron-right"
if s.open[n.path] {
name = "chevron-down"
}
mark.Role(ui.RoleDisclosure).Label(n.path).Cursor(ui.CursorPointer)
mark.Children(func() { ui.Icon(c, icons.Must(name)).Size(14, 14).TextColor(k.TextMuted) })
if mark.Clicked() {
s.open[n.path], s.stale = !s.open[n.path], true
}
}
var spans []ui.Span
if n.key != "" {
for _, sp := range codeMatchSpans(c, n.key, s.Query) {
sp.Color, sp.Weight = k.AccentText, max(sp.Weight, 600)
spans = append(spans, sp)
}
spans = append(spans, ui.Span{Text: ": ", Color: k.TextMuted})
}
switch {
case n.container() && s.open[n.path]:
spans = append(spans, ui.Span{Text: n.value[:1], Color: k.TextMuted})
case n.container():
spans = append(spans, ui.Span{Text: fmt.Sprintf(msg(c, "json_viewer.items"), n.value[:1], n.count, n.value[1:]), Color: k.TextMuted})
default:
tok, _ := internal.Highlight("json", n.value, false)
col := k.Text
if len(tok) > 0 {
col = codeTokenColor(k, tok[0].Kind)
}
for _, sp := range codeMatchSpans(c, n.value, s.Query) {
sp.Color = col
spans = append(spans, sp)
}
}
ui.RichText(c, spans...).Font(theme.MonoFont).FontSize(FontSize(c, codeSize)).SingleLine().Grow(1).MinWidth(0)
}
+119
View File
@@ -0,0 +1,119 @@
package mujica
import (
"fmt"
"strings"
"testing"
"github.com/egoist/mygo/ui"
)
const jsonSample = `{"ball":{"hall":"east wing","guests":[{"name":"Sakiko","seat":1},{"name":"Uika","seat":2}],"wine":null},"open":true,"two words":3.5}`
func jsonViewerTester(data *string, s *JsonViewerState, opts JsonViewerOptions, copied *[]string) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if r := JsonViewer(c, *data, s, opts); r.Copied != "" {
*copied = append(*copied, r.Copied)
}
}, 640, 460)
}
func TestJsonTreePaths(t *testing.T) {
tr, err := parseJSONTree(jsonSample)
if err != nil {
t.Fatal(err)
}
var paths []string
for _, n := range tr.nodes {
paths = append(paths, n.path)
}
want := `$ $.ball $.ball.hall $.ball.guests $.ball.guests[0] $.ball.guests[0].name $.ball.guests[0].seat $.ball.guests[1] $.ball.guests[1].name $.ball.guests[1].seat $.ball.wine $.open $["two words"]`
if strings.Join(paths, " ") != want {
t.Fatalf("paths %v", paths)
}
if tr.nodes[3].count != 2 || tr.nodes[3].end != 10 {
t.Fatalf("guests node %+v", tr.nodes[3])
}
if _, err := parseJSONTree(`{"a":1} x`); err == nil {
t.Fatal("trailing data accepted")
}
}
func TestJsonViewerFoldKeyboardCopy(t *testing.T) {
data := jsonSample
var s JsonViewerState
var copied []string
tt := jsonViewerTester(&data, &s, JsonViewerOptions{}, &copied)
if !tt.HasText("ball: { 3 items }") || tt.HasText(`hall: "east wing"`) {
t.Fatalf("closed: %q", tt.Texts())
}
_ = tt.Click("$.ball") // its fold mark
if !tt.HasText(`hall: "east wing"`) || !tt.HasText("guests: [ 2 items ]") {
t.Fatalf("open: %q", tt.Texts())
}
_ = tt.Click(`hall: "east wing"`)
_ = tt.Click("Copy path")
if len(copied) != 1 || copied[0] != "$.ball.hall" || tt.Clipboard() != "$.ball.hall" {
t.Fatalf("copy: %v", copied)
}
_ = tt.Click(`hall: "east wing"`) // back on the list
tt.Key(0, ui.KeyDown) // guests
tt.Key(0, ui.KeyRight)
if !tt.HasText("[0]: { 2 items }") && !tt.HasText("{ 2 items }") {
t.Fatalf("Right did not open guests: %q", tt.Texts())
}
tt.Key(0, ui.KeyLeft)
if tt.HasText("{ 2 items }") {
t.Fatal("Left did not close guests")
}
}
func TestJsonViewerSearchOpensMatches(t *testing.T) {
data := jsonSample
var s JsonViewerState
var copied []string
tt := jsonViewerTester(&data, &s, JsonViewerOptions{}, &copied)
_ = tt.Click("Find")
tt.Type("uika")
if !tt.HasText("1 of 1") || !tt.HasText("$.ball.guests[1].name") {
t.Fatalf("search: %q", tt.Texts())
}
data = `{"broken": }`
tt.Frame()
found := false
for _, x := range tt.Texts() {
found = found || strings.HasPrefix(x, "Invalid JSON")
}
if !found {
t.Fatalf("parse error: %q", tt.Texts())
}
}
func TestJsonViewerLargeDocumentVirtualized(t *testing.T) {
var b strings.Builder
b.WriteString("[")
for i := range 100000 {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, "%d", i)
}
b.WriteString("]")
data := b.String()
var s JsonViewerState
var copied []string
tt := jsonViewerTester(&data, &s, JsonViewerOptions{Expanded: true}, &copied)
if n := len(tt.Texts()); n > 300 {
t.Fatalf("%d texts for 100000 items", n)
}
}
func TestJsonViewerSnapshot(t *testing.T) {
snapshot(t, "json_viewer", 600, 360, func(c *ui.Context) {
var s JsonViewerState
ui.Column(c).Padding(16).Children(func() {
JsonViewer(c, jsonSample, &s, JsonViewerOptions{Expanded: true, Height: 320})
})
})
}
+88
View File
@@ -0,0 +1,88 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
)
// KeyValue is one editable pair; disabled pairs are kept but not used.
type KeyValue struct {
Key, Value string
Enabled bool
}
// KeyValueInputOptions configure a KeyValueInput.
type KeyValueInputOptions struct {
// KeyPlaceholder and ValuePlaceholder default to "Key" and "Value".
KeyPlaceholder, ValuePlaceholder string
// Label names the editor for assistive technology.
Label string
Disabled bool
}
// KeyValueInput edits rows of pairs with an enable switch, add and remove.
func KeyValueInput(c *ui.Context, rows *[]KeyValue, opts KeyValueInputOptions) ChoiceResult {
if rows == nil {
panic("mujica: KeyValueInput needs rows")
}
kp, vp := opts.KeyPlaceholder, opts.ValuePlaceholder
if kp == "" {
kp = msg(c, "key_value_input.key")
}
if vp == "" {
vp = msg(c, "key_value_input.value")
}
u := Density(c).Unit()
var r ChoiceResult
r.Element = ui.Column(c).Shrink(0).Gap(u * 2).Disabled(opts.Disabled).Label(opts.Label)
locked := r.Element.IsDisabled()
remove := -1
r.Element.Children(func() {
for i := range *rows {
kv := &(*rows)[i]
ui.Row(c).Key(i).Gap(u * 2).AlignItems(ui.Center).Children(func() {
name := fmt.Sprintf(msg(c, "key_value_input.row"), i+1)
on := kv.Enabled
box := Checkbox(c, &on, "", CheckboxOptions{Disabled: locked}).Label(name)
if box.Changed() && !locked {
kv.Enabled, r.changed = on, true
}
k := TextInput(c, &kv.Key, TextInputOptions{Placeholder: kp, Label: name + " " + kp, Disabled: locked})
k.Element.Grow(1).Basis(0)
v := TextInput(c, &kv.Value, TextInputOptions{Placeholder: vp, Label: name + " " + vp, Disabled: locked})
v.Element.Grow(2).Basis(0)
r.changed = r.changed || k.Changed() || v.Changed()
if iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "key_value_input.remove"), i+1)).Disabled(locked).Clicked() && !locked {
remove = i
}
})
}
if Button(c, msg(c, "key_value_input.add"), ButtonOptions{Variant: Ghost, Icon: icons.Must("plus"), Disabled: locked}).AlignSelf(ui.Start).Clicked() && !locked {
*rows = append(*rows, KeyValue{Enabled: true})
r.changed = true
}
})
if remove >= 0 {
*rows = append((*rows)[:remove:remove], (*rows)[remove+1:]...)
r.changed = true
}
if r.changed {
slog.Debug("mujica: key_value_input change", "rows", len(*rows))
}
return r
}
// keyValueEnabled returns the enabled pairs with a key.
func keyValueEnabled(rows []KeyValue) []KeyValue {
var out []KeyValue
for _, kv := range rows {
if kv.Enabled && kv.Key != "" {
out = append(out, kv)
}
}
return out
}
+65
View File
@@ -0,0 +1,65 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func kvTester(rows *[]KeyValue, opts KeyValueInputOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if KeyValueInput(c, rows, opts).Changed() {
*changes++
}
}, 600, 300)
}
func TestKeyValueInputEditToggleAddRemove(t *testing.T) {
rows := []KeyValue{{Key: "page", Value: "1", Enabled: true}, {Key: "debug", Value: "true"}}
n := 0
tt := kvTester(&rows, KeyValueInputOptions{}, &n)
_ = tt.Click("Row 2") // its enable box
if !rows[1].Enabled || n != 1 {
t.Fatalf("toggle: %+v", rows)
}
_ = tt.Click("Add row")
if len(rows) != 3 || !rows[2].Enabled {
t.Fatalf("add: %+v", rows)
}
_ = tt.Click("Row 3 Key")
tt.Type("sort")
tt.Key(0, ui.KeyTab)
tt.Type("name")
if rows[2].Key != "sort" || rows[2].Value != "name" {
t.Fatalf("typing: %+v", rows[2])
}
_ = tt.Click("Remove row 1")
if len(rows) != 2 || rows[0].Key != "debug" {
t.Fatalf("remove: %+v", rows)
}
if got := keyValueEnabled(rows); len(got) != 2 {
t.Fatalf("enabled pairs: %+v", got)
}
}
func TestKeyValueInputDisabled(t *testing.T) {
rows := []KeyValue{{Key: "page", Value: "1"}}
n := 0
tt := kvTester(&rows, KeyValueInputOptions{Disabled: true}, &n)
_ = tt.Click("Row 1")
_ = tt.Click("Add row")
_ = tt.Click("Remove row 1")
_ = tt.Click("Row 1 Key")
tt.Type("x")
if n != 0 || len(rows) != 1 || rows[0].Enabled || rows[0].Key != "page" {
t.Fatalf("disabled editor changed: %+v", rows)
}
}
func TestKeyValueInputSnapshot(t *testing.T) {
snapshot(t, "key_value_input", 560, 200, func(c *ui.Context) {
rows := []KeyValue{{Key: "Accept", Value: "application/json", Enabled: true}, {Key: "X-Trace", Value: "on"}}
ui.Column(c).Padding(16).Children(func() { KeyValueInput(c, &rows, KeyValueInputOptions{}) })
})
}
+122
View File
@@ -0,0 +1,122 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// LogStreamState is a LogStream's pause, level filter and place.
type LogStreamState struct {
Paused bool
// MinLevel hides lines below it.
MinLevel LogLevel
// frozen is the line count at pause; lines after it wait.
frozen int
shown []int
scanned int
min LogLevel
rows ui.ListState
}
// LogStreamOptions configure a LogStream.
type LogStreamOptions struct {
// Height of the line well; zero means 320.
Height float32
Disabled bool
}
// LogStream shows live ANSI log lines that follow the end, pause and filter by level, virtualized.
func LogStream(c *ui.Context, lines []LogLine, s *LogStreamState, opts LogStreamOptions) ChoiceResult {
if s == nil {
panic("mujica: LogStream needs a state")
}
if opts.Height < 0 || s.MinLevel < LogDebug || s.MinLevel > LogError {
panic("mujica: LogStream needs Height >= 0 and a known MinLevel")
}
h := opts.Height
if h == 0 {
h = 320
}
var r ChoiceResult
r.Element = codePanel(c).Disabled(opts.Disabled).Height(h)
locked := r.Element.IsDisabled()
r.Element.Children(func() {
codeHeader(c, func() {
was := s.Paused
label := msg(c, "log_stream.pause")
if s.Paused {
label = msg(c, "log_stream.resume")
}
if Toggle(c, &s.Paused, label, ToggleOptions{Disabled: locked}).Changed() && !locked {
r.changed = true
} else {
s.Paused = was
}
levels := make([]Segment, 4)
for lv := LogDebug; lv <= LogError; lv++ {
levels[lv] = Segment{Label: logViewerName(c, lv)}
}
pick := int(s.MinLevel)
SegmentedControl(c, &pick, levels, SegmentedControlOptions{Label: msg(c, "log_stream.level"), Disabled: locked})
if LogLevel(pick) != s.MinLevel && !locked {
s.MinLevel, r.changed = LogLevel(pick), true
}
ui.Box(c).Grow(1)
if s.Paused && len(lines) > s.frozen {
codeCaption(c, fmt.Sprintf(msg(c, "log_stream.waiting"), len(lines)-s.frozen)).Role(ui.RoleStatus)
}
})
if !s.Paused {
s.frozen = len(lines)
}
logStreamFilter(lines[:min(s.frozen, len(lines))], s)
if len(s.shown) == 0 {
codeCaption(c, msg(c, "log_stream.empty")).Padding(Density(c).Unit() * 4)
return
}
s.rows.FollowEnd = !s.Paused
s.rows.Key = func(i int) any { return s.shown[i] }
d := Density(c)
ui.List(c, &s.rows, len(s.shown), func(i int) { logStreamRow(c, lines[s.shown[i]]) }).Grow(1).Padding(d.Unit(), 0)
})
if r.changed {
slog.Debug("mujica: log_stream change", "paused", s.Paused, "min_level", s.MinLevel)
}
return r
}
// logStreamFilter extends the shown lines, or redoes them after a filter change.
func logStreamFilter(lines []LogLine, s *LogStreamState) {
if s.min != s.MinLevel || s.scanned > len(lines) {
s.min, s.scanned, s.shown = s.MinLevel, 0, s.shown[:0]
}
for i := s.scanned; i < len(lines); i++ {
lv := lines[i].Level
if lv < LogDebug || lv > LogError {
panic("mujica: LogLine level out of range")
}
if lv >= s.MinLevel {
s.shown = append(s.shown, i)
}
}
s.scanned = len(lines)
}
// logStreamRow shows one line: time, level word and ANSI text.
func logStreamRow(c *ui.Context, l LogLine) {
k := Tokens(c)
d := Density(c)
col := [...]ui.Color{k.TextMuted, k.Info, k.Warning, k.Danger}[l.Level]
ui.Row(c).MinHeight(d.ControlHeight() - 8).Padding(0, d.Unit()*3).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() {
if !l.Time.IsZero() {
codeMono(c, l.Time.Format("15:04:05.000")).TextColor(k.TextMuted).Shrink(0)
}
ui.Text(c, fmt.Sprintf("%-5s", logViewerName(c, l.Level))).Font(theme.MonoFont).FontSize(FontSize(c, codeSize)).
FontWeight(700).TextColor(col).SingleLine().Shrink(0)
ui.RichText(c, codeAnsiSpans(c, l.Text)...).Font(theme.MonoFont).FontSize(FontSize(c, codeSize)).Grow(1).MinWidth(0)
})
}
+97
View File
@@ -0,0 +1,97 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
func logStreamLines(n int) []LogLine {
out := make([]LogLine, n)
for i := range out {
out[i] = LogLine{Level: LogLevel(i % 4), Text: fmt.Sprintf("\x1b[1mline\x1b[0m %d", i)}
}
return out
}
func logStreamTester(lines *[]LogLine, s *LogStreamState, opts LogStreamOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if LogStream(c, *lines, s, opts).Changed() {
*changes++
}
}, 700, 400)
}
func TestLogStreamFollowPauseResume(t *testing.T) {
lines := logStreamLines(10)
var s LogStreamState
n := 0
tt := logStreamTester(&lines, &s, LogStreamOptions{}, &n)
if !tt.HasText("line 9") || tt.HasText("\x1b[1mline") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Pause")
lines = append(lines, logStreamLines(13)[10:]...)
tt.Frame()
if !s.Paused || tt.HasText("line 12") || !tt.HasText("3 new lines") {
t.Fatalf("paused: %q", tt.Texts())
}
_ = tt.Click("Resume")
if s.Paused || !tt.HasText("line 12") || n != 2 {
t.Fatalf("resume: %q changes %d", tt.Texts(), n)
}
}
func TestLogStreamLevelFilter(t *testing.T) {
lines := logStreamLines(8)
var s LogStreamState
n := 0
tt := logStreamTester(&lines, &s, LogStreamOptions{}, &n)
_ = tt.Click("WARN")
if s.MinLevel != LogWarn || tt.HasText("line 5") || !tt.HasText("line 6") || !tt.HasText("line 7") {
t.Fatalf("filter: %v %q", s.MinLevel, tt.Texts())
}
tt.Key(0, ui.KeyRight)
if s.MinLevel != LogError || tt.HasText("line 6") {
t.Fatalf("keyboard filter: %v", s.MinLevel)
}
}
func TestLogStreamDisabledEmptyMany(t *testing.T) {
lines := logStreamLines(4)
var s LogStreamState
n := 0
tt := logStreamTester(&lines, &s, LogStreamOptions{Disabled: true}, &n)
_ = tt.Click("Pause")
_ = tt.Click("WARN")
if n != 0 || s.Paused || s.MinLevel != LogDebug {
t.Fatalf("disabled changed: %+v", s)
}
none := []LogLine{}
tt = logStreamTester(&none, &LogStreamState{}, LogStreamOptions{}, &n)
if !tt.HasText("No log lines") {
t.Fatal("empty text missing")
}
many := logStreamLines(100000)
var ms LogStreamState
tt = logStreamTester(&many, &ms, LogStreamOptions{}, &n)
if got := len(tt.Texts()); got > 300 || !tt.HasText("line 99999") {
t.Fatalf("%d texts; last line shown %v", got, tt.HasText("line 99999"))
}
}
func TestLogStreamSnapshot(t *testing.T) {
lines := []LogLine{
{Level: LogInfo, Text: "server listening on :8080"},
{Level: LogDebug, Text: "cache warm: 312 keys"},
{Level: LogWarn, Text: "slow query \x1b[33m412 ms\x1b[0m on guests"},
{Level: LogError, Text: "\x1b[31mpayment provider unreachable\x1b[0m"},
{Level: LogInfo, Text: "retry 1 of 3 succeeded"},
}
snapshot(t, "log_stream", 640, 260, func(c *ui.Context) {
var s LogStreamState
ui.Column(c).Padding(16).Children(func() { LogStream(c, lines, &s, LogStreamOptions{Height: 220}) })
})
}
+111
View File
@@ -0,0 +1,111 @@
package mujica
func init() {
addMessages(map[string][2]string{
"key_value_input.key": {"Key", "键"},
"key_value_input.value": {"Value", "值"},
"key_value_input.row": {"Row %d", "第 %d 行"},
"key_value_input.remove": {"Remove row %d", "删除第 %d 行"},
"key_value_input.add": {"Add row", "添加一行"},
"stat_card.change": {"Change %s", "变化 %s"},
"devtools.ago": {"%s ago", "%s 前"},
"alert_list.info": {"Info", "提示"},
"alert_list.warning": {"Warning", "警告"},
"alert_list.critical": {"Critical", "严重"},
"alert_list.ack": {"Acknowledge", "确认"},
"alert_list.acked": {"Acknowledged", "已确认"},
"alert_list.silence": {"Silence 1 h", "静默 1 小时"},
"alert_list.silenced": {"Silenced until %s", "静默至 %s"},
"incident_card.investigating": {"Investigating", "调查中"},
"incident_card.identified": {"Identified", "已定位"},
"incident_card.monitoring": {"Monitoring", "观察中"},
"incident_card.resolved": {"Resolved", "已解决"},
"incident_card.none": {"none", "无"},
"incident_card.minor": {"minor", "轻微"},
"incident_card.major": {"major", "严重"},
"incident_card.critical": {"critical", "致命"},
"incident_card.impact": {"Impact: %s", "影响:%s"},
"incident_card.started": {"Started %s", "开始于 %s"},
"service_status.operational": {"Operational", "正常"},
"service_status.degraded": {"Degraded", "降级"},
"service_status.outage": {"Outage", "中断"},
"service_status.all_ok": {"All systems operational", "所有服务正常"},
"service_status.some_degraded": {"Some services degraded", "部分服务降级"},
"service_status.some_down": {"Some services down", "部分服务中断"},
"service_status.counts": {"%d up · %d degraded · %d down", "%d 正常 · %d 降级 · %d 中断"},
"service_status.empty": {"No services", "没有服务"},
"uptime_bar.ok": {"Operational", "正常"},
"uptime_bar.degraded": {"Degraded", "降级"},
"uptime_bar.down": {"Outage", "中断"},
"uptime_bar.nodata": {"No data", "无数据"},
"resource_gauge.normal": {"Normal", "正常"},
"resource_gauge.high": {"High", "偏高"},
"resource_gauge.critical": {"Critical", "危险"},
"refresh_interval_selector.off": {"Auto refresh off", "不自动刷新"},
"refresh_interval_selector.refresh": {"Refresh", "刷新"},
"refresh_interval_selector.refreshing": {"Refreshing", "正在刷新"},
"refresh_interval_selector.seconds": {"Every %d s", "每 %d 秒"},
"refresh_interval_selector.minutes": {"Every %d min", "每 %d 分钟"},
"refresh_interval_selector.label": {"Refresh interval", "刷新间隔"},
"dashboard_filter_bar.label": {"Dashboard filters", "仪表盘过滤"},
"dashboard_filter_bar.range": {"Time range", "时间范围"},
"dashboard_filter_bar.default_range": {"Default range", "默认范围"},
"dashboard_filter_bar.last": {"Last %s", "最近 %s"},
"dashboard_filter_bar.minutes": {"%d min", "%d 分钟"},
"dashboard_filter_bar.hours": {"%d h", "%d 小时"},
"dashboard_filter_bar.days": {"%d d", "%d 天"},
"dashboard_filter_bar.env": {"Environment", "环境"},
"dashboard_filter_bar.all_envs": {"All environments", "全部环境"},
"dashboard_filter_bar.tags": {"Tags", "标签"},
"dashboard_filter_bar.clear": {"Clear filters", "清除过滤"},
"log_stream.pause": {"Pause", "暂停"},
"log_stream.resume": {"Resume", "继续"},
"log_stream.level": {"Minimum level", "最低级别"},
"log_stream.waiting": {"%d new lines", "%d 条新日志"},
"log_stream.empty": {"No log lines", "没有日志"},
"trace_waterfall.label": {"Trace", "调用链"},
"trace_waterfall.empty": {"No spans", "没有跨度"},
"trace_waterfall.summary": {"%d spans · %s", "%d 个跨度 · %s"},
"trace_waterfall.detail": {"%s · starts at %s · takes %s", "%s · 起于 %s · 耗时 %s"},
"trace_waterfall.error": {"Error", "错误"},
"json_viewer.invalid": {"Invalid JSON: %v", "JSON 无效:%v"},
"json_viewer.copy": {"Copy path", "复制路径"},
"json_viewer.items": {"%s %d items %s", "%s %d 项 %s"},
"json_editor.label": {"JSON", "JSON"},
"json_editor.format": {"Format", "格式化"},
"json_editor.valid": {"Valid JSON", "JSON 有效"},
"json_editor.at": {"Error at line %d, column %d", "错误位于第 %d 行第 %d 列"},
"schema_tree.label": {"Schema", "数据库结构"},
"schema_tree.empty": {"No databases", "没有数据库"},
"schema_tree.database": {"Database", "数据库"},
"schema_tree.table": {"Table", "表"},
"schema_tree.view": {"View", "视图"},
"schema_tree.column": {"Column", "列"},
"schema_tree.index": {"Index", "索引"},
"api_request_builder.method": {"Method", "方法"},
"api_request_builder.url": {"Request URL", "请求地址"},
"api_request_builder.send": {"Send", "发送"},
"api_request_builder.params": {"Params (%d)", "参数(%d)"},
"api_request_builder.params_label": {"Query parameters", "查询参数"},
"api_request_builder.headers": {"Headers (%d)", "请求头(%d)"},
"api_request_builder.headers_label": {"Request headers", "请求头"},
"api_request_builder.body": {"Body", "正文"},
"api_request_builder.no_body": {"GET and HEAD requests usually carry no body.", "GET 与 HEAD 请求通常不带正文。"},
"api_request_builder.parts": {"Request parts", "请求组成"},
"alert_list.empty": {"No alerts", "没有告警"},
})
}
+90
View File
@@ -0,0 +1,90 @@
package mujica
import (
"fmt"
"log/slog"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
)
// RefreshIntervalSelectorOptions configure a RefreshIntervalSelector.
type RefreshIntervalSelectorOptions struct {
// Intervals to offer besides Off; nil uses 5 s, 10 s, 30 s, 1 m and 5 m.
Intervals []time.Duration
// Refreshing shows the refresh in progress and holds the timer.
Refreshing bool
Disabled bool
}
// RefreshResult reports this frame's events.
type RefreshResult struct {
Element *ui.Element
// Refresh asks the caller to reload, by hand or because the interval elapsed.
Refresh bool
// Changed reports a new interval.
Changed bool
}
type refreshState struct{ last time.Time }
var refreshDefaults = []time.Duration{5 * time.Second, 10 * time.Second, 30 * time.Second, time.Minute, 5 * time.Minute}
// RefreshIntervalSelector picks an auto-refresh interval, zero for off, and refreshes by hand.
func RefreshIntervalSelector(c *ui.Context, interval *time.Duration, opts RefreshIntervalSelectorOptions) RefreshResult {
if interval == nil {
panic("mujica: RefreshIntervalSelector needs an interval")
}
choices := opts.Intervals
if choices == nil {
choices = refreshDefaults
}
options := []SelectOption[time.Duration]{{Value: 0, Label: msg(c, "refresh_interval_selector.off")}}
known := *interval == 0
for _, d := range choices {
if d < time.Second || d%time.Second != 0 {
panic("mujica: RefreshIntervalSelector intervals must be whole seconds")
}
known = known || d == *interval
label := fmt.Sprintf(msg(c, "refresh_interval_selector.seconds"), int(d/time.Second))
if d%time.Minute == 0 {
label = fmt.Sprintf(msg(c, "refresh_interval_selector.minutes"), int(d/time.Minute))
}
options = append(options, SelectOption[time.Duration]{Value: d, Label: label})
}
if !known {
panic("mujica: RefreshIntervalSelector interval is not offered")
}
u := Density(c).Unit()
var r RefreshResult
r.Element = ui.Row(c).Shrink(0).Gap(u * 2).AlignItems(ui.Center).Disabled(opts.Disabled)
st := ui.Local(r.Element, "mujica.refresh", func() refreshState { return refreshState{last: c.Now()} })
locked := r.Element.IsDisabled()
r.Element.Children(func() {
label := msg(c, "refresh_interval_selector.refresh")
if opts.Refreshing {
Spinner(c, SpinnerOptions{Size: 14, Label: msg(c, "refresh_interval_selector.refreshing")})
}
if Button(c, label, ButtonOptions{Variant: Secondary, Icon: icons.Must("refresh-cw"), Disabled: locked || opts.Refreshing}).Clicked() && !locked {
r.Refresh = true
}
r.Changed = Select(c, interval, options, SelectOptions{Label: msg(c, "refresh_interval_selector.label"), Disabled: locked}).Changed()
})
if r.Changed || opts.Refreshing {
st.last = c.Now()
}
if *interval > 0 && !locked && !opts.Refreshing && !r.Refresh {
if wait := *interval - c.Now().Sub(st.last); wait > 0 {
c.After(wait)
} else {
r.Refresh = true
}
}
if r.Refresh {
st.last = c.Now()
slog.Debug("mujica: refresh_interval_selector refresh", "interval", *interval)
}
return r
}
+86
View File
@@ -0,0 +1,86 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func refreshTester(interval *time.Duration, opts *RefreshIntervalSelectorOptions, refreshes, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
r := RefreshIntervalSelector(c, interval, *opts)
if r.Refresh {
*refreshes++
}
if r.Changed {
*changes++
}
}, 500, 300)
}
func TestRefreshIntervalSelectorManualAndAuto(t *testing.T) {
interval := time.Duration(0)
opts := RefreshIntervalSelectorOptions{Intervals: []time.Duration{time.Second, time.Minute}}
refreshes, changes := 0, 0
tt := refreshTester(&interval, &opts, &refreshes, &changes)
_ = tt.Click("Refresh")
if refreshes != 1 {
t.Fatalf("manual refreshes %d", refreshes)
}
time.Sleep(1100 * time.Millisecond)
tt.Frame()
if refreshes != 1 {
t.Fatal("refreshed while off")
}
_ = tt.Click("Refresh interval")
_ = tt.Click("Every 1 s")
if interval != time.Second || changes != 1 {
t.Fatalf("interval %v changes %d", interval, changes)
}
start := time.Now()
if !overlayUntil(tt, func() bool { return refreshes == 2 }) {
t.Fatal("no automatic refresh")
}
if time.Since(start) < 900*time.Millisecond {
t.Fatal("refreshed before the interval")
}
}
func TestRefreshIntervalSelectorRefreshingAndDisabled(t *testing.T) {
interval := time.Second
opts := RefreshIntervalSelectorOptions{Intervals: []time.Duration{time.Second}, Refreshing: true}
refreshes, changes := 0, 0
tt := refreshTester(&interval, &opts, &refreshes, &changes)
_ = tt.Click("Refresh")
time.Sleep(1100 * time.Millisecond)
tt.Frame()
if refreshes != 0 || !tt.HasText("Refresh") {
t.Fatalf("refreshing: %d", refreshes)
}
opts = RefreshIntervalSelectorOptions{Intervals: []time.Duration{time.Second}, Disabled: true}
tt.Frame()
time.Sleep(1100 * time.Millisecond)
tt.Frame()
_ = tt.Click("Refresh")
if refreshes != 0 {
t.Fatalf("disabled refreshed %d", refreshes)
}
defer func() {
if recover() == nil {
t.Fatal("unoffered interval did not panic")
}
}()
bad := 7 * time.Second
refreshTester(&bad, &RefreshIntervalSelectorOptions{}, &refreshes, &changes)
}
func TestRefreshIntervalSelectorSnapshot(t *testing.T) {
snapshot(t, "refresh_interval_selector", 420, 80, func(c *ui.Context) {
interval := 30 * time.Second
ui.Column(c).Padding(16).Children(func() {
RefreshIntervalSelector(c, &interval, RefreshIntervalSelectorOptions{})
})
})
}
+69
View File
@@ -0,0 +1,69 @@
package mujica
import (
"fmt"
"math"
"github.com/egoist/mygo/ui"
)
// ResourceReading is one resource's current use.
type ResourceReading struct {
// Name is required, as "CPU".
Name string
Used, Total float64
// Unit follows the amounts, as "GB"; empty shows percent only.
Unit string
}
// ResourceGaugeOptions configure a ResourceGauge; thresholds are fractions of Total.
type ResourceGaugeOptions struct {
// Warn and Critical default to 0.7 and 0.9.
Warn, Critical float64
// Height of each dial; zero means 150.
Height float32
}
// ResourceGauge shows CPU, memory and disk dials with named normal, high and critical bands.
func ResourceGauge(c *ui.Context, readings []ResourceReading, opts ResourceGaugeOptions) *ui.Element {
warn, crit := opts.Warn, opts.Critical
if warn == 0 {
warn = 0.7
}
if crit == 0 {
crit = 0.9
}
if !(0 < warn && warn < crit && crit < 1) || opts.Height < 0 {
panic("mujica: ResourceGauge needs 0 < Warn < Critical < 1 and Height >= 0")
}
h := opts.Height
if h == 0 {
h = 150
}
u := Density(c).Unit()
row := ui.Row(c).Wrap().Gap(u * 4).Shrink(0)
row.Children(func() {
for i, r := range readings {
if r.Name == "" || !(r.Total > 0) || r.Used < 0 || r.Used > r.Total || math.IsNaN(r.Used) {
panic(fmt.Sprintf("mujica: ResourceGauge reading %d needs a Name and 0 <= Used <= Total", i))
}
frac := r.Used / r.Total
ui.Column(c).Key(r.Name).Width(200).Gap(u).AlignItems(ui.Center).Children(func() {
ui.Text(c, r.Name).FontWeight(600)
Gauge(c, frac*100, GaugeOptions{
Frame: ChartFrame{Label: r.Name, Height: h}, Min: 0, Max: 100, Unit: "%",
Format: func(v float64) string { return fmt.Sprintf("%.0f", v) },
Segments: []GaugeSegment{
{To: warn * 100, Label: msg(c, "resource_gauge.normal"), Tone: GaugeGood},
{To: crit * 100, Label: msg(c, "resource_gauge.high"), Tone: GaugeWarning},
{To: 100, Label: msg(c, "resource_gauge.critical"), Tone: GaugeDanger},
},
})
if r.Unit != "" {
agentCaption(c, fmt.Sprintf("%s / %s %s", sandboxNum(r.Used), sandboxNum(r.Total), r.Unit))
}
})
}
})
return row
}
+56
View File
@@ -0,0 +1,56 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
var resourceSample = []ResourceReading{
{Name: "CPU", Used: 45, Total: 100},
{Name: "Memory", Used: 6.4, Total: 8, Unit: "GB"},
{Name: "Disk", Used: 470, Total: 500, Unit: "GB"},
}
func TestResourceGaugeThresholdsAndKeyboard(t *testing.T) {
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ResourceGauge(c, resourceSample, ResourceGaugeOptions{})
}, 700, 300)
if !tt.HasText("6.4 / 8 GB") || !tt.HasText("470 / 500 GB") {
t.Fatalf("texts %q", tt.Texts())
}
tt.Key(0, ui.KeyTab) // CPU dial
tt.Key(0, ui.KeyRight)
if !tt.HasText("45 % · Normal") {
t.Fatalf("CPU readout: %q", tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if !tt.HasText("80 % · High") {
t.Fatalf("memory readout: %q", tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
if !tt.HasText("94 % · Critical") {
t.Fatalf("disk readout: %q", tt.Texts())
}
}
func TestResourceGaugeRejectsBadReading(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ResourceGauge(c, []ResourceReading{{Name: "CPU", Used: 5, Total: 4}}, ResourceGaugeOptions{})
}, 200, 200)
}
func TestResourceGaugeSnapshot(t *testing.T) {
snapshot(t, "resource_gauge", 680, 250, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { ResourceGauge(c, resourceSample, ResourceGaugeOptions{}) })
})
}
+155
View File
@@ -0,0 +1,155 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// SchemaKind is what a schema node is. The zero value is SchemaDatabase.
type SchemaKind int
const (
SchemaDatabase SchemaKind = iota
SchemaTable
SchemaView
SchemaColumn
SchemaIndex
)
// SchemaNode is one database object; Detail is a column type or index columns.
type SchemaNode struct {
Name string
Kind SchemaKind
Detail string
Children []SchemaNode
}
// SchemaTreeState is a SchemaTree's open nodes and choice.
type SchemaTreeState struct {
outline ui.OutlineState[string]
selected int
nodes map[string]*SchemaNode
kids map[string][]string
roots []string
src []SchemaNode
}
// Selected returns the chosen node's path, as "shop/orders/id", and whether one is chosen.
func (s *SchemaTreeState) Selected() (string, bool) {
if s.selected < 0 || s.selected >= s.outline.Rows() {
return "", false
}
return s.outline.Item(s.selected), true
}
// SchemaTreeOptions configure a SchemaTree.
type SchemaTreeOptions struct {
// Height of the tree; zero means 360.
Height float32
}
// SchemaTree shows databases, tables, views, columns and indexes as a keyboard tree, virtualized.
func SchemaTree(c *ui.Context, s *SchemaTreeState, roots []SchemaNode, opts SchemaTreeOptions) ChoiceResult {
if s == nil || opts.Height < 0 {
panic("mujica: SchemaTree needs a state and Height >= 0")
}
h := opts.Height
if h == 0 {
h = 360
}
schemaIndex(s, roots)
k := Tokens(c)
d := Density(c)
var r ChoiceResult
r.Element = codePanel(c).Height(h).Label(msg(c, "schema_tree.label"))
r.Element.Children(func() {
if len(s.roots) == 0 {
codeCaption(c, msg(c, "schema_tree.empty")).Padding(d.Unit() * 4)
return
}
was := s.selected
s.outline.List.Selected = &s.selected
s.outline.List.Label = func(i int) string { return s.nodes[s.outline.Item(i)].Name }
restore := dataSelectTheme(c, 0)
e := ui.Outline(c, &s.outline, s.roots, func(p string) []string { return s.kids[p] }, func(p string) {
n := s.nodes[p]
ui.Row(c).Grow(1).MinWidth(0).MinHeight(d.ControlHeight()-2*d.Unit()).Gap(d.Unit()*2).AlignItems(ui.Center).
Label(fmt.Sprintf("%s %s %s", schemaWord(c, n.Kind), n.Name, n.Detail)).Children(func() {
sel, _ := s.Selected()
codeSelectMark(c, sel == p)
letter, col := schemaBadge(k, n.Kind)
codeBadge(c, letter, col)
ui.Text(c, n.Name).SingleLine().Shrink(0)
if n.Detail != "" {
codeMono(c, n.Detail).TextColor(k.TextMuted).MinWidth(0)
}
})
}).Grow(1)
restore()
internal.Ring(e, k.Focus, theme.ControlRadius)
r.changed = s.selected != was
})
if r.changed {
p, _ := s.Selected()
slog.Debug("mujica: schema_tree select", "path", p)
}
return r
}
// schemaIndex maps paths to nodes once per new schema.
func schemaIndex(s *SchemaTreeState, roots []SchemaNode) {
if s.nodes != nil && len(roots) == len(s.src) && (len(roots) == 0 || &roots[0] == &s.src[0]) {
return
}
if s.nodes == nil {
s.selected = -1 // nothing chosen at first
}
s.src, s.nodes, s.kids, s.roots = roots, map[string]*SchemaNode{}, map[string][]string{}, nil
var walk func(list []SchemaNode, parent string) []string
walk = func(list []SchemaNode, parent string) []string {
var paths []string
for i := range list {
n := &list[i]
if n.Name == "" || n.Kind < SchemaDatabase || n.Kind > SchemaIndex {
panic(fmt.Sprintf("mujica: SchemaTree node %q needs a Name and a known Kind", n.Name))
}
p := n.Name
if parent != "" {
p = parent + "/" + n.Name
}
if _, dup := s.nodes[p]; dup {
panic("mujica: SchemaTree has two nodes at " + p)
}
s.nodes[p] = n
paths = append(paths, p)
if len(n.Children) > 0 {
s.kids[p] = walk(n.Children, p)
}
}
return paths
}
s.roots = walk(roots, "")
}
func schemaBadge(k theme.Tokens, kind SchemaKind) (string, ui.Color) {
switch kind {
case SchemaDatabase:
return "D", k.AccentText
case SchemaTable:
return "T", k.Info
case SchemaView:
return "V", k.Success
case SchemaIndex:
return "I", k.Warning
}
return "C", k.TextMuted
}
func schemaWord(c *ui.Context, kind SchemaKind) string {
return msg(c, [...]string{"schema_tree.database", "schema_tree.table", "schema_tree.view", "schema_tree.column", "schema_tree.index"}[kind])
}
+90
View File
@@ -0,0 +1,90 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
var schemaSample = []SchemaNode{{Name: "court", Kind: SchemaDatabase, Children: []SchemaNode{
{Name: "guests", Kind: SchemaTable, Children: []SchemaNode{
{Name: "id", Kind: SchemaColumn, Detail: "bigint primary key"},
{Name: "name", Kind: SchemaColumn, Detail: "text not null"},
{Name: "guests_name_idx", Kind: SchemaIndex, Detail: "(name)"},
}},
{Name: "seating", Kind: SchemaView},
}}}
func schemaTester(s *SchemaTreeState, roots []SchemaNode, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if SchemaTree(c, s, roots, SchemaTreeOptions{}).Changed() {
*changes++
}
}, 520, 420)
}
func TestSchemaTreeKeyboardOpenAndChoose(t *testing.T) {
var s SchemaTreeState
n := 0
tt := schemaTester(&s, schemaSample, &n)
if _, ok := s.Selected(); ok || tt.HasText("guests") {
t.Fatalf("start: %q", tt.Texts())
}
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown) // court
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyDown) // guests
tt.Key(0, ui.KeyRight)
for _, s := range []string{"guests", "seating", "bigint primary key", "(name)"} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %q", s, tt.Texts())
}
}
tt.Key(0, ui.KeyDown)
if p, _ := s.Selected(); p != "court/guests/id" || n == 0 {
t.Fatalf("selected %q changes %d", p, n)
}
}
func TestSchemaTreeEmptyRulesMany(t *testing.T) {
var s SchemaTreeState
n := 0
tt := schemaTester(&s, nil, &n)
if !tt.HasText("No databases") {
t.Fatal("empty text")
}
cols := make([]SchemaNode, 100000)
for i := range cols {
cols[i] = SchemaNode{Name: fmt.Sprint("c", i), Kind: SchemaColumn}
}
big := []SchemaNode{{Name: "wide", Kind: SchemaTable, Children: cols}}
var bs SchemaTreeState
tt = schemaTester(&bs, big, &n)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyRight)
if got := len(tt.Texts()); got > 400 || !tt.HasText("c0") {
t.Fatalf("%d texts for 100000 columns", got)
}
defer func() {
if recover() == nil {
t.Fatal("duplicate path did not panic")
}
}()
var ds SchemaTreeState
schemaTester(&ds, []SchemaNode{{Name: "a"}, {Name: "a"}}, &n)
}
func TestSchemaTreeSnapshot(t *testing.T) {
var s SchemaTreeState
snapshotAfter(t, "schema_tree", 500, 300, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { SchemaTree(c, &s, schemaSample, SchemaTreeOptions{Height: 260}) })
}, func(tt *ui.Tester) {
s = SchemaTreeState{}
for _, k := range []ui.Key{ui.KeyTab, ui.KeyDown, ui.KeyRight, ui.KeyDown, ui.KeyRight, ui.KeyDown} {
tt.Key(0, k)
}
})
}
+91
View File
@@ -0,0 +1,91 @@
package mujica
import (
"fmt"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// ServiceHealth is a service's state. The zero value is ServiceOperational.
type ServiceHealth int
const (
ServiceOperational ServiceHealth = iota
ServiceDegraded
ServiceOutage
)
// ServiceItem is one service the caller reports.
type ServiceItem struct {
Name string
Health ServiceHealth
// Note explains a degraded or down service.
Note string
}
// ServiceStatusOptions configure a ServiceStatus.
type ServiceStatusOptions struct {
// MaxHeight caps the scrolling list; zero means 360.
MaxHeight float32
}
// ServiceStatus lists services as operational, degraded or down, under an overall summary.
func ServiceStatus(c *ui.Context, services []ServiceItem, opts ServiceStatusOptions) *ui.Element {
if opts.MaxHeight < 0 {
panic("mujica: ServiceStatus MaxHeight is negative")
}
worst, counts := ServiceOperational, [3]int{}
for i, s := range services {
if s.Name == "" || s.Health < ServiceOperational || s.Health > ServiceOutage {
panic(fmt.Sprintf("mujica: ServiceStatus service %d needs a Name and a known Health", i))
}
worst = max(worst, s.Health)
counts[s.Health]++
}
maxH := opts.MaxHeight
if maxH == 0 {
maxH = 360
}
k := Tokens(c)
u := Density(c).Unit()
summary := msg(c, [...]string{"service_status.all_ok", "service_status.some_degraded", "service_status.some_down"}[worst])
col := ui.Column(c).Shrink(0).Gap(u * 2).Label(summary)
col.Children(func() {
ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Padding(u*3).Radius(theme.CardRadius).Border(theme.BorderWidth, k.Border).
Background(k.Surface).Role(ui.RoleStatus).Label(summary).Children(func() {
devtoolsSeverity(c, serviceSeverity(worst), summary)
ui.Box(c).Grow(1)
agentCaption(c, fmt.Sprintf(msg(c, "service_status.counts"), counts[0], counts[1], counts[2]))
})
if len(services) == 0 {
agentCaption(c, msg(c, "service_status.empty"))
return
}
agentRows(c, len(services), func(i int) {
s := services[i]
word := serviceWord(c, s.Health)
ui.Row(c).Key(i).Gap(u * 3).AlignItems(ui.Center).MinHeight(Density(c).ControlHeight()).Padding(u, u*3).
Label(s.Name + ", " + word).Children(func() {
ui.Column(c).Grow(1).Shrink(1).Children(func() {
ui.Text(c, s.Name).SingleLine()
if s.Note != "" {
agentCaption(c, s.Note)
}
})
devtoolsSeverity(c, serviceSeverity(s.Health), word)
})
}).Shrink(1).MaxHeight(maxH).Background(k.Surface).Border(theme.BorderWidth, k.Border).
Radius(theme.CardRadius).Dividers(theme.BorderWidth, k.Border)
})
return col
}
func serviceSeverity(h ServiceHealth) Severity {
return [...]Severity{SeveritySuccess, SeverityWarning, SeverityError}[h]
}
func serviceWord(c *ui.Context, h ServiceHealth) string {
return msg(c, [...]string{"service_status.operational", "service_status.degraded", "service_status.outage"}[h])
}
+57
View File
@@ -0,0 +1,57 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
var servicesSample = []ServiceItem{
{Name: "API", Health: ServiceOperational},
{Name: "Search", Health: ServiceDegraded, Note: "Indexing is slow"},
{Name: "Payments", Health: ServiceOutage, Note: "Provider unreachable"},
}
func TestServiceStatusSummaryAndRows(t *testing.T) {
services := servicesSample
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ServiceStatus(c, services, ServiceStatusOptions{})
}, 520, 400)
for _, s := range []string{"Some services down", "1 up · 1 degraded · 1 down", "Degraded", "Outage", "Provider unreachable"} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %q", s, tt.Texts())
}
}
services = servicesSample[:1]
tt.Frame()
if !tt.HasText("All systems operational") {
t.Fatalf("all ok: %q", tt.Texts())
}
services = nil
tt.Frame()
if !tt.HasText("No services") {
t.Fatal("empty")
}
}
func TestServiceStatusManyVirtualized(t *testing.T) {
many := make([]ServiceItem, 100000)
for i := range many {
many[i] = ServiceItem{Name: fmt.Sprint("svc ", i)}
}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ServiceStatus(c, many, ServiceStatusOptions{})
}, 520, 400)
if n := len(tt.Texts()); n > 300 {
t.Fatalf("%d texts", n)
}
}
func TestServiceStatusSnapshot(t *testing.T) {
snapshot(t, "service_status", 520, 280, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { ServiceStatus(c, servicesSample, ServiceStatusOptions{}) })
})
}
+106
View File
@@ -0,0 +1,106 @@
package mujica
import (
"fmt"
"log/slog"
"math"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/theme"
)
// StatCardOptions configure a StatCard. Label is required.
type StatCardOptions struct {
Label string
// Change is the relative change, 0.12 for +12%; HasChange shows it.
Change float64
HasChange bool
// LowerIsBetter colors a fall as good, as for latency.
LowerIsBetter bool
// Trend draws a sparkline; nil hides it.
Trend []float64
Status DataStatus
Error string
}
// StatCardResult reports this frame's events.
type StatCardResult struct {
Element *ui.Element
Retry bool
}
// StatCard shows a statistic, its change and trend, or loading and error states at the same size.
func StatCard(c *ui.Context, value string, opts StatCardOptions) StatCardResult {
if opts.Label == "" {
panic("mujica: StatCard needs a Label")
}
if math.IsNaN(opts.Change) || math.IsInf(opts.Change, 0) {
panic("mujica: StatCard Change must be finite")
}
if opts.Status != DataReady && opts.Status != DataLoading && opts.Status != DataFailed {
panic(fmt.Sprintf("mujica: StatCard Status %d is not ready, loading or failed", int(opts.Status)))
}
k := Tokens(c)
u := Density(c).Unit()
var r StatCardResult
r.Element = agentCard(c).Height(116).MinWidth(180).Label(opts.Label)
r.Element.Children(func() {
agentCaption(c, opts.Label)
switch opts.Status {
case DataLoading:
ui.Column(c).Grow(1).Justify(ui.Center).Children(func() {
Spinner(c, SpinnerOptions{Label: msg(c, "list.loading"), ShowLabel: true})
})
return
case DataFailed:
ui.Column(c).Grow(1).Justify(ui.Center).Gap(u).Children(func() {
ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).TextColor(k.Danger).Children(func() {
ui.Icon(c, icons.Must("circle-alert")).Size(16, 16)
ui.Text(c, dataError(c, opts.Error)).Role(ui.RoleStatus).FontSize(FontSize(c, theme.CaptionSize+1))
})
ui.Row(c).Children(func() { r.Retry = agentToggle(c, msg(c, "list.retry"), "refresh-cw", false) })
})
return
}
ui.Text(c, value).Font(theme.SerifFont).FontSize(FontSize(c, theme.H2Size)).FontWeight(600).FontFeatures("tnum").SingleLine()
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
if opts.HasChange {
statChange(c, opts.Change, opts.LowerIsBetter)
}
ui.Box(c).Grow(1)
if len(opts.Trend) > 0 {
Sparkline(c, opts.Trend, SparklineOptions{Frame: ChartFrame{Label: opts.Label}, Width: 88})
}
})
})
if r.Retry {
slog.Debug("mujica: stat_card retry", "label", opts.Label)
}
return r
}
// statChange shows a signed percentage with an arrow, toned good or bad.
func statChange(c *ui.Context, change float64, lowerIsBetter bool) {
k := Tokens(c)
icon, tone, sign := "minus", k.TextMuted, ""
switch {
case change > 0:
icon, tone, sign = "arrow-up", k.Success, "+"
case change < 0:
icon, tone = "arrow-down", k.Danger
}
if lowerIsBetter && change != 0 {
if tone == k.Success {
tone = k.Danger
} else {
tone = k.Success
}
}
text := fmt.Sprintf("%s%.1f%%", sign, change*100)
ui.Row(c).Gap(Density(c).Unit()).AlignItems(ui.Center).Label(fmt.Sprintf(msg(c, "stat_card.change"), text)).Children(func() {
ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(tone)
ui.Text(c, text).TextColor(tone).FontSize(FontSize(c, theme.CaptionSize+1)).FontFeatures("tnum")
})
}
+73
View File
@@ -0,0 +1,73 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestStatCardValueChangeTrend(t *testing.T) {
opts := StatCardOptions{Label: "Requests", Change: 0.124, HasChange: true, Trend: []float64{3, 5, 4, 8, 9}}
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
StatCard(c, "12,480", opts)
}, 400, 200)
for _, s := range []string{"Requests", "12,480", "+12.4%"} {
if !tt.HasText(s) {
t.Fatalf("missing %q in %q", s, tt.Texts())
}
}
opts.Change = -0.05
tt.Frame()
if !tt.HasText("-5.0%") {
t.Fatalf("fall: %q", tt.Texts())
}
opts.HasChange = false
tt.Frame()
if tt.HasText("-5.0%") {
t.Fatal("change shown without HasChange")
}
}
func TestStatCardLoadingErrorSameSize(t *testing.T) {
status, retries := DataReady, 0
var h []float32
tt := ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).AlignItems(ui.Start).Children(func() {
r := StatCard(c, "42 ms", StatCardOptions{Label: "Latency", Status: status, Error: "timeout"})
h = append(h, r.Element.Bounds().H)
if r.Retry {
retries++
}
})
}, 400, 300)
tt.Frame()
ready := h[len(h)-1]
status = DataLoading
tt.Frame()
tt.Frame()
if !tt.HasText("Loading") || h[len(h)-1] != ready {
t.Fatalf("loading: %q height %v vs %v", tt.Texts(), h[len(h)-1], ready)
}
status = DataFailed
tt.Frame()
tt.Frame()
if !tt.HasText("timeout") || h[len(h)-1] != ready {
t.Fatalf("failed: %q height %v vs %v", tt.Texts(), h[len(h)-1], ready)
}
_ = tt.Click("Retry")
if retries != 1 {
t.Fatalf("retries %d", retries)
}
}
func TestStatCardSnapshot(t *testing.T) {
snapshot(t, "stat_card", 640, 150, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(12).Children(func() {
StatCard(c, "12,480", StatCardOptions{Label: "Requests", Change: 0.124, HasChange: true, Trend: []float64{3, 5, 4, 8, 6, 9}})
StatCard(c, "182 ms", StatCardOptions{Label: "p95 latency", Change: 0.08, HasChange: true, LowerIsBetter: true, Trend: []float64{120, 140, 130, 182}})
StatCard(c, "", StatCardOptions{Label: "Errors", Status: DataFailed, Error: "Source unreachable"})
})
})
}
+76
View File
@@ -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
}
+82
View File
@@ -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{}) })
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

+178
View File
@@ -0,0 +1,178 @@
package mujica
import (
"cmp"
"fmt"
"log/slog"
"slices"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// TraceSpan is one timed operation of a request; Parent is "" for the root.
type TraceSpan struct {
ID, Parent string
Name string
Service string
// Start is the offset from the trace start.
Start, Duration time.Duration
Error bool
}
// TraceWaterfallOptions configure a TraceWaterfall.
type TraceWaterfallOptions struct {
// Height of the span list; zero means 320.
Height float32
}
// traceRow is a span in tree order with its depth.
type traceRow struct {
span int
depth int
}
type traceState struct {
key string
rows []traceRow
total time.Duration
selected string
services map[string]int // palette slot of each service
}
// TraceWaterfall lays spans out by parent and time; choosing a span shows its detail.
func TraceWaterfall(c *ui.Context, spans []TraceSpan, opts TraceWaterfallOptions) ChoiceResult {
if opts.Height < 0 {
panic("mujica: TraceWaterfall Height is negative")
}
h := opts.Height
if h == 0 {
h = 320
}
k := Tokens(c)
u := Density(c).Unit()
var r ChoiceResult
r.Element = codePanel(c).Label(msg(c, "trace_waterfall.label"))
st := ui.Local(r.Element, "mujica.trace", func() traceState { return traceState{} })
traceLayout(st, spans)
r.Element.Children(func() {
if len(st.rows) == 0 {
codeCaption(c, msg(c, "trace_waterfall.empty")).Padding(u * 4)
return
}
codeHeader(c, func() {
codeCaption(c, fmt.Sprintf(msg(c, "trace_waterfall.summary"), len(spans), agentDuration(st.total))).Grow(1)
})
agentRows(c, len(st.rows), func(i int) {
row := st.rows[i]
s := spans[row.span]
if traceSpanRow(c, s, row.depth, st.total, st.services[s.Service], s.ID == st.selected) {
st.selected, r.changed = s.ID, true
slog.Debug("mujica: trace_waterfall select", "span", s.ID)
}
}).Height(h).FocusGroup(ui.Vertical)
for _, s := range spans {
if s.ID == st.selected {
ui.Column(c).Shrink(0).Gap(u).Padding(u*2, u*3).BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border).
Role(ui.RoleStatus).Children(func() {
ui.Text(c, s.Name).FontWeight(600)
codeCaption(c, fmt.Sprintf(msg(c, "trace_waterfall.detail"), s.Service, agentDuration(s.Start), agentDuration(s.Duration)))
})
}
}
})
return r
}
// traceLayout orders spans depth-first by start, only when the spans change.
func traceLayout(st *traceState, spans []TraceSpan) {
key := fmt.Sprintf("%d", len(spans))
if len(spans) > 0 {
key += fmt.Sprintf("%p", &spans[0])
}
if key == st.key {
return
}
st.key, st.rows, st.total, st.services = key, st.rows[:0], 0, map[string]int{}
index := make(map[string]int, len(spans))
kids := map[string][]int{}
for i, s := range spans {
if s.ID == "" || s.Name == "" || s.Start < 0 || s.Duration < 0 {
panic(fmt.Sprintf("mujica: TraceWaterfall span %d needs an ID, a Name and non-negative times", i))
}
if _, dup := index[s.ID]; dup {
panic("mujica: TraceWaterfall span IDs must be unique: " + s.ID)
}
index[s.ID] = i
if _, ok := st.services[s.Service]; !ok {
st.services[s.Service] = len(st.services)
}
st.total = max(st.total, s.Start+s.Duration)
}
for i, s := range spans {
if _, ok := index[s.Parent]; s.Parent != "" && !ok {
panic("mujica: TraceWaterfall span " + s.ID + " has an unknown Parent")
}
kids[s.Parent] = append(kids[s.Parent], i)
}
var walk func(parent string, depth int)
walk = func(parent string, depth int) {
list := kids[parent]
slices.SortStableFunc(list, func(a, b int) int { return cmp.Compare(spans[a].Start, spans[b].Start) })
for _, i := range list {
st.rows = append(st.rows, traceRow{i, depth})
walk(spans[i].ID, depth+1)
}
}
walk("", 0)
if len(st.rows) != len(spans) {
panic("mujica: TraceWaterfall spans form a cycle")
}
}
// traceSpanRow shows a span's name indented by depth beside its bar; it reports a choice.
func traceSpanRow(c *ui.Context, s TraceSpan, depth int, total time.Duration, slot int, selected bool) bool {
k := Tokens(c)
d := Density(c)
u := d.Unit()
row := ui.ButtonBase(c).Key(s.ID).Justify(ui.Start).Gap(u*2).MinHeight(d.ControlHeight()-4).Padding(0, u*2).
Label(fmt.Sprintf("%s, %s, %s", s.Name, s.Service, agentDuration(s.Duration)))
internal.Ring(row, k.Focus, theme.ControlRadius)
if selected {
row.Background(k.Selection)
} else if row.Hovered() {
row.Background(k.SurfaceHover)
}
pal := internal.Palette(k)
col := pal[slot%len(pal)]
if s.Error {
col = k.Danger
}
row.Children(func() {
ui.Row(c).Width(220).Shrink(0).Gap(u).Padding(0, 0, 0, float32(depth)*u*4).AlignItems(ui.Center).Children(func() {
if s.Error {
ui.Icon(c, icons.Must("circle-x")).Size(12, 12).TextColor(k.Danger).Label(msg(c, "trace_waterfall.error"))
}
ui.Text(c, s.Name).FontSize(FontSize(c, theme.CaptionSize+1)).SingleLine().Shrink(1)
})
ui.Box(c).Grow(1).Height(14).Draw(func(p *ui.Painter, r ui.Rect) {
span := max(float64(total), 1)
x0 := r.X + r.W*float32(float64(s.Start)/span)
w := max(r.W*float32(float64(s.Duration)/span), 2)
bar := ui.Rect{X: internal.Snap(p, x0), Y: r.Y, W: w, H: r.H}
if s.Error { // hollow, so errors differ by shape too
p.Fill(bar, col.Alpha(0.25), 2)
p.Stroke(bar, col, 2, 1.5)
return
}
p.Fill(bar, col, 2)
})
ui.Text(c, agentDuration(s.Duration)).Width(64).TextAlign(ui.End).FontSize(FontSize(c, theme.CaptionSize)).
TextColor(k.TextMuted).FontFeatures("tnum").Shrink(0)
})
return row.Clicked()
}
+78
View File
@@ -0,0 +1,78 @@
package mujica
import (
"fmt"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
var traceSample = []TraceSpan{
{ID: "db", Parent: "api", Name: "SELECT guests", Service: "postgres", Start: 40 * time.Millisecond, Duration: 90 * time.Millisecond},
{ID: "root", Name: "GET /ball", Service: "gateway", Duration: 320 * time.Millisecond},
{ID: "api", Parent: "root", Name: "ListGuests", Service: "api", Start: 20 * time.Millisecond, Duration: 200 * time.Millisecond},
{ID: "pay", Parent: "root", Name: "Charge", Service: "payments", Start: 230 * time.Millisecond, Duration: 80 * time.Millisecond, Error: true},
}
func traceTester(spans []TraceSpan, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if TraceWaterfall(c, spans, TraceWaterfallOptions{}).Changed() {
*changes++
}
}, 700, 460)
}
func TestTraceWaterfallOrderAndDetail(t *testing.T) {
n := 0
tt := traceTester(traceSample, &n)
if !tt.HasText("4 spans · 320 ms") {
t.Fatalf("summary: %q", tt.Texts())
}
root, _ := tt.Find("GET /ball")
api, _ := tt.Find("ListGuests")
db, _ := tt.Find("SELECT guests")
pay, _ := tt.Find("Charge")
if !(root.Y < api.Y && api.Y < db.Y && db.Y < pay.Y && db.X > api.X && api.X > root.X) {
t.Fatalf("tree order: root %v api %v db %v pay %v", root, api, db, pay)
}
_ = tt.Click("Charge")
if n != 1 || !tt.HasText("payments · starts at 230 ms · takes 80 ms") {
t.Fatalf("detail: %q", tt.Texts())
}
tt.Key(0, ui.KeyUp)
tt.Key(0, ui.KeyEnter)
if !tt.HasText("postgres · starts at 40 ms · takes 90 ms") {
t.Fatalf("keyboard detail: %q", tt.Texts())
}
}
func TestTraceWaterfallRulesAndMany(t *testing.T) {
n := 0
tt := traceTester(nil, &n)
if !tt.HasText("No spans") {
t.Fatal("empty")
}
many := make([]TraceSpan, 100000)
many[0] = TraceSpan{ID: "0", Name: "root", Duration: time.Second}
for i := 1; i < len(many); i++ {
many[i] = TraceSpan{ID: fmt.Sprint(i), Parent: "0", Name: fmt.Sprint("span ", i), Start: time.Duration(i) * time.Microsecond, Duration: time.Microsecond}
}
tt = traceTester(many, &n)
if got := len(tt.Texts()); got > 400 {
t.Fatalf("%d texts for 100000 spans", got)
}
defer func() {
if recover() == nil {
t.Fatal("cycle did not panic")
}
}()
traceTester([]TraceSpan{{ID: "a", Parent: "b", Name: "a"}, {ID: "b", Parent: "a", Name: "b"}}, &n)
}
func TestTraceWaterfallSnapshot(t *testing.T) {
snapshot(t, "trace_waterfall", 680, 280, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() { TraceWaterfall(c, traceSample, TraceWaterfallOptions{Height: 180}) })
})
}
+96
View File
@@ -0,0 +1,96 @@
package mujica
import (
"fmt"
"math"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
)
// UptimeDay is one day's availability, 0 to 1; NaN means no data.
type UptimeDay struct {
Date Date
Uptime float64
// Note says what happened, as "12 min outage".
Note string
}
// UptimeBarOptions configure an UptimeBar; Frame.Height 0 means 36.
type UptimeBarOptions struct {
Frame ChartFrame
// Degraded and Down are the uptime limits below which a day is marked; zero means 0.999 and 0.95.
Degraded, Down float64
}
// UptimeBar draws one bar per day by availability; hover or arrows read a day's detail.
func UptimeBar(c *ui.Context, days []UptimeDay, opts UptimeBarOptions) ChartResult {
deg, down := opts.Degraded, opts.Down
if deg == 0 {
deg = 0.999
}
if down == 0 {
down = 0.95
}
if !(down < deg && deg <= 1 && down > 0) {
panic("mujica: UptimeBar needs 0 < Down < Degraded <= 1")
}
for i, d := range days {
if !math.IsNaN(d.Uptime) && (d.Uptime < 0 || d.Uptime > 1) {
panic(fmt.Sprintf("mujica: UptimeBar day %d uptime %v outside 0..1", i, d.Uptime))
}
}
if opts.Frame.Height == 0 {
opts.Frame.Height = 36
}
k := Tokens(c)
ch := newChart(c, opts.Frame)
n := len(days)
band := func(r ui.Rect) internal.Band { return internal.Band{N: n, R0: r.X, R1: r.X + r.W, Pad: 0.25} }
level := func(v float64) int { // 0 ok, 1 degraded, 2 down, 3 no data
switch {
case math.IsNaN(v):
return 3
case v < down:
return 2
case v < deg:
return 1
}
return 0
}
words := [...]string{msg(c, "uptime_bar.ok"), msg(c, "uptime_bar.degraded"), msg(c, "uptime_bar.down"), msg(c, "uptime_bar.nodata")}
res, _ := ch.build(chartSpec{
n: n,
draw: func(p *ui.Painter, r ui.Rect, active int) {
b := band(r)
for i, d := range days {
x, w := internal.Snap(p, b.Start(i)), max(b.Width(), 1)
lv := level(d.Uptime)
col := [...]ui.Color{k.Success, k.Warning, k.Danger, k.Border}[lv]
// Shorter bars mark bad days, so color is not the only cue.
h := r.H * [...]float32{1, 0.75, 0.5, 1}[lv]
p.Fill(ui.Rect{X: x, Y: r.Y + r.H - h, W: w, H: h}, col, 1)
if i == active {
p.Stroke(ui.Rect{X: x - 2, Y: r.Y - 2, W: w + 4, H: r.H + 4}, k.Focus, 2, 1.5)
}
}
},
pick: func(r ui.Rect, x, _ float32) int {
b := band(r)
return max(0, min(n-1, int((x-r.X)/b.Step())))
},
readout: func(i int) string {
d := days[i]
s := fmt.Sprintf("%s · %s", d.Date.String(), words[level(d.Uptime)])
if !math.IsNaN(d.Uptime) {
s += fmt.Sprintf(" · %.2f%%", d.Uptime*100)
}
if d.Note != "" {
s += " · " + d.Note
}
return s
},
})
return res
}
+87
View File
@@ -0,0 +1,87 @@
package mujica
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
)
func uptimeSample(n int) []UptimeDay {
days := make([]UptimeDay, n)
start := Date{Year: 2026, Month: 9, Day: 1}
for i := range days {
days[i] = UptimeDay{Date: start.AddDays(i), Uptime: 1}
}
days[3] = UptimeDay{Date: start.AddDays(3), Uptime: 0.9975, Note: "slow search"}
days[7] = UptimeDay{Date: start.AddDays(7), Uptime: 0.91, Note: "2 h outage"}
days[9].Uptime = math.NaN()
return days
}
func uptimeTester(days []UptimeDay, frame ChartFrame, points *[]int) *ui.Tester {
if frame.Label == "" {
frame.Label = "API uptime"
}
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ui.Column(c).Padding(10).Children(func() {
r := UptimeBar(c, days, UptimeBarOptions{Frame: frame})
*points = append(*points, r.Point)
})
}, 520, 140)
}
func TestUptimeBarKeyboardReadout(t *testing.T) {
var pts []int
tt := uptimeTester(uptimeSample(30), ChartFrame{}, &pts)
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
for range 7 {
tt.Key(0, ui.KeyRight)
}
if !tt.HasText("2026-09-08 · Outage · 91.00% · 2 h outage") {
t.Fatalf("readout: %q", tt.Texts())
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
if !tt.HasText("2026-09-10 · No data") {
t.Fatalf("no data readout: %q", tt.Texts())
}
}
func TestUptimeBarHover(t *testing.T) {
var pts []int
tt := uptimeTester(uptimeSample(30), ChartFrame{}, &pts)
// The 4th of 30 bars across 500 DIPs starting at x = 10.
tt.Move(10+500*3.5/30, 30)
if pts[len(pts)-1] != 3 || !tt.HasText("2026-09-04 · Degraded · 99.75% · slow search") {
t.Fatalf("hover point %d: %q", pts[len(pts)-1], tt.Texts())
}
}
func TestUptimeBarStatesAndRules(t *testing.T) {
var pts []int
tt := uptimeTester(nil, ChartFrame{}, &pts)
if !tt.HasText("No data") {
t.Fatalf("empty: %q", tt.Texts())
}
tt = uptimeTester(uptimeSample(12), ChartFrame{Status: DataLoading}, &pts)
if !tt.HasText("Loading") {
t.Fatalf("loading: %q", tt.Texts())
}
defer func() {
if recover() == nil {
t.Fatal("uptime above 1 did not panic")
}
}()
uptimeTester([]UptimeDay{{Uptime: 1.2}}, ChartFrame{}, &pts)
}
func TestUptimeBarSnapshot(t *testing.T) {
snapshot(t, "uptime_bar", 520, 110, func(c *ui.Context) {
ui.Column(c).Padding(10).Children(func() {
UptimeBar(c, uptimeSample(45), UptimeBarOptions{Frame: ChartFrame{Label: "API uptime"}})
})
})
}