diff --git a/alert_list.go b/alert_list.go new file mode 100644 index 0000000..97c92d6 --- /dev/null +++ b/alert_list.go @@ -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 +} diff --git a/alert_list_test.go b/alert_list_test.go new file mode 100644 index 0000000..156f7e0 --- /dev/null +++ b/alert_list_test.go @@ -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{}) }) + }) +} diff --git a/api_request_builder.go b/api_request_builder.go new file mode 100644 index 0000000..8abceab --- /dev/null +++ b/api_request_builder.go @@ -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 +} diff --git a/api_request_builder_test.go b/api_request_builder_test.go new file mode 100644 index 0000000..151a7c5 --- /dev/null +++ b/api_request_builder_test.go @@ -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{}) }) + }) +} diff --git a/dashboard_filter_bar.go b/dashboard_filter_bar.go new file mode 100644 index 0000000..efd0686 --- /dev/null +++ b/dashboard_filter_bar.go @@ -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") +} diff --git a/dashboard_filter_bar_test.go b/dashboard_filter_bar_test.go new file mode 100644 index 0000000..db17674 --- /dev/null +++ b/dashboard_filter_bar_test.go @@ -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) }) + }) +} diff --git a/devtools_parts.go b/devtools_parts.go new file mode 100644 index 0000000..845590d --- /dev/null +++ b/devtools_parts.go @@ -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) +} diff --git a/incident_card.go b/incident_card.go new file mode 100644 index 0000000..25b6281 --- /dev/null +++ b/incident_card.go @@ -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]) +} diff --git a/incident_card_test.go b/incident_card_test.go new file mode 100644 index 0000000..98fa5f1 --- /dev/null +++ b/incident_card_test.go @@ -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()) }) + }) +} diff --git a/json_editor.go b/json_editor.go new file mode 100644 index 0000000..eb66d78 --- /dev/null +++ b/json_editor.go @@ -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) + }) +} diff --git a/json_editor_test.go b/json_editor_test.go new file mode 100644 index 0000000..ebed49e --- /dev/null +++ b/json_editor_test.go @@ -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}) }) + }) +} diff --git a/json_tree.go b/json_tree.go new file mode 100644 index 0000000..fd570f5 --- /dev/null +++ b/json_tree.go @@ -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 +} diff --git a/json_viewer.go b/json_viewer.go new file mode 100644 index 0000000..0a847c6 --- /dev/null +++ b/json_viewer.go @@ -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) +} diff --git a/json_viewer_test.go b/json_viewer_test.go new file mode 100644 index 0000000..8ba2c81 --- /dev/null +++ b/json_viewer_test.go @@ -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}) + }) + }) +} diff --git a/key_value_input.go b/key_value_input.go new file mode 100644 index 0000000..7a0beca --- /dev/null +++ b/key_value_input.go @@ -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 +} diff --git a/key_value_input_test.go b/key_value_input_test.go new file mode 100644 index 0000000..c100bb8 --- /dev/null +++ b/key_value_input_test.go @@ -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{}) }) + }) +} diff --git a/log_stream.go b/log_stream.go new file mode 100644 index 0000000..0e188a5 --- /dev/null +++ b/log_stream.go @@ -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) + }) +} diff --git a/log_stream_test.go b/log_stream_test.go new file mode 100644 index 0000000..782c686 --- /dev/null +++ b/log_stream_test.go @@ -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}) }) + }) +} diff --git a/messages_devtools.go b/messages_devtools.go new file mode 100644 index 0000000..1b8a9e1 --- /dev/null +++ b/messages_devtools.go @@ -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", "没有告警"}, + }) +} diff --git a/refresh_interval_selector.go b/refresh_interval_selector.go new file mode 100644 index 0000000..5839c59 --- /dev/null +++ b/refresh_interval_selector.go @@ -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 +} diff --git a/refresh_interval_selector_test.go b/refresh_interval_selector_test.go new file mode 100644 index 0000000..c456eb6 --- /dev/null +++ b/refresh_interval_selector_test.go @@ -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{}) + }) + }) +} diff --git a/resource_gauge.go b/resource_gauge.go new file mode 100644 index 0000000..14a6f54 --- /dev/null +++ b/resource_gauge.go @@ -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 +} diff --git a/resource_gauge_test.go b/resource_gauge_test.go new file mode 100644 index 0000000..bc03728 --- /dev/null +++ b/resource_gauge_test.go @@ -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{}) }) + }) +} diff --git a/schema_tree.go b/schema_tree.go new file mode 100644 index 0000000..0c8fed3 --- /dev/null +++ b/schema_tree.go @@ -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]) +} diff --git a/schema_tree_test.go b/schema_tree_test.go new file mode 100644 index 0000000..2a487b1 --- /dev/null +++ b/schema_tree_test.go @@ -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) + } + }) +} diff --git a/service_status.go b/service_status.go new file mode 100644 index 0000000..e2a9c91 --- /dev/null +++ b/service_status.go @@ -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]) +} diff --git a/service_status_test.go b/service_status_test.go new file mode 100644 index 0000000..6d9a07b --- /dev/null +++ b/service_status_test.go @@ -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{}) }) + }) +} diff --git a/stat_card.go b/stat_card.go new file mode 100644 index 0000000..1a5bc7c --- /dev/null +++ b/stat_card.go @@ -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") + }) +} diff --git a/stat_card_test.go b/stat_card_test.go new file mode 100644 index 0000000..d843db7 --- /dev/null +++ b/stat_card_test.go @@ -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"}) + }) + }) +} diff --git a/system_monitor.go b/system_monitor.go new file mode 100644 index 0000000..52f2ff2 --- /dev/null +++ b/system_monitor.go @@ -0,0 +1,76 @@ +package mujica + +import ( + "fmt" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// MonitorMetric is one sampled resource; Samples line up with the shared times. +type MonitorMetric struct { + Name string + Unit string + Samples []float64 +} + +// SystemMonitorOptions configure a SystemMonitor. +type SystemMonitorOptions struct { + // Height of each chart; zero means 110. + Height float32 + Status DataStatus + Error string +} + +// SystemMonitorResult reports this frame's events. +type SystemMonitorResult struct { + Element *ui.Element + Retry bool +} + +// SystemMonitor stacks a live line chart per metric over one time axis, sampled by the caller. +func SystemMonitor(c *ui.Context, times []time.Time, metrics []MonitorMetric, opts SystemMonitorOptions) SystemMonitorResult { + if opts.Height < 0 { + panic("mujica: SystemMonitor Height is negative") + } + for i, m := range metrics { + if m.Name == "" || len(m.Samples) != len(times) { + panic(fmt.Sprintf("mujica: SystemMonitor metric %d needs a Name and one sample per time", i)) + } + } + h := opts.Height + if h == 0 { + h = 110 + } + k := Tokens(c) + u := Density(c).Unit() + var r SystemMonitorResult + r.Element = ui.Column(c).Shrink(0).Gap(u * 3) + r.Element.Children(func() { + for _, m := range metrics { + ui.Column(c).Key(m.Name).Gap(u).Children(func() { + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() { + ui.Text(c, m.Name).FontWeight(600).Grow(1) + last := math.NaN() + if n := len(m.Samples); n > 0 { + last = m.Samples[n-1] + } + if !math.IsNaN(last) && opts.Status == DataReady { + ui.Text(c, fmt.Sprintf("%s %s", sandboxNum(last), m.Unit)).FontFeatures("tnum"). + FontSize(FontSize(c, theme.CaptionSize+1)).TextColor(k.Text).Role(ui.RoleStatus) + } + }) + unit := m.Unit + res := LineChart(c, []ChartSeries{{Name: m.Name, Values: m.Samples}}, LineChartOptions{ + Frame: ChartFrame{Label: m.Name, Height: h, Status: opts.Status, Error: opts.Error}, + Times: times, Format: func(v float64) string { return sandboxNum(v) + " " + unit }, + }) + r.Retry = r.Retry || res.Retried() + }) + } + }) + return r +} diff --git a/system_monitor_test.go b/system_monitor_test.go new file mode 100644 index 0000000..cbc7edf --- /dev/null +++ b/system_monitor_test.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "math" + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func monitorSample(n int) ([]time.Time, []MonitorMetric) { + t0 := time.Date(2026, 10, 5, 21, 0, 0, 0, time.UTC) + times := make([]time.Time, n) + cpu, mem := make([]float64, n), make([]float64, n) + for i := range n { + times[i] = t0.Add(time.Duration(i) * 5 * time.Second) + cpu[i] = 40 + 20*math.Sin(float64(i)/4) + mem[i] = 3.2 + float64(i)*0.02 + } + return times, []MonitorMetric{{Name: "CPU", Unit: "%", Samples: cpu}, {Name: "Memory", Unit: "GB", Samples: mem}} +} + +func TestSystemMonitorLiveAndKeyboard(t *testing.T) { + times, metrics := monitorSample(20) + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SystemMonitor(c, times, metrics, SystemMonitorOptions{}) + }, 600, 420) + if !tt.HasText("3.6 GB") || !tt.HasText("20.0 %") { + t.Fatalf("latest samples missing: %q", tt.Texts()) + } + times, metrics = monitorSample(21) + tt.Frame() + if !tt.HasText("20.8 %") || tt.HasText("20.0 %") { + t.Fatalf("new sample: %q", tt.Texts()) + } + tt.Key(0, ui.KeyTab) // the legend key + tt.Key(0, ui.KeyTab) // the plot + tt.Key(0, ui.KeyEnd) + found := false + for _, s := range tt.Texts() { + found = found || strings.Contains(s, "21:01:40") + } + if !found { + t.Fatalf("keyboard readout: %q", tt.Texts()) + } +} + +func TestSystemMonitorStatesAndRules(t *testing.T) { + times, metrics := monitorSample(5) + retries := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if SystemMonitor(c, times, metrics[:1], SystemMonitorOptions{Status: DataFailed, Error: "agent offline"}).Retry { + retries++ + } + }, 600, 300) + if !tt.HasText("agent offline") { + t.Fatalf("error: %q", tt.Texts()) + } + _ = tt.Click("Retry") + if retries != 1 { + t.Fatal("retry not reported") + } + defer func() { + if recover() == nil { + t.Fatal("mismatched samples did not panic") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SystemMonitor(c, times, []MonitorMetric{{Name: "x", Samples: []float64{1}}}, SystemMonitorOptions{}) + }, 200, 200) +} + +func TestSystemMonitorSnapshot(t *testing.T) { + times, metrics := monitorSample(40) + snapshot(t, "system_monitor", 560, 380, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { SystemMonitor(c, times, metrics, SystemMonitorOptions{}) }) + }) +} diff --git a/testdata/snapshots/darwin/alert_list-dark.png b/testdata/snapshots/darwin/alert_list-dark.png new file mode 100644 index 0000000..ee48696 Binary files /dev/null and b/testdata/snapshots/darwin/alert_list-dark.png differ diff --git a/testdata/snapshots/darwin/alert_list-light.png b/testdata/snapshots/darwin/alert_list-light.png new file mode 100644 index 0000000..ab19dc5 Binary files /dev/null and b/testdata/snapshots/darwin/alert_list-light.png differ diff --git a/testdata/snapshots/darwin/api_request_builder-dark.png b/testdata/snapshots/darwin/api_request_builder-dark.png new file mode 100644 index 0000000..9639561 Binary files /dev/null and b/testdata/snapshots/darwin/api_request_builder-dark.png differ diff --git a/testdata/snapshots/darwin/api_request_builder-light.png b/testdata/snapshots/darwin/api_request_builder-light.png new file mode 100644 index 0000000..89bc6c3 Binary files /dev/null and b/testdata/snapshots/darwin/api_request_builder-light.png differ diff --git a/testdata/snapshots/darwin/dashboard_filter_bar-dark.png b/testdata/snapshots/darwin/dashboard_filter_bar-dark.png new file mode 100644 index 0000000..8e96c88 Binary files /dev/null and b/testdata/snapshots/darwin/dashboard_filter_bar-dark.png differ diff --git a/testdata/snapshots/darwin/dashboard_filter_bar-light.png b/testdata/snapshots/darwin/dashboard_filter_bar-light.png new file mode 100644 index 0000000..be55904 Binary files /dev/null and b/testdata/snapshots/darwin/dashboard_filter_bar-light.png differ diff --git a/testdata/snapshots/darwin/incident_card-dark.png b/testdata/snapshots/darwin/incident_card-dark.png new file mode 100644 index 0000000..464c306 Binary files /dev/null and b/testdata/snapshots/darwin/incident_card-dark.png differ diff --git a/testdata/snapshots/darwin/incident_card-light.png b/testdata/snapshots/darwin/incident_card-light.png new file mode 100644 index 0000000..0a9a822 Binary files /dev/null and b/testdata/snapshots/darwin/incident_card-light.png differ diff --git a/testdata/snapshots/darwin/json_editor-dark.png b/testdata/snapshots/darwin/json_editor-dark.png new file mode 100644 index 0000000..8c843a6 Binary files /dev/null and b/testdata/snapshots/darwin/json_editor-dark.png differ diff --git a/testdata/snapshots/darwin/json_editor-light.png b/testdata/snapshots/darwin/json_editor-light.png new file mode 100644 index 0000000..59b92e9 Binary files /dev/null and b/testdata/snapshots/darwin/json_editor-light.png differ diff --git a/testdata/snapshots/darwin/json_viewer-dark.png b/testdata/snapshots/darwin/json_viewer-dark.png new file mode 100644 index 0000000..2993757 Binary files /dev/null and b/testdata/snapshots/darwin/json_viewer-dark.png differ diff --git a/testdata/snapshots/darwin/json_viewer-light.png b/testdata/snapshots/darwin/json_viewer-light.png new file mode 100644 index 0000000..3345091 Binary files /dev/null and b/testdata/snapshots/darwin/json_viewer-light.png differ diff --git a/testdata/snapshots/darwin/key_value_input-dark.png b/testdata/snapshots/darwin/key_value_input-dark.png new file mode 100644 index 0000000..9a3c8a3 Binary files /dev/null and b/testdata/snapshots/darwin/key_value_input-dark.png differ diff --git a/testdata/snapshots/darwin/key_value_input-light.png b/testdata/snapshots/darwin/key_value_input-light.png new file mode 100644 index 0000000..e89ea5c Binary files /dev/null and b/testdata/snapshots/darwin/key_value_input-light.png differ diff --git a/testdata/snapshots/darwin/log_stream-dark.png b/testdata/snapshots/darwin/log_stream-dark.png new file mode 100644 index 0000000..64003df Binary files /dev/null and b/testdata/snapshots/darwin/log_stream-dark.png differ diff --git a/testdata/snapshots/darwin/log_stream-light.png b/testdata/snapshots/darwin/log_stream-light.png new file mode 100644 index 0000000..68839db Binary files /dev/null and b/testdata/snapshots/darwin/log_stream-light.png differ diff --git a/testdata/snapshots/darwin/refresh_interval_selector-dark.png b/testdata/snapshots/darwin/refresh_interval_selector-dark.png new file mode 100644 index 0000000..8d76da9 Binary files /dev/null and b/testdata/snapshots/darwin/refresh_interval_selector-dark.png differ diff --git a/testdata/snapshots/darwin/refresh_interval_selector-light.png b/testdata/snapshots/darwin/refresh_interval_selector-light.png new file mode 100644 index 0000000..586fba2 Binary files /dev/null and b/testdata/snapshots/darwin/refresh_interval_selector-light.png differ diff --git a/testdata/snapshots/darwin/resource_gauge-dark.png b/testdata/snapshots/darwin/resource_gauge-dark.png new file mode 100644 index 0000000..1996758 Binary files /dev/null and b/testdata/snapshots/darwin/resource_gauge-dark.png differ diff --git a/testdata/snapshots/darwin/resource_gauge-light.png b/testdata/snapshots/darwin/resource_gauge-light.png new file mode 100644 index 0000000..0bad7e2 Binary files /dev/null and b/testdata/snapshots/darwin/resource_gauge-light.png differ diff --git a/testdata/snapshots/darwin/schema_tree-dark.png b/testdata/snapshots/darwin/schema_tree-dark.png new file mode 100644 index 0000000..b11c6b2 Binary files /dev/null and b/testdata/snapshots/darwin/schema_tree-dark.png differ diff --git a/testdata/snapshots/darwin/schema_tree-light.png b/testdata/snapshots/darwin/schema_tree-light.png new file mode 100644 index 0000000..f1e1389 Binary files /dev/null and b/testdata/snapshots/darwin/schema_tree-light.png differ diff --git a/testdata/snapshots/darwin/service_status-dark.png b/testdata/snapshots/darwin/service_status-dark.png new file mode 100644 index 0000000..20be965 Binary files /dev/null and b/testdata/snapshots/darwin/service_status-dark.png differ diff --git a/testdata/snapshots/darwin/service_status-light.png b/testdata/snapshots/darwin/service_status-light.png new file mode 100644 index 0000000..0f232c8 Binary files /dev/null and b/testdata/snapshots/darwin/service_status-light.png differ diff --git a/testdata/snapshots/darwin/stat_card-dark.png b/testdata/snapshots/darwin/stat_card-dark.png new file mode 100644 index 0000000..197be7a Binary files /dev/null and b/testdata/snapshots/darwin/stat_card-dark.png differ diff --git a/testdata/snapshots/darwin/stat_card-light.png b/testdata/snapshots/darwin/stat_card-light.png new file mode 100644 index 0000000..3cc3a9c Binary files /dev/null and b/testdata/snapshots/darwin/stat_card-light.png differ diff --git a/testdata/snapshots/darwin/system_monitor-dark.png b/testdata/snapshots/darwin/system_monitor-dark.png new file mode 100644 index 0000000..8123bcf Binary files /dev/null and b/testdata/snapshots/darwin/system_monitor-dark.png differ diff --git a/testdata/snapshots/darwin/system_monitor-light.png b/testdata/snapshots/darwin/system_monitor-light.png new file mode 100644 index 0000000..f8eabe1 Binary files /dev/null and b/testdata/snapshots/darwin/system_monitor-light.png differ diff --git a/testdata/snapshots/darwin/trace_waterfall-dark.png b/testdata/snapshots/darwin/trace_waterfall-dark.png new file mode 100644 index 0000000..637f862 Binary files /dev/null and b/testdata/snapshots/darwin/trace_waterfall-dark.png differ diff --git a/testdata/snapshots/darwin/trace_waterfall-light.png b/testdata/snapshots/darwin/trace_waterfall-light.png new file mode 100644 index 0000000..314480b Binary files /dev/null and b/testdata/snapshots/darwin/trace_waterfall-light.png differ diff --git a/testdata/snapshots/darwin/uptime_bar-dark.png b/testdata/snapshots/darwin/uptime_bar-dark.png new file mode 100644 index 0000000..03b3e10 Binary files /dev/null and b/testdata/snapshots/darwin/uptime_bar-dark.png differ diff --git a/testdata/snapshots/darwin/uptime_bar-light.png b/testdata/snapshots/darwin/uptime_bar-light.png new file mode 100644 index 0000000..d01b930 Binary files /dev/null and b/testdata/snapshots/darwin/uptime_bar-light.png differ diff --git a/trace_waterfall.go b/trace_waterfall.go new file mode 100644 index 0000000..9ac6b6b --- /dev/null +++ b/trace_waterfall.go @@ -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() +} diff --git a/trace_waterfall_test.go b/trace_waterfall_test.go new file mode 100644 index 0000000..a15ad5a --- /dev/null +++ b/trace_waterfall_test.go @@ -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}) }) + }) +} diff --git a/uptime_bar.go b/uptime_bar.go new file mode 100644 index 0000000..9c1fe37 --- /dev/null +++ b/uptime_bar.go @@ -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 +} diff --git a/uptime_bar_test.go b/uptime_bar_test.go new file mode 100644 index 0000000..fdbff9f --- /dev/null +++ b/uptime_bar_test.go @@ -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"}}) + }) + }) +}