diff --git a/alert_list.go b/alert_list.go index d6bb98e..5d97579 100644 --- a/alert_list.go +++ b/alert_list.go @@ -40,11 +40,11 @@ type AlertListOptions struct { // 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 and Silence report requested alert IDs; empty means idle. Ack, Silence string } -// AlertList lists alerts by level, source and time with acknowledge and silence. +// AlertList shows alerts with acknowledge and silence actions. func AlertList(c *ui.Context, alerts []AlertItem, opts AlertListOptions) AlertListResult { if opts.MaxHeight < 0 { panic("mujica: AlertList MaxHeight is negative") @@ -86,7 +86,7 @@ func AlertList(c *ui.Context, alerts []AlertItem, opts AlertListOptions) AlertLi return r } -// alertRow shows one alert and reports its acknowledge and silence presses. +// alertRow shows an alert with acknowledge and silence buttons. func alertRow(c *ui.Context, a AlertItem, locked bool) (ack, silence bool) { k := Tokens(c) d := Density(c) diff --git a/alert_list_test.go b/alert_list_test.go index 156f7e0..c3d136f 100644 --- a/alert_list_test.go +++ b/alert_list_test.go @@ -81,3 +81,13 @@ func TestAlertListSnapshot(t *testing.T) { ui.Column(c).Padding(16).Children(func() { AlertList(c, alerts, AlertListOptions{}) }) }) } + +func TestAlertListRejectsDuplicateIDs(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("duplicate alerts accepted") + } + }() + var results []AlertListResult + alertTester([]AlertItem{{ID: "a", Title: "One"}, {ID: "a", Title: "Two"}}, AlertListOptions{}, &results) +} diff --git a/api_request_builder.go b/api_request_builder.go index 8abceab..47452d8 100644 --- a/api_request_builder.go +++ b/api_request_builder.go @@ -12,7 +12,7 @@ import ( "mujicaui/theme" ) -// ApiRequest is an HTTP request being built; the caller sends it. +// ApiRequest holds request data sent by the caller. type ApiRequest struct { // Method is GET when empty. Method string @@ -41,13 +41,16 @@ 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. +// ApiRequestBuilder edits requests and reports send actions. func ApiRequestBuilder(c *ui.Context, req *ApiRequest, opts ApiRequestBuilderOptions) ApiRequestResult { if req == nil { panic("mujica: ApiRequestBuilder needs a request") } + original := req if req.Method == "" { - req.Method = "GET" + copy := *req + copy.Method = "GET" + req = © } if !slices.Contains(apiMethods, req.Method) { panic("mujica: ApiRequestBuilder method " + req.Method + " is not offered") @@ -57,6 +60,15 @@ func ApiRequestBuilder(c *ui.Context, req *ApiRequest, opts ApiRequestBuilderOpt 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() + if locked { + copy := *req + copy.Params = slices.Clone(req.Params) + copy.Headers = slices.Clone(req.Headers) + req = © + } else { + *original = *req + req = original + } full, urlErr := apiRequestURL(*req) r.Element.Children(func() { ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() { @@ -67,7 +79,7 @@ func ApiRequestBuilder(c *ui.Context, req *ApiRequest, opts ApiRequestBuilderOpt 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() + shownErr = msg(c, "api_request_builder.invalid_url") } in := TextInput(c, &req.URL, TextInputOptions{Placeholder: "https://", Label: msg(c, "api_request_builder.url"), Disabled: locked, Error: shownErr}) in.Element.Grow(1) @@ -98,12 +110,12 @@ func ApiRequestBuilder(c *ui.Context, req *ApiRequest, opts ApiRequestBuilderOpt 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 + slog.Debug("mujica: api_request_builder send", "method", req.Method) // Request URLs may contain secrets. } return r } -// apiRequestURL joins the URL with its enabled query parameters, or says why it is invalid. +// apiRequestURL joins enabled query parameters with a validated URL. func apiRequestURL(req ApiRequest) (string, error) { if req.URL == "" { return "", fmt.Errorf("empty URL") diff --git a/api_request_builder_test.go b/api_request_builder_test.go index 151a7c5..1013035 100644 --- a/api_request_builder_test.go +++ b/api_request_builder_test.go @@ -28,7 +28,7 @@ func TestApiRequestBuilderBuildAndSend(t *testing.T) { _ = 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 { + if !tt.HasText("Enter a valid HTTP or HTTPS URL") || sends != 0 { t.Fatalf("invalid URL: %q", tt.Texts()) } req.URL = "" @@ -91,3 +91,34 @@ func TestApiRequestBuilderSnapshot(t *testing.T) { ui.Column(c).Padding(16).Children(func() { ApiRequestBuilder(c, &req, ApiRequestBuilderOptions{}) }) }) } + +func TestApiRequestBuilderDisabledPreservesDefault(t *testing.T) { + var req ApiRequest + sends := 0 + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(true).Children(func() { ApiRequestBuilder(c, &req, ApiRequestBuilderOptions{}) }) + }, 600, 400) + if req.Method != "" || sends != 0 { + t.Fatal("disabled request changed") + } +} + +func TestApiRequestBuilderAncestorDisabledTabs(t *testing.T) { + disabled := false + request := ApiRequest{URL: "https://court.example", Params: []KeyValue{{Key: "page", Value: "1", Enabled: true}}, Headers: []KeyValue{{Key: "Accept", Value: "application/json", Enabled: true}}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + ApiRequestBuilder(c, &request, ApiRequestBuilderOptions{}) + }) + }, 760, 520) + if err := tt.Click("Params (1)"); err != nil { + t.Fatal(err) + } + disabled = true + tt.Key(0, ui.KeyRight) + if !tt.HasText("page") || tt.HasText("Accept") { + t.Fatal("disabled request switched tabs") + } +} diff --git a/dashboard_filter_bar.go b/dashboard_filter_bar.go index efd0686..b998da5 100644 --- a/dashboard_filter_bar.go +++ b/dashboard_filter_bar.go @@ -11,9 +11,9 @@ import ( "mujicaui/icons" ) -// DashboardFilters is what a dashboard is filtered by; the zero value filters nothing. +// DashboardFilters holds filters; zero uses caller defaults. type DashboardFilters struct { - // Range is how far back to look; zero means the caller's default. + // Range is the look-back; zero uses the caller's default. Range time.Duration Env string Tags []string @@ -21,7 +21,7 @@ type DashboardFilters struct { // 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 defaults to 15m, 1h, 24h and 7d. Ranges []time.Duration // Envs and Tags are the offered environments and tags. Envs, Tags []string @@ -30,7 +30,7 @@ type DashboardFilterBarOptions struct { 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. +// DashboardFilterBar edits time, environment and tags with clear. func DashboardFilterBar(c *ui.Context, f *DashboardFilters, opts DashboardFilterBarOptions) ChoiceResult { if f == nil { panic("mujica: DashboardFilterBar needs filters") @@ -42,6 +42,11 @@ func DashboardFilterBar(c *ui.Context, f *DashboardFilters, opts DashboardFilter if f.Env != "" && !slices.Contains(opts.Envs, f.Env) { panic("mujica: DashboardFilterBar Env is not offered") } + for _, tag := range f.Tags { + if !slices.Contains(opts.Tags, tag) { + panic("mujica: DashboardFilterBar tag 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). diff --git a/dashboard_filter_bar_test.go b/dashboard_filter_bar_test.go index db17674..b87c7cd 100644 --- a/dashboard_filter_bar_test.go +++ b/dashboard_filter_bar_test.go @@ -71,3 +71,37 @@ func TestDashboardFilterBarSnapshot(t *testing.T) { ui.Column(c).Padding(16).Children(func() { DashboardFilterBar(c, &f, filterOpts) }) }) } + +func TestDashboardFilterBarKeyboardAndAncestorDisabled(t *testing.T) { + var f DashboardFilters + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { DashboardFilterBar(c, &f, filterOpts) }) + }, 900, 420) + _ = tt.Click("Environment") + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if f.Env != "production" { + t.Fatal("keyboard environment") + } + _ = tt.Click("Environment") + disabled = true + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + _ = tt.Click("Clear filters") + if f.Env != "production" || tt.HasText("staging") { + t.Fatal("disabled filter or popup changed") + } +} + +func TestDashboardFilterBarRejectsUnknownTags(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("unoffered tag accepted") + } + }() + f := DashboardFilters{Tags: []string{"unknown"}} + n := 0 + filterTester(&f, filterOpts, &n) +} diff --git a/devtools_parts.go b/devtools_parts.go index 845590d..47439c7 100644 --- a/devtools_parts.go +++ b/devtools_parts.go @@ -9,13 +9,13 @@ import ( "mujicaui/theme" ) -// devtoolsAgo formats how long before now t was, as "5 m ago". +// devtoolsAgo formats elapsed time using localized copy. 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. +// devtoolsSeverity shows severity with an icon and word. 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() { diff --git a/docs/components/442-stat-card.md b/docs/components/442-stat-card.md new file mode 100644 index 0000000..481ee55 --- /dev/null +++ b/docs/components/442-stat-card.md @@ -0,0 +1,39 @@ +# 442 StatCard · 统计卡片 + +## 用途 + +展示调用方统计值、变化率和迷你趋势图。 + +## 最小示例 + +```go +value := "12,480" +opts := mujica.StatCardOptions{Label: "Requests", Change: 0.12, HasChange: true, Trend: []float64{3, 5, 4, 8}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.StatCard(c, value, opts).Changed() { + fmt.Println("retry") + } +} +ui.Render(view, 720, 480, 1) +``` + +## 参数 + +Label 必填;Change 是相对变化;HasChange 开启变化说明;LowerIsBetter 反转好坏;Trend 为趋势值;Status、Error 控制加载与错误。 + +## 状态 + +值与变化率、趋势、加载、失败、父级禁用。 + +## 事件 + +Changed() 表示重试。 + +## 键盘操作 + +Tab 聚焦趋势或重试;方向键读取数据;Enter 激活重试。 + +## 限制 + +卡片最小高度 116 DIP,随桌面文本缩放增长;统计值由调用方计算。 diff --git a/docs/components/443-alert-list.md b/docs/components/443-alert-list.md new file mode 100644 index 0000000..d8472d4 --- /dev/null +++ b/docs/components/443-alert-list.md @@ -0,0 +1,42 @@ +# 443 AlertList · 告警列表 + +## 用途 + +按级别、来源与发生时间展示告警;报告确认和静默请求。 + +## 最小示例 + +```go +alerts := []mujica.AlertItem{{ID: "cpu", Title: "CPU usage high", Level: mujica.AlertWarning, Source: "worker", At: time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.AlertList(c, alerts, mujica.AlertListOptions{}) + if r.Ack != "" { + fmt.Println("ack", r.Ack) + } + if r.Silence != "" { + fmt.Println("silence", r.Silence) + } +} +ui.Render(view, 720, 480, 1) +``` + +## 参数 + +MaxHeight 为列表最大高度,默认 360 DIP;Disabled 禁用操作。AlertItem.ID 唯一,Title 必填,Level 为有效枚举;Acked、Silence 展示处理状态。 + +## 状态 + +未处理、已确认、静默中、空列表、禁用。 + +## 事件 + +Ack、Silence 返回告警 ID;调用方更新 Acked 或 Silence。 + +## 键盘操作 + +Tab 聚焦按钮;Enter 或 Space 激活。 + +## 限制 + +静默请求固定为一小时;告警数据由调用方管理。 diff --git a/docs/components/444-incident-card.md b/docs/components/444-incident-card.md new file mode 100644 index 0000000..98f4d80 --- /dev/null +++ b/docs/components/444-incident-card.md @@ -0,0 +1,36 @@ +# 444 IncidentCard · 事件卡片 + +## 用途 + +展示事件标题、状态、影响与按时间排列的更新。 + +## 最小示例 + +```go +incident := mujica.Incident{Title: "API latency", Status: mujica.IncidentMonitoring, Impact: mujica.ImpactMinor, Updates: []mujica.IncidentUpdate{{Status: mujica.IncidentIdentified, Text: "Slow query located"}, {Status: mujica.IncidentMonitoring, Text: "Recovery observed"}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IncidentCard(c, incident) +} +ui.Render(view, 720, 480, 1) +``` + +## 参数 + +Incident.Title 必填;Status、Impact 使用枚举;Started 与 Updates 的时间由调用方提供。 + +## 状态 + +调查中、已定位、观察中、已解决;四级影响。 + +## 事件 + +展示组件,调用方更新 Incident。 + +## 键盘操作 + +展示内容随调用方更新。 + +## 限制 + +时间线按输入顺序展示;调用方排序。 diff --git a/docs/components/445-uptime-bar.md b/docs/components/445-uptime-bar.md new file mode 100644 index 0000000..b52ed3e --- /dev/null +++ b/docs/components/445-uptime-bar.md @@ -0,0 +1,42 @@ +# 445 UptimeBar · 可用性条 + +## 用途 + +按日展示可用性,以条高与文字区分状态。 + +## 最小示例 + +```go +days := []mujica.UptimeDay{{Date: mujica.Date{Year: 2026, Month: 10, Day: 1}, Uptime: 1}, {Date: mujica.Date{Year: 2026, Month: 10, Day: 2}, Uptime: 0.91, Note: "Recovery"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.UptimeBar(c, days, mujica.UptimeBarOptions{Frame: mujica.ChartFrame{Label: "API uptime"}}) + if r.Changed() { + fmt.Println("day", r.Point) + } + if r.Retried() { + fmt.Println("retry") + } +} +ui.Render(view, 720, 480, 1) +``` + +## 参数 + +Frame.Label 必填;Frame.Height 默认 36 DIP;Frame.Status、Error 控制状态;Degraded 默认 0.999,Down 默认 0.95;Uptime 为 0 到 1,NaN 表示缺少数据。 + +## 状态 + +正常、降级、中断、缺测、空数据、加载、失败、父级禁用。 + +## 事件 + +Changed() 报告读取点变化;Point 为日期索引;Retried() 报告重试。 + +## 键盘操作 + +Tab 聚焦图;方向键逐日移动;Home、End 到边界;Escape 清除读取。 + +## 限制 + +大数据按像素列汇总;更新数据时提交新切片。 diff --git a/docs/components/446-service-status.md b/docs/components/446-service-status.md new file mode 100644 index 0000000..2692c51 --- /dev/null +++ b/docs/components/446-service-status.md @@ -0,0 +1,36 @@ +# 446 ServiceStatus · 服务状态 + +## 用途 + +展示服务正常、降级、中断和总体统计。 + +## 最小示例 + +```go +services := []mujica.ServiceItem{{Name: "API", Health: mujica.ServiceOperational}, {Name: "Search", Health: mujica.ServiceDegraded, Note: "Slow queries"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ServiceStatus(c, services, mujica.ServiceStatusOptions{}) +} +ui.Render(view, 720, 480, 1) +``` + +## 参数 + +MaxHeight 默认 360 DIP;ServiceItem.Name 必填;Health 为有效枚举;Note 展示原因。 + +## 状态 + +正常、降级、中断、空列表;总体统计随数据更新。 + +## 事件 + +展示组件,调用方更新服务切片。 + +## 键盘操作 + +滚动列表浏览服务。 + +## 限制 + +状态来自调用方;每行使用图标和文字区分状态。更新服务数据时提交新切片。 diff --git a/docs/components/447-log-stream.md b/docs/components/447-log-stream.md new file mode 100644 index 0000000..951d541 --- /dev/null +++ b/docs/components/447-log-stream.md @@ -0,0 +1,39 @@ +# 447 LogStream · 日志流 + +## 用途 + +展示实时 ANSI 日志,支持暂停、级别过滤与跟随末尾。 + +## 最小示例 + +```go +lines := []mujica.LogLine{{Level: mujica.LogInfo, Text: "server ready"}, {Level: mujica.LogWarn, Text: "slow query"}} +var state mujica.LogStreamState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.LogStream(c, lines, &state, mujica.LogStreamOptions{}).Changed() { + fmt.Println("filter changed") + } +} +ui.Render(view, 720, 480, 1) +``` + +## 参数 + +Height 默认 320 DIP;Disabled 禁用;LogStreamState.Paused 控制暂停,MinLevel 指定最低级别。 + +## 状态 + +跟随末尾、暂停、级别过滤、空数据、禁用。 + +## 事件 + +Changed() 报告暂停或过滤变化。 + +## 键盘操作 + +Tab 聚焦暂停和级别;方向键切换级别;Space 激活暂停。 + +## 限制 + +追加或替换日志提交新切片;暂停冻结条数。 diff --git a/docs/components/448-trace-waterfall.md b/docs/components/448-trace-waterfall.md new file mode 100644 index 0000000..b7fa2e6 --- /dev/null +++ b/docs/components/448-trace-waterfall.md @@ -0,0 +1,38 @@ +# 448 TraceWaterfall · 调用链瀑布图 + +## 用途 + +按跨度父子层级与耗时展示调用链,并读取跨度详情。 + +## 最小示例 + +```go +spans := []mujica.TraceSpan{{ID: "root", Name: "GET /api", Service: "gateway", Duration: 200 * time.Millisecond}, {ID: "db", Parent: "root", Name: "Query", Service: "database", Start: 20 * time.Millisecond, Duration: 80 * time.Millisecond}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.TraceWaterfall(c, spans, mujica.TraceWaterfallOptions{}).Changed() { + fmt.Println("span selected") + } +} +ui.Render(view, 720, 480, 1) +``` + +## 参数 + +Height 默认 320 DIP;TraceSpan.ID 唯一,Name 必填;Parent 关联父跨度;Start、Duration 为非负时间;Error 标记失败。 + +## 状态 + +空数据、跨度选择、失败跨度、父级禁用。 + +## 事件 + +Changed() 表示选择了跨度。 + +## 键盘操作 + +Tab 聚焦跨度;上下方向键移动;Enter 读取详情。 + +## 限制 + +更新调用链提交新切片;跨度父子关系须构成树。 diff --git a/docs/components/449-resource-gauge.md b/docs/components/449-resource-gauge.md new file mode 100644 index 0000000..47b9336 --- /dev/null +++ b/docs/components/449-resource-gauge.md @@ -0,0 +1,38 @@ +# 449 ResourceGauge · 资源仪表 + +## 用途 + +展示 CPU、内存与磁盘使用量和阈值状态。 + +## 最小示例 + +```go +readings := []mujica.ResourceReading{{Name: "CPU", Used: 45, Total: 100}, {Name: "Memory", Used: 6.4, Total: 8, Unit: "GB"}, {Name: "Disk", Used: 470, Total: 500, Unit: "GB"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ResourceGauge(c, readings, mujica.ResourceGaugeOptions{}).Changed() { + fmt.Println("retry") + } +} +ui.Render(view, 720, 480, 1) +``` + +## 参数 + +Warn 默认 0.7,Critical 默认 0.9,须 0 < Warn < Critical < 1;Height 默认 150 DIP;Status、Error 控制加载与失败;Name 必填,Total 为有限正数,Used 在 0 到 Total 内。 + +## 状态 + +正常、偏高、危险、空资源、加载、失败、父级禁用。 + +## 事件 + +Changed() 表示请求重试;调用方更新 readings。 + +## 键盘操作 + +Tab 聚焦仪表;方向键读取数值与阈值文字。 + +## 限制 + +资源采样与容量换算由调用方完成。 diff --git a/docs/components/450-system-monitor.md b/docs/components/450-system-monitor.md new file mode 100644 index 0000000..71bce85 --- /dev/null +++ b/docs/components/450-system-monitor.md @@ -0,0 +1,39 @@ +# 450 SystemMonitor · 系统监视器 + +## 用途 + +在同一时间轴上组合多资源折线,展示最新采样值。 + +## 最小示例 + +```go +times := []time.Time{time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC), time.Date(2026, 10, 5, 9, 0, 5, 0, time.UTC)} +metrics := []mujica.MonitorMetric{{Name: "CPU", Unit: "%", Samples: []float64{42, 51}}, {Name: "Memory", Unit: "GB", Samples: []float64{3.2, 3.4}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SystemMonitor(c, times, metrics, mujica.SystemMonitorOptions{}).Changed() { + fmt.Println("retry") + } +} +ui.Render(view, 720, 480, 1) +``` + +## 参数 + +Height 为单图高度,默认 110 DIP;Status、Error 控制加载与失败;每个 MonitorMetric.Name 必填,Samples 与 times 等长。 + +## 状态 + +实时采样、空资源、加载、失败、父级禁用。 + +## 事件 + +Changed() 表示请求重试。 + +## 键盘操作 + +Tab 到图例及图;方向键读取采样点;Home、End 到边界。 + +## 限制 + +采样由调用方提供;更新采样提交新切片;NaN 表示缺测。 diff --git a/docs/components/451-refresh-interval-selector.md b/docs/components/451-refresh-interval-selector.md new file mode 100644 index 0000000..5dd56da --- /dev/null +++ b/docs/components/451-refresh-interval-selector.md @@ -0,0 +1,42 @@ +# 451 RefreshIntervalSelector · 刷新间隔选择 + +## 用途 + +选择自动刷新间隔,报告手动与定时刷新请求。 + +## 最小示例 + +```go +interval := 30 * time.Second +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.RefreshIntervalSelector(c, &interval, mujica.RefreshIntervalSelectorOptions{}) + if r.Refresh { + fmt.Println("refresh") + } + if r.Changed { + fmt.Println("interval", interval) + } +} +ui.Render(view, 720, 480, 1) +``` + +## 参数 + +Intervals 默认提供 5、10、30 秒、1、5 分钟;0 表示关闭;选项须为正整数秒;Refreshing 暂停计时,Disabled 禁用。 + +## 状态 + +关闭自动刷新、自动刷新、刷新中、禁用。 + +## 事件 + +Refresh 表示请求刷新;Changed 报告间隔变化。 + +## 键盘操作 + +Tab 到选择器与按钮;方向键选择间隔;Enter 激活。 + +## 限制 + +调用方收到 Refresh 后执行加载并设置 Refreshing。 diff --git a/docs/components/452-dashboard-filter-bar.md b/docs/components/452-dashboard-filter-bar.md new file mode 100644 index 0000000..b5f3cf5 --- /dev/null +++ b/docs/components/452-dashboard-filter-bar.md @@ -0,0 +1,39 @@ +# 452 DashboardFilterBar · 仪表盘过滤栏 + +## 用途 + +编辑时间范围、环境和标签,并支持清除。 + +## 最小示例 + +```go +var filters mujica.DashboardFilters +opts := mujica.DashboardFilterBarOptions{Envs: []string{"production", "staging"}, Tags: []string{"api", "db"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.DashboardFilterBar(c, &filters, opts).Changed() { + fmt.Println("filters changed") + } +} +ui.Render(view, 720, 480, 1) +``` + +## 参数 + +Ranges 默认 15 分钟、1 小时、1 天、7 天;Env 与 Tags 来自选项;Range 为 0 使用调用方默认;Disabled 禁用。 + +## 状态 + +默认过滤、已选择、已清除、禁用。 + +## 事件 + +Changed() 报告过滤或清除。 + +## 键盘操作 + +Tab 遍历控件;方向键选择;Space 切换标签;Escape 关闭弹层。 + +## 限制 + +过滤请求由调用方执行;选中值须存在于提供的选项中。 diff --git a/docs/components/453-json-viewer.md b/docs/components/453-json-viewer.md new file mode 100644 index 0000000..5abd3ce --- /dev/null +++ b/docs/components/453-json-viewer.md @@ -0,0 +1,40 @@ +# 453 JsonViewer · JSON 查看器 + +## 用途 + +显示 JSON 树、折叠节点、复制路径并搜索键和值。 + +## 最小示例 + +```go +data := `{"server":{"port":8080},"ready":true}` +var state mujica.JsonViewerState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.JsonViewer(c, data, &state, mujica.JsonViewerOptions{}) + if r.Copied != "" { + fmt.Println("path copied") + } +} +ui.Render(view, 720, 480, 1) +``` + +## 参数 + +state 必填;Height 默认 360 DIP;Expanded 首次展开所有节点;Query 为搜索文本,Current 为匹配索引。 + +## 状态 + +展开、折叠、搜索匹配、无匹配、解析失败、父级禁用。 + +## 事件 + +Copied 为写入剪贴板的路径。 + +## 键盘操作 + +Tab 到搜索、复制和树;上下移动;左右展开折叠;Enter 切换节点。 + +## 限制 + +JSONPath 使用点号、数组索引和带引号的键;解析结果按数据变化缓存。 diff --git a/docs/components/454-json-editor.md b/docs/components/454-json-editor.md new file mode 100644 index 0000000..2bb8043 --- /dev/null +++ b/docs/components/454-json-editor.md @@ -0,0 +1,39 @@ +# 454 JsonEditor · JSON 编辑器 + +## 用途 + +编辑 JSON、格式化并显示解析错误行列与上下文。 + +## 最小示例 + +```go +text := `{"port":8080,"ready":true}` +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.JsonEditor(c, &text, mujica.JsonEditorOptions{}) + if r.Changed { + fmt.Println("valid", r.Valid) + } +} +ui.Render(view, 720, 480, 1) +``` + +## 参数 + +Label 默认 JSON;MinHeight 默认 160,MaxHeight 默认 400 DIP;Disabled 禁用;text 必填。 + +## 状态 + +有效 JSON、解析失败、错误行列定位、禁用。 + +## 事件 + +Changed 报告文本变化;Valid 为解析结果;Line、Column 为从 1 开始的错误位置。 + +## 键盘操作 + +Tab 到格式化和文本框;标准文本编辑与剪贴板快捷键。 + +## 限制 + +编辑单个 JSON 值;格式化使用两个空格缩进。 diff --git a/docs/components/455-schema-tree.md b/docs/components/455-schema-tree.md new file mode 100644 index 0000000..8eedc87 --- /dev/null +++ b/docs/components/455-schema-tree.md @@ -0,0 +1,40 @@ +# 455 SchemaTree · 数据库结构树 + +## 用途 + +显示数据库、表、视图、列和索引树。 + +## 最小示例 + +```go +roots := []mujica.SchemaNode{{Name: "app", Children: []mujica.SchemaNode{{Name: "users", Kind: mujica.SchemaTable, Children: []mujica.SchemaNode{{Name: "id", Kind: mujica.SchemaColumn, Detail: "bigint"}, {Name: "users_pk", Kind: mujica.SchemaIndex, Detail: "(id)"}}}}}} +var state mujica.SchemaTreeState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SchemaTree(c, &state, roots, mujica.SchemaTreeOptions{}).Changed() { + path, _ := state.Selected() + fmt.Println("selected", path) + } +} +ui.Render(view, 720, 480, 1) +``` + +## 参数 + +state 必填;Height 默认 360 DIP;Name 必填,兄弟名称唯一;Kind 使用枚举,Detail 为类型说明。 + +## 状态 + +展开、折叠、选择、空结构、父级禁用。 + +## 事件 + +Changed() 报告选择变化;state.Selected() 返回路径。 + +## 键盘操作 + +Tab 进入树;上下选择;左右展开折叠。 + +## 限制 + +路径以斜线分隔;节点名称须保持路径唯一;更新结构提交新切片。 diff --git a/docs/components/456-api-request-builder.md b/docs/components/456-api-request-builder.md new file mode 100644 index 0000000..90b2de9 --- /dev/null +++ b/docs/components/456-api-request-builder.md @@ -0,0 +1,42 @@ +# 456 ApiRequestBuilder · API 请求构建器 + +## 用途 + +编辑方法、URL、参数、请求头和正文,报告发送请求。 + +## 最小示例 + +```go +request := mujica.ApiRequest{Method: "POST", URL: "https://example.com/api", Headers: []mujica.KeyValue{{Key: "Accept", Value: "application/json", Enabled: true}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ApiRequestBuilder(c, &request, mujica.ApiRequestBuilderOptions{}) + if r.Send { + fmt.Println("send", request.Method) + } + if r.Changed { + fmt.Println("request changed") + } +} +ui.Render(view, 720, 480, 1) +``` + +## 参数 + +Sending 防止重复发送;Disabled 禁用;Method 默认为 GET;URL 须为 HTTP 或 HTTPS;启用且键非空的参数进入预览。 + +## 状态 + +空地址、有效地址、无效地址、发送中、禁用。 + +## 事件 + +Send 表示请求发送;Changed 为编辑变化。 + +## 键盘操作 + +Tab 遍历控件;URL 框 Enter 发送;左右切换标签页。 + +## 限制 + +网络请求由调用方执行;支持 GET、POST、PUT、PATCH、DELETE、HEAD、OPTIONS。 diff --git a/docs/components/457-response-viewer.md b/docs/components/457-response-viewer.md new file mode 100644 index 0000000..e90b164 --- /dev/null +++ b/docs/components/457-response-viewer.md @@ -0,0 +1,38 @@ +# 457 ResponseViewer · 响应查看器 + +## 用途 + +显示状态码、耗时、大小、JSON 或文本正文与响应头。 + +## 最小示例 + +```go +response := &mujica.ApiResponse{Status: 200, Duration: 182 * time.Millisecond, Size: 32, Body: `{"ready":true}`, Headers: []mujica.KeyValue{{Key: "Content-Type", Value: "application/json"}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ResponseViewer(c, response, mujica.ResponseViewerOptions{}).Changed() { + fmt.Println("retry") + } +} +ui.Render(view, 720, 480, 1) +``` + +## 参数 + +resp 为 nil 时显示等待;Status、Error 控制请求状态;Height 默认 320 DIP;HTTP 状态码须在 100 到 599;Size 为非负字节数。 + +## 状态 + +等待请求、加载、失败、JSON 正文、文本正文、空正文、空响应头、父级禁用。 + +## 事件 + +Changed() 表示重试。 + +## 键盘操作 + +Tab 到标签页;左右切换正文与响应头;JSON 树沿用查看器键盘操作。 + +## 限制 + +响应来自调用方;正文为有效 JSON 时使用树视图;其余正文使用文本。 diff --git a/docs/components/458-key-value-input.md b/docs/components/458-key-value-input.md new file mode 100644 index 0000000..46acde6 --- /dev/null +++ b/docs/components/458-key-value-input.md @@ -0,0 +1,38 @@ +# 458 KeyValueInput · 键值对输入 + +## 用途 + +编辑键值对、切换启用状态并增删行。 + +## 最小示例 + +```go +rows := []mujica.KeyValue{{Key: "Accept", Value: "application/json", Enabled: true}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.KeyValueInput(c, &rows, mujica.KeyValueInputOptions{}).Changed() { + fmt.Println("rows", len(rows)) + } +} +ui.Render(view, 720, 480, 1) +``` + +## 参数 + +KeyPlaceholder、ValuePlaceholder 默认内置文案;Label 为控件名称;MaxHeight 默认 320 DIP;Disabled 禁用;rows 必填。 + +## 状态 + +空集合、启用行、停用行、禁用。 + +## 事件 + +Changed() 报告编辑、启用切换或增删。 + +## 键盘操作 + +Tab 到各字段及按钮;Space 切换启用;Enter 激活增删。 + +## 限制 + +行顺序保持输入顺序;大量行采用虚拟化。 diff --git a/example_devtools_test.go b/example_devtools_test.go new file mode 100644 index 0000000..937de0f --- /dev/null +++ b/example_devtools_test.go @@ -0,0 +1,218 @@ +package mujica_test + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func ExampleStatCard() { + value := "12,480" + opts := mujica.StatCardOptions{Label: "Requests", Change: 0.12, HasChange: true, Trend: []float64{3, 5, 4, 8}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.StatCard(c, value, opts).Changed() { + fmt.Println("retry") + } + } + ui.Render(view, 720, 480, 1) +} + +func ExampleAlertList() { + alerts := []mujica.AlertItem{{ID: "cpu", Title: "CPU usage high", Level: mujica.AlertWarning, Source: "worker", At: time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.AlertList(c, alerts, mujica.AlertListOptions{}) + if r.Ack != "" { + fmt.Println("ack", r.Ack) + } + if r.Silence != "" { + fmt.Println("silence", r.Silence) + } + } + ui.Render(view, 720, 480, 1) +} + +func ExampleIncidentCard() { + incident := mujica.Incident{Title: "API latency", Status: mujica.IncidentMonitoring, Impact: mujica.ImpactMinor, Updates: []mujica.IncidentUpdate{{Status: mujica.IncidentIdentified, Text: "Slow query located"}, {Status: mujica.IncidentMonitoring, Text: "Recovery observed"}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IncidentCard(c, incident) + } + ui.Render(view, 720, 480, 1) +} + +func ExampleUptimeBar() { + days := []mujica.UptimeDay{{Date: mujica.Date{Year: 2026, Month: 10, Day: 1}, Uptime: 1}, {Date: mujica.Date{Year: 2026, Month: 10, Day: 2}, Uptime: 0.91, Note: "Recovery"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.UptimeBar(c, days, mujica.UptimeBarOptions{Frame: mujica.ChartFrame{Label: "API uptime"}}) + if r.Changed() { + fmt.Println("day", r.Point) + } + if r.Retried() { + fmt.Println("retry") + } + } + ui.Render(view, 720, 480, 1) +} + +func ExampleServiceStatus() { + services := []mujica.ServiceItem{{Name: "API", Health: mujica.ServiceOperational}, {Name: "Search", Health: mujica.ServiceDegraded, Note: "Slow queries"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ServiceStatus(c, services, mujica.ServiceStatusOptions{}) + } + ui.Render(view, 720, 480, 1) +} + +func ExampleLogStream() { + lines := []mujica.LogLine{{Level: mujica.LogInfo, Text: "server ready"}, {Level: mujica.LogWarn, Text: "slow query"}} + var state mujica.LogStreamState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.LogStream(c, lines, &state, mujica.LogStreamOptions{}).Changed() { + fmt.Println("filter changed") + } + } + ui.Render(view, 720, 480, 1) +} + +func ExampleTraceWaterfall() { + spans := []mujica.TraceSpan{{ID: "root", Name: "GET /api", Service: "gateway", Duration: 200 * time.Millisecond}, {ID: "db", Parent: "root", Name: "Query", Service: "database", Start: 20 * time.Millisecond, Duration: 80 * time.Millisecond}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.TraceWaterfall(c, spans, mujica.TraceWaterfallOptions{}).Changed() { + fmt.Println("span selected") + } + } + ui.Render(view, 720, 480, 1) +} + +func ExampleResourceGauge() { + readings := []mujica.ResourceReading{{Name: "CPU", Used: 45, Total: 100}, {Name: "Memory", Used: 6.4, Total: 8, Unit: "GB"}, {Name: "Disk", Used: 470, Total: 500, Unit: "GB"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ResourceGauge(c, readings, mujica.ResourceGaugeOptions{}).Changed() { + fmt.Println("retry") + } + } + ui.Render(view, 720, 480, 1) +} + +func ExampleSystemMonitor() { + times := []time.Time{time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC), time.Date(2026, 10, 5, 9, 0, 5, 0, time.UTC)} + metrics := []mujica.MonitorMetric{{Name: "CPU", Unit: "%", Samples: []float64{42, 51}}, {Name: "Memory", Unit: "GB", Samples: []float64{3.2, 3.4}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SystemMonitor(c, times, metrics, mujica.SystemMonitorOptions{}).Changed() { + fmt.Println("retry") + } + } + ui.Render(view, 720, 480, 1) +} + +func ExampleRefreshIntervalSelector() { + interval := 30 * time.Second + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.RefreshIntervalSelector(c, &interval, mujica.RefreshIntervalSelectorOptions{}) + if r.Refresh { + fmt.Println("refresh") + } + if r.Changed { + fmt.Println("interval", interval) + } + } + ui.Render(view, 720, 480, 1) +} + +func ExampleDashboardFilterBar() { + var filters mujica.DashboardFilters + opts := mujica.DashboardFilterBarOptions{Envs: []string{"production", "staging"}, Tags: []string{"api", "db"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.DashboardFilterBar(c, &filters, opts).Changed() { + fmt.Println("filters changed") + } + } + ui.Render(view, 720, 480, 1) +} + +func ExampleJsonViewer() { + data := `{"server":{"port":8080},"ready":true}` + var state mujica.JsonViewerState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.JsonViewer(c, data, &state, mujica.JsonViewerOptions{}) + if r.Copied != "" { + fmt.Println("path copied") + } + } + ui.Render(view, 720, 480, 1) +} + +func ExampleJsonEditor() { + text := `{"port":8080,"ready":true}` + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.JsonEditor(c, &text, mujica.JsonEditorOptions{}) + if r.Changed { + fmt.Println("valid", r.Valid) + } + } + ui.Render(view, 720, 480, 1) +} + +func ExampleSchemaTree() { + roots := []mujica.SchemaNode{{Name: "app", Children: []mujica.SchemaNode{{Name: "users", Kind: mujica.SchemaTable, Children: []mujica.SchemaNode{{Name: "id", Kind: mujica.SchemaColumn, Detail: "bigint"}, {Name: "users_pk", Kind: mujica.SchemaIndex, Detail: "(id)"}}}}}} + var state mujica.SchemaTreeState + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SchemaTree(c, &state, roots, mujica.SchemaTreeOptions{}).Changed() { + path, _ := state.Selected() + fmt.Println("selected", path) + } + } + ui.Render(view, 720, 480, 1) +} + +func ExampleApiRequestBuilder() { + request := mujica.ApiRequest{Method: "POST", URL: "https://example.com/api", Headers: []mujica.KeyValue{{Key: "Accept", Value: "application/json", Enabled: true}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ApiRequestBuilder(c, &request, mujica.ApiRequestBuilderOptions{}) + if r.Send { + fmt.Println("send", request.Method) + } + if r.Changed { + fmt.Println("request changed") + } + } + ui.Render(view, 720, 480, 1) +} + +func ExampleResponseViewer() { + response := &mujica.ApiResponse{Status: 200, Duration: 182 * time.Millisecond, Size: 32, Body: `{"ready":true}`, Headers: []mujica.KeyValue{{Key: "Content-Type", Value: "application/json"}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ResponseViewer(c, response, mujica.ResponseViewerOptions{}).Changed() { + fmt.Println("retry") + } + } + ui.Render(view, 720, 480, 1) +} + +func ExampleKeyValueInput() { + rows := []mujica.KeyValue{{Key: "Accept", Value: "application/json", Enabled: true}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.KeyValueInput(c, &rows, mujica.KeyValueInputOptions{}).Changed() { + fmt.Println("rows", len(rows)) + } + } + ui.Render(view, 720, 480, 1) +} diff --git a/examples/gallery/pages_devtools.go b/examples/gallery/pages_devtools.go index bd2c03b..0469143 100644 --- a/examples/gallery/pages_devtools.go +++ b/examples/gallery/pages_devtools.go @@ -1,3 +1,23 @@ package main -var devtoolsPages = []page{} +const devCategory = "Dashboards & Dev Tools" + +var devtoolsPages = []page{ + {ID: "442", Name: "StatCard", ZhName: "统计卡片", Category: devCategory, Demo: devStatCardDemo, Code: devStatCardCode, Keys: "Tab 聚焦趋势或重试;方向键读取数据;Enter 激活重试。"}, + {ID: "443", Name: "AlertList", ZhName: "告警列表", Category: devCategory, Demo: devAlertListDemo, Code: devAlertListCode, Keys: "Tab 聚焦按钮;Enter 或 Space 激活。"}, + {ID: "444", Name: "IncidentCard", ZhName: "事件卡片", Category: devCategory, Demo: devIncidentCardDemo, Code: devIncidentCardCode, Keys: "展示内容随调用方更新。"}, + {ID: "445", Name: "UptimeBar", ZhName: "可用性条", Category: devCategory, Demo: devUptimeBarDemo, Code: devUptimeBarCode, Keys: "Tab 聚焦图;方向键逐日移动;Home、End 到边界;Escape 清除读取。"}, + {ID: "446", Name: "ServiceStatus", ZhName: "服务状态", Category: devCategory, Demo: devServiceStatusDemo, Code: devServiceStatusCode, Keys: "滚动列表浏览服务。"}, + {ID: "447", Name: "LogStream", ZhName: "日志流", Category: devCategory, Demo: devLogStreamDemo, Code: devLogStreamCode, Keys: "Tab 聚焦暂停和级别;方向键切换级别;Space 激活暂停。"}, + {ID: "448", Name: "TraceWaterfall", ZhName: "调用链瀑布图", Category: devCategory, Demo: devTraceWaterfallDemo, Code: devTraceWaterfallCode, Keys: "Tab 聚焦跨度;上下方向键移动;Enter 读取详情。"}, + {ID: "449", Name: "ResourceGauge", ZhName: "资源仪表", Category: devCategory, Demo: devResourceGaugeDemo, Code: devResourceGaugeCode, Keys: "Tab 聚焦仪表;方向键读取数值与阈值文字。"}, + {ID: "450", Name: "SystemMonitor", ZhName: "系统监视器", Category: devCategory, Demo: devSystemMonitorDemo, Code: devSystemMonitorCode, Keys: "Tab 到图例及图;方向键读取采样点;Home、End 到边界。"}, + {ID: "451", Name: "RefreshIntervalSelector", ZhName: "刷新间隔选择", Category: devCategory, Demo: devRefreshIntervalSelectorDemo, Code: devRefreshIntervalSelectorCode, Keys: "Tab 到选择器与按钮;方向键选择间隔;Enter 激活。"}, + {ID: "452", Name: "DashboardFilterBar", ZhName: "仪表盘过滤栏", Category: devCategory, Demo: devDashboardFilterBarDemo, Code: devDashboardFilterBarCode, Keys: "Tab 遍历控件;方向键选择;Space 切换标签;Escape 关闭弹层。"}, + {ID: "453", Name: "JsonViewer", ZhName: "JSON 查看器", Category: devCategory, Demo: devJsonViewerDemo, Code: devJsonViewerCode, Keys: "Tab 到搜索、复制和树;上下移动;左右展开折叠;Enter 切换节点。"}, + {ID: "454", Name: "JsonEditor", ZhName: "JSON 编辑器", Category: devCategory, Demo: devJsonEditorDemo, Code: devJsonEditorCode, Keys: "Tab 到格式化和文本框;标准文本编辑与剪贴板快捷键。"}, + {ID: "455", Name: "SchemaTree", ZhName: "数据库结构树", Category: devCategory, Demo: devSchemaTreeDemo, Code: devSchemaTreeCode, Keys: "Tab 进入树;上下选择;左右展开折叠。"}, + {ID: "456", Name: "ApiRequestBuilder", ZhName: "API 请求构建器", Category: devCategory, Demo: devApiRequestBuilderDemo, Code: devApiRequestBuilderCode, Keys: "Tab 遍历控件;URL 框 Enter 发送;左右切换标签页。"}, + {ID: "457", Name: "ResponseViewer", ZhName: "响应查看器", Category: devCategory, Demo: devResponseViewerDemo, Code: devResponseViewerCode, Keys: "Tab 到标签页;左右切换正文与响应头;JSON 树沿用查看器键盘操作。"}, + {ID: "458", Name: "KeyValueInput", ZhName: "键值对输入", Category: devCategory, Demo: devKeyValueInputDemo, Code: devKeyValueInputCode, Keys: "Tab 到各字段及按钮;Space 切换启用;Enter 激活增删。"}, +} diff --git a/examples/gallery/pages_devtools_code.go b/examples/gallery/pages_devtools_code.go new file mode 100644 index 0000000..1e33ea5 --- /dev/null +++ b/examples/gallery/pages_devtools_code.go @@ -0,0 +1,175 @@ +package main + +const devStatCardCode = `value := "12,480" +opts := mujica.StatCardOptions{Label: "Requests", Change: 0.12, HasChange: true, Trend: []float64{3, 5, 4, 8}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.StatCard(c, value, opts).Changed() { + fmt.Println("retry") + } +} +ui.Render(view, 720, 480, 1)` + +const devAlertListCode = `alerts := []mujica.AlertItem{{ID: "cpu", Title: "CPU usage high", Level: mujica.AlertWarning, Source: "worker", At: time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.AlertList(c, alerts, mujica.AlertListOptions{}) + if r.Ack != "" { + fmt.Println("ack", r.Ack) + } + if r.Silence != "" { + fmt.Println("silence", r.Silence) + } +} +ui.Render(view, 720, 480, 1)` + +const devIncidentCardCode = `incident := mujica.Incident{Title: "API latency", Status: mujica.IncidentMonitoring, Impact: mujica.ImpactMinor, Updates: []mujica.IncidentUpdate{{Status: mujica.IncidentIdentified, Text: "Slow query located"}, {Status: mujica.IncidentMonitoring, Text: "Recovery observed"}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.IncidentCard(c, incident) +} +ui.Render(view, 720, 480, 1)` + +const devUptimeBarCode = `days := []mujica.UptimeDay{{Date: mujica.Date{Year: 2026, Month: 10, Day: 1}, Uptime: 1}, {Date: mujica.Date{Year: 2026, Month: 10, Day: 2}, Uptime: 0.91, Note: "Recovery"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.UptimeBar(c, days, mujica.UptimeBarOptions{Frame: mujica.ChartFrame{Label: "API uptime"}}) + if r.Changed() { + fmt.Println("day", r.Point) + } + if r.Retried() { + fmt.Println("retry") + } +} +ui.Render(view, 720, 480, 1)` + +const devServiceStatusCode = `services := []mujica.ServiceItem{{Name: "API", Health: mujica.ServiceOperational}, {Name: "Search", Health: mujica.ServiceDegraded, Note: "Slow queries"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ServiceStatus(c, services, mujica.ServiceStatusOptions{}) +} +ui.Render(view, 720, 480, 1)` + +const devLogStreamCode = `lines := []mujica.LogLine{{Level: mujica.LogInfo, Text: "server ready"}, {Level: mujica.LogWarn, Text: "slow query"}} +var state mujica.LogStreamState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.LogStream(c, lines, &state, mujica.LogStreamOptions{}).Changed() { + fmt.Println("filter changed") + } +} +ui.Render(view, 720, 480, 1)` + +const devTraceWaterfallCode = `spans := []mujica.TraceSpan{{ID: "root", Name: "GET /api", Service: "gateway", Duration: 200 * time.Millisecond}, {ID: "db", Parent: "root", Name: "Query", Service: "database", Start: 20 * time.Millisecond, Duration: 80 * time.Millisecond}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.TraceWaterfall(c, spans, mujica.TraceWaterfallOptions{}).Changed() { + fmt.Println("span selected") + } +} +ui.Render(view, 720, 480, 1)` + +const devResourceGaugeCode = `readings := []mujica.ResourceReading{{Name: "CPU", Used: 45, Total: 100}, {Name: "Memory", Used: 6.4, Total: 8, Unit: "GB"}, {Name: "Disk", Used: 470, Total: 500, Unit: "GB"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ResourceGauge(c, readings, mujica.ResourceGaugeOptions{}).Changed() { + fmt.Println("retry") + } +} +ui.Render(view, 720, 480, 1)` + +const devSystemMonitorCode = `times := []time.Time{time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC), time.Date(2026, 10, 5, 9, 0, 5, 0, time.UTC)} +metrics := []mujica.MonitorMetric{{Name: "CPU", Unit: "%", Samples: []float64{42, 51}}, {Name: "Memory", Unit: "GB", Samples: []float64{3.2, 3.4}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SystemMonitor(c, times, metrics, mujica.SystemMonitorOptions{}).Changed() { + fmt.Println("retry") + } +} +ui.Render(view, 720, 480, 1)` + +const devRefreshIntervalSelectorCode = `interval := 30 * time.Second +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.RefreshIntervalSelector(c, &interval, mujica.RefreshIntervalSelectorOptions{}) + if r.Refresh { + fmt.Println("refresh") + } + if r.Changed { + fmt.Println("interval", interval) + } +} +ui.Render(view, 720, 480, 1)` + +const devDashboardFilterBarCode = `var filters mujica.DashboardFilters +opts := mujica.DashboardFilterBarOptions{Envs: []string{"production", "staging"}, Tags: []string{"api", "db"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.DashboardFilterBar(c, &filters, opts).Changed() { + fmt.Println("filters changed") + } +} +ui.Render(view, 720, 480, 1)` + +const devJsonViewerCode = `data := ` + "`" + `{"server":{"port":8080},"ready":true}` + "`" + ` +var state mujica.JsonViewerState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.JsonViewer(c, data, &state, mujica.JsonViewerOptions{}) + if r.Copied != "" { + fmt.Println("path copied") + } +} +ui.Render(view, 720, 480, 1)` + +const devJsonEditorCode = `text := ` + "`" + `{"port":8080,"ready":true}` + "`" + ` +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.JsonEditor(c, &text, mujica.JsonEditorOptions{}) + if r.Changed { + fmt.Println("valid", r.Valid) + } +} +ui.Render(view, 720, 480, 1)` + +const devSchemaTreeCode = `roots := []mujica.SchemaNode{{Name: "app", Children: []mujica.SchemaNode{{Name: "users", Kind: mujica.SchemaTable, Children: []mujica.SchemaNode{{Name: "id", Kind: mujica.SchemaColumn, Detail: "bigint"}, {Name: "users_pk", Kind: mujica.SchemaIndex, Detail: "(id)"}}}}}} +var state mujica.SchemaTreeState +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.SchemaTree(c, &state, roots, mujica.SchemaTreeOptions{}).Changed() { + path, _ := state.Selected() + fmt.Println("selected", path) + } +} +ui.Render(view, 720, 480, 1)` + +const devApiRequestBuilderCode = `request := mujica.ApiRequest{Method: "POST", URL: "https://example.com/api", Headers: []mujica.KeyValue{{Key: "Accept", Value: "application/json", Enabled: true}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.ApiRequestBuilder(c, &request, mujica.ApiRequestBuilderOptions{}) + if r.Send { + fmt.Println("send", request.Method) + } + if r.Changed { + fmt.Println("request changed") + } +} +ui.Render(view, 720, 480, 1)` + +const devResponseViewerCode = `response := &mujica.ApiResponse{Status: 200, Duration: 182 * time.Millisecond, Size: 32, Body: ` + "`" + `{"ready":true}` + "`" + `, Headers: []mujica.KeyValue{{Key: "Content-Type", Value: "application/json"}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.ResponseViewer(c, response, mujica.ResponseViewerOptions{}).Changed() { + fmt.Println("retry") + } +} +ui.Render(view, 720, 480, 1)` + +const devKeyValueInputCode = `rows := []mujica.KeyValue{{Key: "Accept", Value: "application/json", Enabled: true}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.KeyValueInput(c, &rows, mujica.KeyValueInputOptions{}).Changed() { + fmt.Println("rows", len(rows)) + } +} +ui.Render(view, 720, 480, 1)` diff --git a/examples/gallery/pages_devtools_editors.go b/examples/gallery/pages_devtools_editors.go new file mode 100644 index 0000000..7d3b74b --- /dev/null +++ b/examples/gallery/pages_devtools_editors.go @@ -0,0 +1,133 @@ +package main + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func devJsonViewerDemo(c *ui.Context, log func(string)) { + dtSection(c, "JsonViewer", func() { + s := devControls(c, "453", log, false) + type state struct { + tree mujica.JsonViewerState + invalid bool + } + st := ui.Local(c.Root(), "453", func() state { return state{} }) + if mujica.Checkbox(c, &st.invalid, "Invalid JSON", mujica.CheckboxOptions{}).Changed() { + log("JSON validity changed") + } + devPreview(c, s, func() { + data := `{"server":{"port":8080,"hosts":["east","west"]},"ready":true}` + if st.invalid { + data = `{"port":}` + } + if s.empty { + data = `{}` + } + if mujica.JsonViewer(c, data, &st.tree, mujica.JsonViewerOptions{}).Copied != "" { + log("path copied") + } + }) + }) +} + +func devJsonEditorDemo(c *ui.Context, log func(string)) { + dtSection(c, "JsonEditor", func() { + s := devControls(c, "454", log, false) + text := ui.Local(c.Root(), "454", func() string { return `{"port":8080,"ready":true}` }) + if mujica.Button(c, "Invalid sample", mujica.ButtonOptions{}).Clicked() { + *text = "{\n \"port\": [1,2,]\n}" + log("invalid sample loaded") + } + devPreview(c, s, func() { + r := mujica.JsonEditor(c, text, mujica.JsonEditorOptions{}) + if r.Changed { + log(fmt.Sprintf("JSON edited; valid=%t", r.Valid)) + } + }) + }) +} + +func devSchemaTreeDemo(c *ui.Context, log func(string)) { + dtSection(c, "SchemaTree", func() { + s := devControls(c, "455", log, false) + type state struct { + tree mujica.SchemaTreeState + roots []mujica.SchemaNode + } + st := ui.Local(c.Root(), "455", func() state { + return state{roots: []mujica.SchemaNode{{Name: "app", Children: []mujica.SchemaNode{{Name: "users", Kind: mujica.SchemaTable, Children: []mujica.SchemaNode{{Name: "id", Kind: mujica.SchemaColumn, Detail: "bigint"}, {Name: "name", Kind: mujica.SchemaColumn, Detail: "text"}, {Name: "users_pk", Kind: mujica.SchemaIndex, Detail: "(id)"}}}, {Name: "active_users", Kind: mujica.SchemaView}}}}} + }) + devPreview(c, s, func() { + roots := st.roots + if s.empty { + roots = nil + } + if mujica.SchemaTree(c, &st.tree, roots, mujica.SchemaTreeOptions{}).Changed() { + log("schema node selected") + } + }) + }) +} + +func devApiRequestBuilderDemo(c *ui.Context, log func(string)) { + dtSection(c, "ApiRequestBuilder", func() { + s := devControls(c, "456", log, false) + request := ui.Local(c.Root(), "456", func() mujica.ApiRequest { + return mujica.ApiRequest{URL: "https://example.com/api", Headers: []mujica.KeyValue{{Key: "Accept", Value: "application/json", Enabled: true}}} + }) + sending := ui.Local(c.Root(), "456-sending", func() bool { return false }) + if mujica.Checkbox(c, sending, "Sending", mujica.CheckboxOptions{}).Changed() { + log("send state changed") + } + devPreview(c, s, func() { + r := mujica.ApiRequestBuilder(c, request, mujica.ApiRequestBuilderOptions{Sending: *sending}) + if r.Send { + log("send requested") + } + if r.Changed { + log("request edited") + } + }) + }) +} + +func devResponseViewerDemo(c *ui.Context, log func(string)) { + dtSection(c, "ResponseViewer", func() { + s := devControls(c, "457", log, true) + text := ui.Local(c.Root(), "457", func() bool { return false }) + if mujica.Checkbox(c, text, "Text body", mujica.CheckboxOptions{}).Changed() { + log("response format changed") + } + devPreview(c, s, func() { + resp := &mujica.ApiResponse{Status: 200, Duration: 182 * time.Millisecond, Size: 2048, Body: `{"ready":true,"requests":1248}`, Headers: []mujica.KeyValue{{Key: "Content-Type", Value: "application/json"}, {Key: "X-Trace", Value: "demo"}}} + if *text { + resp.Body = "Response received successfully." + } + if s.empty { + resp = nil + } + if mujica.ResponseViewer(c, resp, mujica.ResponseViewerOptions{Height: 260, Status: s.status, Error: "Connection refused"}).Changed() { + log("retry requested") + } + }) + }) +} + +func devKeyValueInputDemo(c *ui.Context, log func(string)) { + dtSection(c, "KeyValueInput", func() { + s := devControls(c, "458", log, false) + rows := ui.Local(c.Root(), "458", func() []mujica.KeyValue { + return []mujica.KeyValue{{Key: "Accept", Value: "application/json", Enabled: true}, {Key: "debug", Value: "true"}} + }) + devPreview(c, s, func() { + if mujica.KeyValueInput(c, rows, mujica.KeyValueInputOptions{Label: "Headers"}).Changed() { + log("pairs edited") + } + }) + }) +} diff --git a/examples/gallery/pages_devtools_status.go b/examples/gallery/pages_devtools_status.go new file mode 100644 index 0000000..ee44e1c --- /dev/null +++ b/examples/gallery/pages_devtools_status.go @@ -0,0 +1,173 @@ +package main + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +type devDemoSettings struct { + disabled bool + status mujica.DataStatus + empty bool +} + +func devControls(c *ui.Context, id string, log func(string), states bool) *devDemoSettings { + s := ui.Local(c.Root(), id+"-controls", func() devDemoSettings { return devDemoSettings{} }) + dtControls(c, func() { + if mujica.Checkbox(c, &s.disabled, "Disabled", mujica.CheckboxOptions{}).Changed() { + log("disabled changed") + } + switch id { + case "451", "454", "456", "458": + default: + if mujica.Checkbox(c, &s.empty, "Empty data", mujica.CheckboxOptions{}).Changed() { + log("data state changed") + } + } + if states { + options := []mujica.SelectOption[mujica.DataStatus]{{Value: mujica.DataReady, Label: "Ready"}, {Value: mujica.DataLoading, Label: "Loading"}, {Value: mujica.DataFailed, Label: "Failed"}} + if mujica.Select(c, &s.status, options, mujica.SelectOptions{Label: "Status"}).Changed() { + log("status changed") + } + } + }) + return s +} + +func devPreview(c *ui.Context, s *devDemoSettings, build func()) { + ui.Column(c).Disabled(s.disabled).Gap(8).Children(build) +} + +func devStatCardDemo(c *ui.Context, log func(string)) { + dtSection(c, "StatCard", func() { + s := devControls(c, "442", log, true) + lower := ui.Local(c.Root(), "442-lower", func() bool { return false }) + if mujica.Checkbox(c, lower, "Lower is better", mujica.CheckboxOptions{}).Changed() { + log("change direction updated") + } + devPreview(c, s, func() { + value := "12,480" + trend := []float64{3, 5, 4, 8, 7, 9} + if s.empty { + value, trend = "—", nil + } + if mujica.StatCard(c, value, mujica.StatCardOptions{Label: "Requests", Change: 0.12, HasChange: true, LowerIsBetter: *lower, Trend: trend, Status: s.status, Error: "Source unreachable"}).Changed() { + log("retry requested") + } + }) + }) +} + +func devAlertListDemo(c *ui.Context, log func(string)) { + dtSection(c, "AlertList", func() { + s := devControls(c, "443", log, false) + alerts := ui.Local(c.Root(), "443", func() []mujica.AlertItem { + return []mujica.AlertItem{{ID: "cpu", Title: "CPU usage high", Level: mujica.AlertWarning, Source: "worker", At: time.Now()}, {ID: "api", Title: "API unavailable", Level: mujica.AlertCritical, Source: "gateway", At: time.Now()}} + }) + devPreview(c, s, func() { + shown := *alerts + if s.empty { + shown = nil + } + r := mujica.AlertList(c, shown, mujica.AlertListOptions{}) + for i := range *alerts { + if r.Ack == (*alerts)[i].ID { + (*alerts)[i].Acked = true + log("acknowledged " + r.Ack) + } + if r.Silence == (*alerts)[i].ID { + (*alerts)[i].Silence = c.Now().Add(time.Hour) + log("silenced " + r.Silence) + } + } + }) + }) +} + +func devIncidentCardDemo(c *ui.Context, log func(string)) { + dtSection(c, "IncidentCard", func() { + s := devControls(c, "444", log, false) + status := ui.Local(c.Root(), "444", func() mujica.IncidentStatus { return mujica.IncidentMonitoring }) + choices := []mujica.SelectOption[mujica.IncidentStatus]{{Value: mujica.IncidentInvestigating, Label: "Investigating"}, {Value: mujica.IncidentIdentified, Label: "Identified"}, {Value: mujica.IncidentMonitoring, Label: "Monitoring"}, {Value: mujica.IncidentResolved, Label: "Resolved"}} + if mujica.Select(c, status, choices, mujica.SelectOptions{Label: "Incident status"}).Changed() { + log("incident status changed") + } + devPreview(c, s, func() { + updates := []mujica.IncidentUpdate{{Status: mujica.IncidentIdentified, Text: "Slow query located"}, {Status: mujica.IncidentMonitoring, Text: "Recovery observed"}} + if s.empty { + updates = nil + } + mujica.IncidentCard(c, mujica.Incident{Title: "API latency", Status: *status, Impact: mujica.ImpactMinor, Updates: updates}) + }) + }) +} + +func devUptimeBarDemo(c *ui.Context, log func(string)) { + dtSection(c, "UptimeBar", func() { + s := devControls(c, "445", log, true) + days := ui.Local(c.Root(), "445", func() []mujica.UptimeDay { + out := make([]mujica.UptimeDay, 30) + start := mujica.Date{Year: 2026, Month: 10, Day: 1} + for i := range out { + out[i] = mujica.UptimeDay{Date: start.AddDays(i), Uptime: 1} + } + out[7].Uptime, out[7].Note = 0.91, "2 h outage" + out[12].Uptime = 0.98 + return out + }) + devPreview(c, s, func() { + data := *days + if s.empty { + data = nil + } + r := mujica.UptimeBar(c, data, mujica.UptimeBarOptions{Frame: mujica.ChartFrame{Label: "API uptime", Status: s.status, Error: "Source unreachable"}}) + if r.Changed() { + log(fmt.Sprintf("day %d", r.Point)) + } + if r.Retried() { + log("retry requested") + } + }) + }) +} + +func devServiceStatusDemo(c *ui.Context, log func(string)) { + dtSection(c, "ServiceStatus", func() { + s := devControls(c, "446", log, false) + health := ui.Local(c.Root(), "446", func() mujica.ServiceHealth { return mujica.ServiceDegraded }) + choices := []mujica.SelectOption[mujica.ServiceHealth]{{Value: mujica.ServiceOperational, Label: "Operational"}, {Value: mujica.ServiceDegraded, Label: "Degraded"}, {Value: mujica.ServiceOutage, Label: "Outage"}} + if mujica.Select(c, health, choices, mujica.SelectOptions{Label: "Search health"}).Changed() { + log("service health changed") + } + devPreview(c, s, func() { + data := []mujica.ServiceItem{{Name: "API", Health: mujica.ServiceOperational}, {Name: "Search", Health: *health, Note: "Search cluster"}} + if s.empty { + data = nil + } + mujica.ServiceStatus(c, data, mujica.ServiceStatusOptions{}) + }) + }) +} + +func devResourceGaugeDemo(c *ui.Context, log func(string)) { + dtSection(c, "ResourceGauge", func() { + s := devControls(c, "449", log, true) + used := ui.Local(c.Root(), "449", func() float64 { return 45 }) + if mujica.NumberInput(c, used, mujica.NumberInputOptions{Min: 0, Max: 100, Step: 5, Label: "CPU usage"}).Changed() { + log("resource reading changed") + } + devPreview(c, s, func() { + data := []mujica.ResourceReading{{Name: "CPU", Used: *used, Total: 100}, {Name: "Memory", Used: 6.4, Total: 8, Unit: "GB"}, {Name: "Disk", Used: 470, Total: 500, Unit: "GB"}} + if s.empty { + data = nil + } + if mujica.ResourceGauge(c, data, mujica.ResourceGaugeOptions{Status: s.status, Error: "Source unreachable"}).Changed() { + log("retry requested") + } + }) + }) +} diff --git a/examples/gallery/pages_devtools_streams.go b/examples/gallery/pages_devtools_streams.go new file mode 100644 index 0000000..99fc0b8 --- /dev/null +++ b/examples/gallery/pages_devtools_streams.go @@ -0,0 +1,127 @@ +package main + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func devLogStreamDemo(c *ui.Context, log func(string)) { + dtSection(c, "LogStream", func() { + s := devControls(c, "447", log, false) + type state struct { + lines []mujica.LogLine + stream mujica.LogStreamState + } + st := ui.Local(c.Root(), "447", func() state { + return state{lines: []mujica.LogLine{{Level: mujica.LogInfo, Text: "server ready"}, {Level: mujica.LogWarn, Text: "slow query"}}} + }) + if mujica.Button(c, "Append line", mujica.ButtonOptions{}).Clicked() { + st.lines = append(st.lines, mujica.LogLine{Time: c.Now(), Level: mujica.LogInfo, Text: fmt.Sprintf("request %d completed", len(st.lines))}) + log("line appended") + } + devPreview(c, s, func() { + data := st.lines + if s.empty { + data = nil + } + if mujica.LogStream(c, data, &st.stream, mujica.LogStreamOptions{}).Changed() { + log("pause or level changed") + } + }) + }) +} + +func devTraceWaterfallDemo(c *ui.Context, log func(string)) { + dtSection(c, "TraceWaterfall", func() { + s := devControls(c, "448", log, false) + spans := ui.Local(c.Root(), "448", func() []mujica.TraceSpan { + return []mujica.TraceSpan{{ID: "root", Name: "GET /api", Service: "gateway", Duration: 320 * time.Millisecond}, {ID: "db", Parent: "root", Name: "Query", Service: "database", Start: 40 * time.Millisecond, Duration: 90 * time.Millisecond}, {ID: "pay", Parent: "root", Name: "Charge", Service: "payments", Start: 230 * time.Millisecond, Duration: 80 * time.Millisecond, Error: true}} + }) + devPreview(c, s, func() { + data := *spans + if s.empty { + data = nil + } + if mujica.TraceWaterfall(c, data, mujica.TraceWaterfallOptions{}).Changed() { + log("span selected") + } + }) + }) +} + +func devSystemMonitorDemo(c *ui.Context, log func(string)) { + dtSection(c, "SystemMonitor", func() { + s := devControls(c, "450", log, true) + type state struct { + times []time.Time + metrics []mujica.MonitorMetric + } + st := ui.Local(c.Root(), "450", func() state { + out := state{metrics: []mujica.MonitorMetric{{Name: "CPU", Unit: "%"}, {Name: "Memory", Unit: "GB"}}} + for i := range 20 { + out.times = append(out.times, time.Date(2026, 10, 5, 9, 0, i*5, 0, time.UTC)) + out.metrics[0].Samples = append(out.metrics[0].Samples, 40+float64(i%7)*3) + out.metrics[1].Samples = append(out.metrics[1].Samples, 3.2+float64(i)*0.02) + } + return out + }) + if mujica.Button(c, "Append sample", mujica.ButtonOptions{}).Clicked() { + st.times = append(append([]time.Time(nil), st.times...), st.times[len(st.times)-1].Add(5*time.Second)) + for i := range st.metrics { + last := st.metrics[i].Samples[len(st.metrics[i].Samples)-1] + st.metrics[i].Samples = append(append([]float64(nil), st.metrics[i].Samples...), last+0.1) + } + log("sample appended") + } + devPreview(c, s, func() { + times, metrics := st.times, st.metrics + if s.empty { + times, metrics = nil, nil + } + if mujica.SystemMonitor(c, times, metrics, mujica.SystemMonitorOptions{Status: s.status, Error: "Agent offline"}).Changed() { + log("retry requested") + } + }) + }) +} + +func devRefreshIntervalSelectorDemo(c *ui.Context, log func(string)) { + dtSection(c, "RefreshIntervalSelector", func() { + s := devControls(c, "451", log, false) + interval := ui.Local(c.Root(), "451", func() time.Duration { return 0 }) + refreshing := ui.Local(c.Root(), "451-refreshing", func() bool { return false }) + if mujica.Checkbox(c, refreshing, "Refreshing", mujica.CheckboxOptions{}).Changed() { + log("refresh state changed") + } + devPreview(c, s, func() { + r := mujica.RefreshIntervalSelector(c, interval, mujica.RefreshIntervalSelectorOptions{Refreshing: *refreshing}) + if r.Refresh { + log("refresh requested") + } + if r.Changed { + log("interval changed") + } + }) + }) +} + +func devDashboardFilterBarDemo(c *ui.Context, log func(string)) { + dtSection(c, "DashboardFilterBar", func() { + s := devControls(c, "452", log, false) + filters := ui.Local(c.Root(), "452", func() mujica.DashboardFilters { return mujica.DashboardFilters{} }) + devPreview(c, s, func() { + opts := mujica.DashboardFilterBarOptions{Envs: []string{"production", "staging"}, Tags: []string{"api", "db", "web"}} + if s.empty { + opts.Envs, opts.Tags = nil, nil + *filters = mujica.DashboardFilters{} + } + if mujica.DashboardFilterBar(c, filters, opts).Changed() { + log("filters changed") + } + }) + }) +} diff --git a/examples/gallery/pages_devtools_test.go b/examples/gallery/pages_devtools_test.go new file mode 100644 index 0000000..c010b2b --- /dev/null +++ b/examples/gallery/pages_devtools_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func devPageTester(p page, mode mujica.Mode, events *[]string) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + mujica.Use(c, mujica.Settings{Mode: mode}) + ui.Column(c).Padding(16).Children(func() { p.Demo(c, func(s string) { *events = append(*events, s) }) }) + }, 1000, 800) +} + +func TestDevtoolsPagesControlsAndThemes(t *testing.T) { + if len(devtoolsPages) != 17 { + t.Fatalf("pages=%d", len(devtoolsPages)) + } + for _, p := range devtoolsPages { + t.Run(p.Name, func(t *testing.T) { + for _, mode := range []mujica.Mode{mujica.Light, mujica.Dark} { + var events []string + tt := devPageTester(p, mode, &events) + if p.Category != "Dashboards & Dev Tools" || p.Code == "" || p.Keys == "" { + t.Fatal("page metadata") + } + if err := tt.Click("Disabled"); err != nil { + t.Fatal(err) + } + if len(events) != 1 || events[0] != "disabled changed" { + t.Fatalf("control events %v", events) + } + if err := tt.Click("Disabled"); err != nil { + t.Fatal(err) + } + if _, ok := tt.Find("Empty data"); ok { + if err := tt.Click("Empty data"); err != nil { + t.Fatal(err) + } + if events[len(events)-1] != "data state changed" { + t.Fatal("empty event") + } + if err := tt.Click("Empty data"); err != nil { + t.Fatal(err) + } + } + } + }) + } +} + +func TestDevtoolsDemoEvents(t *testing.T) { + cases := []struct{ id, label, event string }{ + {"443", "Acknowledge", "acknowledged cpu"}, + {"447", "Append line", "line appended"}, + {"450", "Append sample", "sample appended"}, + {"451", "Refresh", "refresh requested"}, + {"453", "Copy path", "path copied"}, + {"454", "Format", "JSON edited; valid=true"}, + {"456", "Send", "send requested"}, + {"458", "Add row", "pairs edited"}, + } + for _, tc := range cases { + t.Run(tc.id, func(t *testing.T) { + for _, p := range devtoolsPages { + if p.ID != tc.id { + continue + } + var events []string + tt := devPageTester(p, mujica.Light, &events) + if err := tt.Click(tc.label); err != nil { + t.Fatal(err) + } + found := false + for _, event := range events { + found = found || event == tc.event + } + if !found { + t.Fatalf("events %v want %s", events, tc.event) + } + } + }) + } +} diff --git a/incident_card.go b/incident_card.go index efdf3c1..4491f8f 100644 --- a/incident_card.go +++ b/incident_card.go @@ -10,7 +10,7 @@ import ( "mujicaui/theme" ) -// IncidentStatus is where an incident stands. The zero value is IncidentInvestigating. +// IncidentStatus tracks investigation; zero means IncidentInvestigating. type IncidentStatus int const ( @@ -20,7 +20,7 @@ const ( IncidentResolved ) -// IncidentImpact is how badly users are affected. The zero value is ImpactNone. +// IncidentImpact tracks severity; zero means ImpactNone. type IncidentImpact int const ( @@ -75,8 +75,8 @@ func IncidentCard(c *ui.Context, inc Incident) *ui.Element { 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) + if len(inc.Updates) > 0 { + agentRows(c, len(inc.Updates), func(i int) { incidentUpdate(c, k, inc.Updates[i], i == len(inc.Updates)-1) }).Shrink(1).MaxHeight(320) } }) return card diff --git a/incident_card_test.go b/incident_card_test.go index 98fa5f1..f352a00 100644 --- a/incident_card_test.go +++ b/incident_card_test.go @@ -53,3 +53,14 @@ func TestIncidentCardSnapshot(t *testing.T) { ui.Column(c).Padding(16).Children(func() { IncidentCard(c, incidentSample()) }) }) } + +func TestIncidentCardManyUpdates(t *testing.T) { + inc := Incident{Title: "Recovery", Updates: make([]IncidentUpdate, 100000)} + for i := range inc.Updates { + inc.Updates[i].Text = "Update received" + } + tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); IncidentCard(c, inc) }, 560, 500) + if n := len(tt.Texts()); n > 500 { + t.Fatalf("built %d texts for 100000 updates", n) + } +} diff --git a/json_editor.go b/json_editor.go index eb66d78..0265b0b 100644 --- a/json_editor.go +++ b/json_editor.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log/slog" "strings" @@ -19,7 +20,7 @@ import ( type JsonEditorOptions struct { // Label names the editor; empty uses "JSON". Label string - // MinHeight and MaxHeight bound the text; zero means 160 and 400. + // MinHeight and MaxHeight default to 160 and 400 DIPs. MinHeight, MaxHeight float32 Disabled bool } @@ -30,7 +31,7 @@ type JsonEditorResult struct { 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 and Column locate errors; valid documents use zero. Line, Column int } @@ -40,7 +41,7 @@ type jsonEditorCheck struct { line, column int } -// JsonEditor edits JSON text, formats it and points at the line and column of a parse error. +// JsonEditor edits and formats JSON with error locations. 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") @@ -56,6 +57,9 @@ func JsonEditor(c *ui.Context, text *string, opts JsonEditorOptions) JsonEditorR if maxH == 0 { maxH = 400 } + if minH > maxH { + panic("mujica: JsonEditor MinHeight exceeds MaxHeight") + } k := Tokens(c) u := Density(c).Unit() var r JsonEditorResult @@ -101,11 +105,20 @@ func jsonEditorValidate(chk *jsonEditorCheck, text string) { } chk.src, chk.err, chk.line, chk.column = text, "", 0, 0 dec := json.NewDecoder(strings.NewReader(text)) + dec.UseNumber() var v any err := dec.Decode(&v) - offset := dec.InputOffset() - if err == nil && dec.More() { - err = errors.New("unexpected data after the value") + var offset int64 + if err == nil { + rest := text[dec.InputOffset():] + offset = dec.InputOffset() + int64(len(rest)-len(strings.TrimLeft(rest, " \t\r\n"))) + 1 + var trailing any + if next := dec.Decode(&trailing); next != io.EOF { + err = errors.New("unexpected data after the value") + if next != nil { + err = next + } + } } if err == nil { return @@ -120,7 +133,9 @@ func jsonEditorValidate(chk *jsonEditorCheck, text string) { case strings.TrimSpace(text) == "": err, offset = errors.New("empty document"), 0 default: - offset = int64(len(text)) + if offset == 0 { + offset = int64(len(text)) + } } before := text[:min(int(offset), len(text))] chk.line = strings.Count(before, "\n") + 1 @@ -129,7 +144,7 @@ func jsonEditorValidate(chk *jsonEditorCheck, text string) { chk.err = fmt.Sprintf("%d:%d %v", chk.line, chk.column, err) } -// jsonEditorExcerpt shows the error line with a caret under the column. +// jsonEditorExcerpt shows an error line and column caret. func jsonEditorExcerpt(c *ui.Context, line string, n, col int) { k := Tokens(c) u := Density(c).Unit() diff --git a/json_editor_test.go b/json_editor_test.go index ebed49e..f4f0933 100644 --- a/json_editor_test.go +++ b/json_editor_test.go @@ -73,3 +73,63 @@ func TestJsonEditorSnapshot(t *testing.T) { ui.Column(c).Padding(16).Children(func() { JsonEditor(c, &text, JsonEditorOptions{MinHeight: 120}) }) }) } + +func TestJsonEditorRejectsTrailingDelimiter(t *testing.T) { + for _, text := range []string{"{} ]", "1 }", "true ]"} { + var r JsonEditorResult + jsonEditorTester(&text, JsonEditorOptions{}, &r) + if r.Valid { + t.Fatalf("accepted trailing delimiter: %q", text) + } + } +} + +func TestJsonEditorHeightBounds(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("reversed heights accepted") + } + }() + text := "{}" + var r JsonEditorResult + jsonEditorTester(&text, JsonEditorOptions{MinHeight: 300, MaxHeight: 100}, &r) +} + +func TestJsonEditorAncestorDisabledFormat(t *testing.T) { + text := `{"a":1}` + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { JsonEditor(c, &text, JsonEditorOptions{}) }) + }, 600, 460) + tt.Key(0, ui.KeyTab) + disabled = true + tt.Key(0, ui.KeyEnter) + _ = tt.Click("JSON") + tt.Type("x") + if text != `{"a":1}` { + t.Fatal("disabled editor changed") + } +} + +func TestJsonEditorTrailingValueLocation(t *testing.T) { + text := "{}\n{}" + var result JsonEditorResult + jsonEditorTester(&text, JsonEditorOptions{}, &result) + if result.Valid || result.Line != 2 || result.Column != 1 { + t.Fatalf("trailing value at %d:%d", result.Line, result.Column) + } +} + +func TestJsonEditorPreservesLargeNumbers(t *testing.T) { + text := `{"value":1e309}` + var result JsonEditorResult + tt := jsonEditorTester(&text, JsonEditorOptions{}, &result) + if !result.Valid { + t.Fatal("valid large JSON number rejected") + } + _ = tt.Click("Format") + if text != "{\n \"value\": 1e309\n}" { + t.Fatal("large number changed by formatting") + } +} diff --git a/json_viewer.go b/json_viewer.go index 0a847c6..5ac4d89 100644 --- a/json_viewer.go +++ b/json_viewer.go @@ -45,7 +45,7 @@ type JsonViewerResult struct { Copied string } -// JsonViewer shows JSON as a tree with folding, path copy and search, virtualized. +// JsonViewer shows searchable JSON trees with folding and path copying. 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") @@ -66,6 +66,11 @@ func JsonViewer(c *ui.Context, data string, s *JsonViewerState, opts JsonViewerO u := Density(c).Unit() var r JsonViewerResult r.Element = codePanel(c).Height(h) + if r.Element.IsDisabled() { + for _, key := range []ui.Key{ui.KeyDown, ui.KeyUp, ui.KeyHome, ui.KeyEnd, ui.KeyPageDown, ui.KeyPageUp} { + r.Element.Shortcut(0, key) + } + } 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() { @@ -103,10 +108,10 @@ func JsonViewer(c *ui.Context, data string, s *JsonViewerState, opts JsonViewerO 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 { + if !list.IsDisabled() && at != was && at >= 0 { s.sel = s.rows[at] } - if at >= 0 && at < len(s.rows) { + if !list.IsDisabled() && 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] @@ -117,7 +122,7 @@ func JsonViewer(c *ui.Context, data string, s *JsonViewerState, opts JsonViewerO return r } -// jsonViewerSearch finds matches and opens their ancestors when the query changes. +// jsonViewerSearch finds matches and opens their ancestors. func jsonViewerSearch(s *JsonViewerState) { if s.searched == s.Query { return @@ -142,7 +147,7 @@ func jsonViewerSearch(s *JsonViewerState) { s.stale = true } -// jsonViewerRows lists the visible nodes, skipping closed subtrees, when anything changed. +// jsonViewerRows rebuilds visible indices when nodes change. func jsonViewerRows(s *JsonViewerState) { if !s.stale { return @@ -167,7 +172,7 @@ func jsonViewerRows(s *JsonViewerState) { } } -// jsonViewerRow shows one node: fold mark, key, and value or size. +// jsonViewerRow shows disclosure, key and value. func jsonViewerRow(c *ui.Context, s *JsonViewerState, i int) { k := Tokens(c) n := s.tree.nodes[i] @@ -180,7 +185,7 @@ func jsonViewerRow(c *ui.Context, s *JsonViewerState, i int) { } 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() { + if !mark.IsDisabled() && mark.Clicked() { s.open[n.path], s.stale = !s.open[n.path], true } } diff --git a/json_viewer_test.go b/json_viewer_test.go index 8ba2c81..5f9fc68 100644 --- a/json_viewer_test.go +++ b/json_viewer_test.go @@ -117,3 +117,48 @@ func TestJsonViewerSnapshot(t *testing.T) { }) }) } + +func TestJsonViewerAncestorDisabledKeepsTreeAndClipboard(t *testing.T) { + data := jsonSample + var s JsonViewerState + disabled := false + copied := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + if JsonViewer(c, data, &s, JsonViewerOptions{}).Copied != "" { + copied++ + } + }) + }, 640, 460) + if err := tt.Click("ball: { 3 items }"); err != nil { + t.Fatal(err) + } + disabled = true + tt.Key(0, ui.KeyRight) + _ = tt.Click("$.ball") + _ = tt.Click("Copy path") + if s.open["$.ball"] || copied != 0 || tt.Clipboard() != "" { + t.Fatal("disabled tree input") + } +} + +func TestJsonViewerAncestorDisabledSelection(t *testing.T) { + var state JsonViewerState + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + JsonViewer(c, `{"a":1,"b":2}`, &state, JsonViewerOptions{}) + }) + }, 640, 460) + if err := tt.Click("a: 1"); err != nil { + t.Fatal(err) + } + before := state.sel + disabled = true + tt.Key(0, ui.KeyDown) + if state.sel != before { + t.Fatal("disabled JSON selection changed") + } +} diff --git a/key_value_input.go b/key_value_input.go index 7a0beca..3c94780 100644 --- a/key_value_input.go +++ b/key_value_input.go @@ -3,13 +3,14 @@ package mujica import ( "fmt" "log/slog" + "slices" "github.com/egoist/mygo/ui" "mujicaui/icons" ) -// KeyValue is one editable pair; disabled pairs are kept but not used. +// KeyValue holds an editable pair and enable flag. type KeyValue struct { Key, Value string Enabled bool @@ -22,11 +23,13 @@ type KeyValueInputOptions struct { // Label names the editor for assistive technology. Label string Disabled bool + // MaxHeight caps the rows; zero means 320. + MaxHeight float32 } -// KeyValueInput edits rows of pairs with an enable switch, add and remove. +// KeyValueInput edits virtualized pairs with enable, add and remove. func KeyValueInput(c *ui.Context, rows *[]KeyValue, opts KeyValueInputOptions) ChoiceResult { - if rows == nil { + if rows == nil || opts.MaxHeight < 0 { panic("mujica: KeyValueInput needs rows") } kp, vp := opts.KeyPlaceholder, opts.ValuePlaceholder @@ -40,9 +43,17 @@ func KeyValueInput(c *ui.Context, rows *[]KeyValue, opts KeyValueInputOptions) C var r ChoiceResult r.Element = ui.Column(c).Shrink(0).Gap(u * 2).Disabled(opts.Disabled).Label(opts.Label) locked := r.Element.IsDisabled() + if locked { + copy := slices.Clone(*rows) + rows = © + } + maxH := opts.MaxHeight + if maxH == 0 { + maxH = 320 + } remove := -1 r.Element.Children(func() { - for i := range *rows { + agentRows(c, len(*rows), func(i int) { 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) @@ -60,7 +71,7 @@ func KeyValueInput(c *ui.Context, rows *[]KeyValue, opts KeyValueInputOptions) C remove = i } }) - } + }).Shrink(1).MaxHeight(maxH) 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 diff --git a/key_value_input_test.go b/key_value_input_test.go index c100bb8..491cdc6 100644 --- a/key_value_input_test.go +++ b/key_value_input_test.go @@ -63,3 +63,29 @@ func TestKeyValueInputSnapshot(t *testing.T) { ui.Column(c).Padding(16).Children(func() { KeyValueInput(c, &rows, KeyValueInputOptions{}) }) }) } + +func TestKeyValueInputManyRows(t *testing.T) { + rows := make([]KeyValue, 100000) + for i := range rows { + rows[i] = KeyValue{Key: "item", Value: "value", Enabled: true} + } + n := 0 + tt := kvTester(&rows, KeyValueInputOptions{}, &n) + if got := len(tt.Texts()); got > 500 { + t.Fatalf("built %d texts for 100000 rows", got) + } +} + +func TestKeyValueInputAncestorDisabledAndEmpty(t *testing.T) { + var rows []KeyValue + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(true).Children(func() { KeyValueInput(c, &rows, KeyValueInputOptions{}) }) + }, 600, 300) + _ = tt.Click("Add row") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if len(rows) != 0 { + t.Fatal("disabled empty editor changed") + } +} diff --git a/log_stream.go b/log_stream.go index a50368f..77f725d 100644 --- a/log_stream.go +++ b/log_stream.go @@ -14,12 +14,14 @@ type LogStreamState struct { Paused bool // MinLevel hides lines below it. MinLevel LogLevel - // frozen is the line count at pause; lines after it wait. + // frozen holds the paused line count; later lines wait. frozen int shown []int scanned int min LogLevel rows ui.ListState + src []LogLine + started bool } // LogStreamOptions configure a LogStream. @@ -29,7 +31,7 @@ type LogStreamOptions struct { Disabled bool } -// LogStream shows live ANSI log lines that follow the end, pause and filter by level, virtualized. +// LogStream shows virtualized ANSI logs with pause and level filters. func LogStream(c *ui.Context, lines []LogLine, s *LogStreamState, opts LogStreamOptions) ChoiceResult { if s == nil { panic("mujica: LogStream needs a state") @@ -44,6 +46,9 @@ func LogStream(c *ui.Context, lines []LogLine, s *LogStreamState, opts LogStream var r ChoiceResult r.Element = codePanel(c).Disabled(opts.Disabled).Height(h) locked := r.Element.IsDisabled() + if !s.started { + s.frozen, s.started = len(lines), true + } r.Element.Children(func() { codeHeader(c, func() { was := s.Paused @@ -89,9 +94,9 @@ func LogStream(c *ui.Context, lines []LogLine, s *LogStreamState, opts LogStream return r } -// logStreamFilter extends the shown lines, or redoes them after a filter change. +// logStreamFilter updates filtered indices when lines or filters change. func logStreamFilter(lines []LogLine, s *LogStreamState) { - if s.min != s.MinLevel || s.scanned > len(lines) { + if s.min != s.MinLevel || s.scanned > len(lines) || len(lines) > 0 && (len(s.src) == 0 || &lines[0] != &s.src[0]) { s.min, s.scanned, s.shown = s.MinLevel, 0, s.shown[:0] } for i := s.scanned; i < len(lines); i++ { @@ -103,7 +108,7 @@ func logStreamFilter(lines []LogLine, s *LogStreamState) { s.shown = append(s.shown, i) } } - s.scanned = len(lines) + s.scanned, s.src = len(lines), lines } // logStreamRow shows one line: time, level word and ANSI text. @@ -111,7 +116,7 @@ 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() { + ui.Row(c).MinHeight(d.ControlHeight()).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) } diff --git a/log_stream_test.go b/log_stream_test.go index 782c686..f8f1adb 100644 --- a/log_stream_test.go +++ b/log_stream_test.go @@ -95,3 +95,50 @@ func TestLogStreamSnapshot(t *testing.T) { ui.Column(c).Padding(16).Children(func() { LogStream(c, lines, &s, LogStreamOptions{Height: 220}) }) }) } + +func TestLogStreamAncestorDisabledAndEmptyFilter(t *testing.T) { + lines := []LogLine{{Level: LogInfo, Text: "ready"}} + var s LogStreamState + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { LogStream(c, lines, &s, LogStreamOptions{}) }) + }, 700, 400) + _ = tt.Click("WARN") + if !tt.HasText("No log lines") || s.MinLevel != LogWarn { + t.Fatal("empty level filter") + } + disabled = true + tt.Key(0, ui.KeyRight) + _ = tt.Click("Pause") + if s.MinLevel != LogWarn || s.Paused { + t.Fatal("disabled stream changed") + } +} + +func TestLogStreamReplacedLevels(t *testing.T) { + lines := []LogLine{{Level: LogInfo, Text: "ready"}} + s := LogStreamState{MinLevel: LogWarn} + changes := 0 + tt := logStreamTester(&lines, &s, LogStreamOptions{}, &changes) + lines = []LogLine{{Level: LogError, Text: "failed"}} + tt.Frame() + if !tt.HasText("failed") || tt.HasText("No log lines") { + t.Fatal("replaced log levels retained old filter") + } +} + +func TestLogStreamInitiallyPausedKeepsCurrentLines(t *testing.T) { + lines := logStreamLines(4) + state := LogStreamState{Paused: true} + changes := 0 + tt := logStreamTester(&lines, &state, LogStreamOptions{}, &changes) + if !tt.HasText("line 3") { + t.Fatal("initial pause hid existing lines") + } + lines = append(lines, LogLine{Level: LogInfo, Text: "later"}) + tt.Frame() + if tt.HasText("later") || !tt.HasText("1 new lines") { + t.Fatal("initial pause did not freeze lines") + } +} diff --git a/messages_devtools.go b/messages_devtools.go index 1b8a9e1..b1caaeb 100644 --- a/messages_devtools.go +++ b/messages_devtools.go @@ -8,8 +8,9 @@ func init() { "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 前"}, + "system_monitor.empty": {"No resources", "没有资源"}, + "stat_card.change": {"Change %s", "变化 %s"}, + "devtools.ago": {"%s ago", "%s 前"}, "alert_list.info": {"Info", "提示"}, "alert_list.warning": {"Warning", "警告"}, @@ -43,6 +44,7 @@ func init() { "uptime_bar.down": {"Outage", "中断"}, "uptime_bar.nodata": {"No data", "无数据"}, + "resource_gauge.empty": {"No resources", "没有资源"}, "resource_gauge.normal": {"Normal", "正常"}, "resource_gauge.high": {"High", "偏高"}, "resource_gauge.critical": {"Critical", "危险"}, @@ -95,6 +97,7 @@ func init() { "schema_tree.column": {"Column", "列"}, "schema_tree.index": {"Index", "索引"}, + "api_request_builder.invalid_url": {"Enter a valid HTTP or HTTPS URL", "请输入有效的 HTTP 或 HTTPS 地址"}, "api_request_builder.method": {"Method", "方法"}, "api_request_builder.url": {"Request URL", "请求地址"}, "api_request_builder.send": {"Send", "发送"}, @@ -106,6 +109,13 @@ func init() { "api_request_builder.no_body": {"GET and HEAD requests usually carry no body.", "GET 与 HEAD 请求通常不带正文。"}, "api_request_builder.parts": {"Request parts", "请求组成"}, + "response_viewer.label": {"Response", "响应"}, + "response_viewer.none": {"Send a request to see its response", "发送请求后在此查看响应"}, + "response_viewer.body": {"Body", "正文"}, + "response_viewer.empty": {"Empty body", "正文为空"}, + "response_viewer.headers": {"Headers (%d)", "响应头(%d)"}, + "response_viewer.no_headers": {"No headers", "没有响应头"}, + "alert_list.empty": {"No alerts", "没有告警"}, }) } diff --git a/refresh_interval_selector.go b/refresh_interval_selector.go index 5839c59..af7e12a 100644 --- a/refresh_interval_selector.go +++ b/refresh_interval_selector.go @@ -12,7 +12,7 @@ import ( // 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 defaults to 5s, 10s, 30s, 1m and 5m. Intervals []time.Duration // Refreshing shows the refresh in progress and holds the timer. Refreshing bool @@ -22,7 +22,7 @@ type RefreshIntervalSelectorOptions struct { // 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 requests caller loading, manually or after the interval. Refresh bool // Changed reports a new interval. Changed bool @@ -32,7 +32,7 @@ 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. +// RefreshIntervalSelector selects timed refresh intervals and reports manual requests. func RefreshIntervalSelector(c *ui.Context, interval *time.Duration, opts RefreshIntervalSelectorOptions) RefreshResult { if interval == nil { panic("mujica: RefreshIntervalSelector needs an interval") @@ -43,10 +43,12 @@ func RefreshIntervalSelector(c *ui.Context, interval *time.Duration, opts Refres } options := []SelectOption[time.Duration]{{Value: 0, Label: msg(c, "refresh_interval_selector.off")}} known := *interval == 0 + seen := map[time.Duration]bool{} for _, d := range choices { - if d < time.Second || d%time.Second != 0 { + if d < time.Second || d%time.Second != 0 || seen[d] { panic("mujica: RefreshIntervalSelector intervals must be whole seconds") } + seen[d] = true known = known || d == *interval label := fmt.Sprintf(msg(c, "refresh_interval_selector.seconds"), int(d/time.Second)) if d%time.Minute == 0 { diff --git a/refresh_interval_selector_test.go b/refresh_interval_selector_test.go index c456eb6..1d642b8 100644 --- a/refresh_interval_selector_test.go +++ b/refresh_interval_selector_test.go @@ -84,3 +84,45 @@ func TestRefreshIntervalSelectorSnapshot(t *testing.T) { }) }) } + +func TestRefreshIntervalSelectorKeyboardAncestorDisable(t *testing.T) { + interval := time.Duration(0) + disabled := false + changes, refreshes := 0, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + r := RefreshIntervalSelector(c, &interval, RefreshIntervalSelectorOptions{}) + if r.Changed { + changes++ + } + if r.Refresh { + refreshes++ + } + }) + }, 500, 400) + _ = tt.Click("Refresh interval") + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if interval != 5*time.Second || changes != 1 { + t.Fatal("keyboard interval") + } + _ = tt.Click("Refresh interval") + disabled = true + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeyEnter) + if interval != 5*time.Second || changes != 1 || refreshes != 0 || tt.HasText("Every 10 s") { + t.Fatal("disabled popup remained active") + } +} + +func TestRefreshIntervalSelectorRejectsDuplicateIntervals(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("duplicate intervals accepted") + } + }() + interval := time.Duration(0) + refreshes, changes := 0, 0 + refreshTester(&interval, &RefreshIntervalSelectorOptions{Intervals: []time.Duration{time.Second, time.Second}}, &refreshes, &changes) +} diff --git a/resource_gauge.go b/resource_gauge.go index 14a6f54..f6fc08f 100644 --- a/resource_gauge.go +++ b/resource_gauge.go @@ -2,6 +2,7 @@ package mujica import ( "fmt" + "log/slog" "math" "github.com/egoist/mygo/ui" @@ -22,10 +23,15 @@ type ResourceGaugeOptions struct { Warn, Critical float64 // Height of each dial; zero means 150. Height float32 + Status DataStatus + Error string } -// 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 { +// ResourceGauge shows resource usage with named threshold bands. +func ResourceGauge(c *ui.Context, readings []ResourceReading, opts ResourceGaugeOptions) ChoiceResult { + if opts.Status != DataReady && opts.Status != DataLoading && opts.Status != DataFailed { + panic("mujica: ResourceGauge needs a known status") + } warn, crit := opts.Warn, opts.Critical if warn == 0 { warn = 0.7 @@ -42,16 +48,23 @@ func ResourceGauge(c *ui.Context, readings []ResourceReading, opts ResourceGauge } u := Density(c).Unit() row := ui.Row(c).Wrap().Gap(u * 4).Shrink(0) + result := ChoiceResult{Element: row} row.Children(func() { + if len(readings) == 0 { + ui.Column(c).Height(h).Children(func() { + result.changed = dataPlaceholder(c, opts.Status, opts.Error, msg(c, "resource_gauge.empty"), "") + }) + return + } for i, r := range readings { - if r.Name == "" || !(r.Total > 0) || r.Used < 0 || r.Used > r.Total || math.IsNaN(r.Used) { + if r.Name == "" || !(r.Total > 0) || r.Used < 0 || r.Used > r.Total || math.IsNaN(r.Used) || math.IsInf(r.Used, 0) || math.IsInf(r.Total, 0) { 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: "%", + gauge := Gauge(c, frac*100, GaugeOptions{ + Frame: ChartFrame{Label: r.Name, Height: h, Status: opts.Status, Error: opts.Error}, 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}, @@ -59,11 +72,15 @@ func ResourceGauge(c *ui.Context, readings []ResourceReading, opts ResourceGauge {To: 100, Label: msg(c, "resource_gauge.critical"), Tone: GaugeDanger}, }, }) + result.changed = result.changed || gauge.Retried() if r.Unit != "" { agentCaption(c, fmt.Sprintf("%s / %s %s", sandboxNum(r.Used), sandboxNum(r.Total), r.Unit)) } }) } }) - return row + if result.changed { + slog.Debug("mujica: resource_gauge retry") + } + return result } diff --git a/resource_gauge_test.go b/resource_gauge_test.go index bc03728..38d4501 100644 --- a/resource_gauge_test.go +++ b/resource_gauge_test.go @@ -1,6 +1,7 @@ package mujica import ( + "math" "testing" "github.com/egoist/mygo/ui" @@ -54,3 +55,49 @@ func TestResourceGaugeSnapshot(t *testing.T) { ui.Column(c).Padding(16).Children(func() { ResourceGauge(c, resourceSample, ResourceGaugeOptions{}) }) }) } + +func TestResourceGaugeRejectsInfiniteTotal(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("infinite capacity accepted") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ResourceGauge(c, []ResourceReading{{Name: "CPU", Total: math.Inf(1)}}, ResourceGaugeOptions{}) + }, 400, 300) +} + +func TestResourceGaugeEmptyState(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); ResourceGauge(c, nil, ResourceGaugeOptions{}) }, 600, 300) + if !tt.HasText("No resources") { + t.Fatal("empty resource state") + } +} + +func TestResourceGaugeLoadingErrorAndDisabledRetry(t *testing.T) { + status, disabled := DataLoading, false + retries := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + if ResourceGauge(c, resourceSample[:1], ResourceGaugeOptions{Status: status, Error: "offline"}).Changed() { + retries++ + } + }) + }, 400, 300) + if !tt.HasText("Loading") { + t.Fatal("loading resource") + } + status = DataFailed + tt.Frame() + _ = tt.Click("Retry") + if retries != 1 || !tt.HasText("offline") { + t.Fatal("error retry") + } + disabled = true + tt.Key(0, ui.KeyEnter) + if retries != 1 { + t.Fatal("disabled resource retry") + } +} diff --git a/response_viewer.go b/response_viewer.go new file mode 100644 index 0000000..f5e728b --- /dev/null +++ b/response_viewer.go @@ -0,0 +1,127 @@ +package mujica + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// ApiResponse is an HTTP response the caller received. +type ApiResponse struct { + Status int + Duration time.Duration + // Size is the body size in bytes. + Size int64 + Headers []KeyValue + Body string +} + +// ResponseViewerOptions configure a ResponseViewer. +type ResponseViewerOptions struct { + // Status replaces the response while loading or failed. + Status DataStatus + Error string + // Height of the body; zero means 320. + Height float32 +} + +type responseViewerState struct { + tab int + json JsonViewerState + body string + isJSON bool +} + +// ResponseViewer shows response metadata, body and headers. +func ResponseViewer(c *ui.Context, resp *ApiResponse, opts ResponseViewerOptions) ChoiceResult { + if opts.Status != DataReady && opts.Status != DataLoading && opts.Status != DataFailed { + panic("mujica: ResponseViewer needs a known status") + } + if opts.Height < 0 || resp != nil && (resp.Status < 100 || resp.Status > 599 || resp.Size < 0 || resp.Duration < 0) { + panic("mujica: ResponseViewer needs Height >= 0, a 1xx–5xx Status and Size >= 0") + } + h := opts.Height + if h == 0 { + h = 320 + } + u := Density(c).Unit() + var r ChoiceResult + r.Element = ui.Column(c).Shrink(0).Gap(u * 3).Label(msg(c, "response_viewer.label")) + st := ui.Local(r.Element, "mujica.response", func() responseViewerState { return responseViewerState{} }) + r.Element.Children(func() { + if opts.Status != DataReady || resp == nil { + ui.Column(c).Height(h).Children(func() { + r.changed = dataPlaceholder(c, opts.Status, opts.Error, msg(c, "response_viewer.none"), "") + }) + return + } + ui.Row(c).Gap(u * 4).AlignItems(ui.Center).Role(ui.RoleStatus).Children(func() { + responseStatus(c, resp.Status) + agentCaption(c, agentDuration(resp.Duration)) + agentCaption(c, processListBytes(uint64(resp.Size))) + }) + if st.body != resp.Body { + st.body, st.isJSON = resp.Body, json.Valid([]byte(resp.Body)) + } + tabs := []Tab{ + {Label: msg(c, "response_viewer.body"), Panel: func() { + ui.Column(c).Height(h).Children(func() { + switch { + case resp.Body == "": + agentCaption(c, msg(c, "response_viewer.empty")) + case st.isJSON: + JsonViewer(c, resp.Body, &st.json, JsonViewerOptions{Height: h}) + default: + agentCode(c, "", resp.Body, h) + } + }) + }}, + {Label: fmt.Sprintf(msg(c, "response_viewer.headers"), len(resp.Headers)), Panel: func() { + ui.Column(c).Height(h).Children(func() { responseHeaders(c, resp.Headers, h) }) + }}, + } + Tabs(c, &st.tab, tabs, TabsOptions{Label: msg(c, "response_viewer.label")}) + }) + if r.changed { + slog.Debug("mujica: response_viewer retry") + } + return r +} + +// responseStatus shows "200 OK" with a severity icon and tone. +func responseStatus(c *ui.Context, code int) { + sev := SeveritySuccess + switch { + case code >= 500: + sev = SeverityError + case code >= 400: + sev = SeverityWarning + case code >= 300 || code < 200: + sev = SeverityInfo + } + text := strings.TrimSpace(fmt.Sprintf("%d %s", code, http.StatusText(code))) + devtoolsSeverity(c, sev, text) +} + +// responseHeaders lists headers as name and value rows. +func responseHeaders(c *ui.Context, headers []KeyValue, h float32) { + k := Tokens(c) + if len(headers) == 0 { + agentCaption(c, msg(c, "response_viewer.no_headers")) + return + } + agentWellRows(c, len(headers), func(i int) { + kv := headers[i] + ui.Row(c).MinHeight(Density(c).ControlHeight()).Gap(Density(c).Unit() * 3).Children(func() { + ui.Text(c, kv.Key).TextColor(k.AccentText).FontWeight(600).Width(200).Shrink(0) + ui.Text(c, kv.Value).Selectable().Grow(1).Shrink(1) + }) + }, h).Font(theme.MonoFont) +} diff --git a/response_viewer_test.go b/response_viewer_test.go new file mode 100644 index 0000000..27daf29 --- /dev/null +++ b/response_viewer_test.go @@ -0,0 +1,154 @@ +package mujica + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func responseSample() *ApiResponse { + return &ApiResponse{Status: 201, Duration: 182 * time.Millisecond, Size: 2048, + Headers: []KeyValue{{Key: "Content-Type", Value: "application/json"}, {Key: "X-Trace", Value: "ab12"}}, + Body: `{"id":7,"hall":"east wing"}`} +} + +func responseTester(resp **ApiResponse, opts *ResponseViewerOptions, retries *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ResponseViewer(c, *resp, *opts).Changed() { + *retries++ + } + }, 640, 520) +} + +func TestResponseViewerJsonTextHeaders(t *testing.T) { + resp := responseSample() + opts := ResponseViewerOptions{} + n := 0 + tt := responseTester(&resp, &opts, &n) + for _, s := range []string{"201 Created", "182 ms", "2 KB", `id: 7`} { + if !tt.HasText(s) { + t.Fatalf("missing %q in %q", s, tt.Texts()) + } + } + _ = tt.Click("Headers (2)") + if !tt.HasText("X-Trace") || !tt.HasText("ab12") { + t.Fatalf("headers: %q", tt.Texts()) + } + resp = &ApiResponse{Status: 503, Body: "upstream down"} + tt.Frame() + _ = tt.Click("Body") + if !tt.HasText("503 Service Unavailable") || !tt.HasText("upstream down") { + t.Fatalf("text body: %q", tt.Texts()) + } +} + +func TestResponseViewerStates(t *testing.T) { + var resp *ApiResponse + opts := ResponseViewerOptions{} + n := 0 + tt := responseTester(&resp, &opts, &n) + if !tt.HasText("Send a request to see its response") { + t.Fatalf("none: %q", tt.Texts()) + } + opts = ResponseViewerOptions{Status: DataFailed, Error: "connection refused"} + tt.Frame() + _ = tt.Click("Retry") + if n != 1 || !tt.HasText("connection refused") { + t.Fatalf("failed: %q retries %d", tt.Texts(), n) + } + defer func() { + if recover() == nil { + t.Fatal("status 42 did not panic") + } + }() + bad := &ApiResponse{Status: 42} + responseTester(&bad, &ResponseViewerOptions{}, &n) +} + +func TestResponseViewerSnapshot(t *testing.T) { + snapshot(t, "response_viewer", 620, 360, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + ResponseViewer(c, responseSample(), ResponseViewerOptions{Height: 240}) + }) + }) +} + +func TestResponseViewerKeyboardDisabledAndLoading(t *testing.T) { + resp := responseSample() + disabled := false + retries := 0 + opts := ResponseViewerOptions{} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + if ResponseViewer(c, resp, opts).Changed() { + retries++ + } + }) + }, 640, 520) + if err := tt.Click("Body"); err != nil { + t.Fatal(err) + } + tt.Key(0, ui.KeyRight) + if !tt.HasText("X-Trace") { + t.Fatal("keyboard header selection") + } + opts.Status = DataLoading + tt.Frame() + if !tt.HasText("Loading") || tt.HasText("X-Trace") { + t.Fatal("loading state") + } + opts.Status = DataFailed + opts.Error = "offline" + disabled = true + tt.Frame() + _ = tt.Click("Retry") + tt.Key(0, ui.KeyEnter) + if retries != 0 { + t.Fatal("disabled retry") + } +} + +func TestResponseViewerRejectsUnknownDataStatus(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("unknown data status accepted") + } + }() + resp := responseSample() + n := 0 + responseTester(&resp, &ResponseViewerOptions{Status: DataStatus(99)}, &n) +} + +func TestResponseViewerEmptyBodyAndHeaders(t *testing.T) { + resp := &ApiResponse{Status: 204} + n := 0 + tt := responseTester(&resp, &ResponseViewerOptions{}, &n) + if !tt.HasText("204 No Content") || !tt.HasText("Empty body") { + t.Fatal("empty response") + } + _ = tt.Click("Headers (0)") + if !tt.HasText("No headers") { + t.Fatal("empty headers") + } +} + +func TestResponseViewerAncestorDisabledTabs(t *testing.T) { + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + ResponseViewer(c, responseSample(), ResponseViewerOptions{}) + }) + }, 640, 520) + if err := tt.Click("Body"); err != nil { + t.Fatal(err) + } + disabled = true + tt.Key(0, ui.KeyRight) + if tt.HasText("X-Trace") || !tt.HasText("id: 7") { + t.Fatal("disabled response switched tabs") + } +} diff --git a/schema_tree.go b/schema_tree.go index 7d9cfb1..3dfc50c 100644 --- a/schema_tree.go +++ b/schema_tree.go @@ -10,7 +10,7 @@ import ( "mujicaui/theme" ) -// SchemaKind is what a schema node is. The zero value is SchemaDatabase. +// SchemaKind classifies nodes; zero means SchemaDatabase. type SchemaKind int const ( @@ -21,7 +21,7 @@ const ( SchemaIndex ) -// SchemaNode is one database object; Detail is a column type or index columns. +// SchemaNode describes an object; Detail holds type or index columns. type SchemaNode struct { Name string Kind SchemaKind @@ -39,7 +39,7 @@ type SchemaTreeState struct { src []SchemaNode } -// Selected returns the chosen node's path, as "shop/orders/id", and whether one is chosen. +// Selected returns the chosen path and whether it exists. func (s *SchemaTreeState) Selected() (string, bool) { if s.selected < 0 || s.selected >= s.outline.Rows() { return "", false @@ -53,7 +53,7 @@ type SchemaTreeOptions struct { Height float32 } -// SchemaTree shows databases, tables, views, columns and indexes as a keyboard tree, virtualized. +// SchemaTree shows database objects in a virtualized keyboard tree. 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") @@ -67,6 +67,19 @@ func SchemaTree(c *ui.Context, s *SchemaTreeState, roots []SchemaNode, opts Sche d := Density(c) var r ChoiceResult r.Element = codePanel(c).Height(h).Label(msg(c, "schema_tree.label")) + locked := r.Element.IsDisabled() + if locked { + copy := *s + copy.outline.Open = ui.Selection[string]{} + for path := range s.outline.Open.All() { + copy.outline.Open.Add(path) + } + s = © + for _, mods := range []ui.Modifiers{0, ui.Alt, ui.Shift} { + r.Element.Shortcut(mods, ui.KeyLeft) + r.Element.Shortcut(mods, ui.KeyRight) + } + } r.Element.Children(func() { if len(s.roots) == 0 { codeCaption(c, msg(c, "schema_tree.empty")).Padding(d.Unit() * 4) @@ -75,7 +88,7 @@ func SchemaTree(c *ui.Context, s *SchemaTreeState, roots []SchemaNode, opts Sche 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) + restore := dataSelectTheme(c, theme.MinHitSize/4) 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). @@ -92,7 +105,7 @@ func SchemaTree(c *ui.Context, s *SchemaTreeState, roots []SchemaNode, opts Sche }).Grow(1) restore() internal.Ring(e, k.Focus, theme.ControlRadius) - r.changed = s.selected != was + r.changed = !locked && s.selected != was }) if r.changed { p, _ := s.Selected() @@ -134,6 +147,10 @@ func schemaIndex(s *SchemaTreeState, roots []SchemaNode) { return paths } s.roots = walk(roots, "") + if len(s.roots) == 0 { + s.outline = ui.OutlineState[string]{} + s.selected = -1 + } } func schemaBadge(k theme.Tokens, kind SchemaKind) (string, ui.Color) { diff --git a/schema_tree_test.go b/schema_tree_test.go index 2a487b1..0f55243 100644 --- a/schema_tree_test.go +++ b/schema_tree_test.go @@ -88,3 +88,61 @@ func TestSchemaTreeSnapshot(t *testing.T) { } }) } + +func TestSchemaTreeAncestorDisabled(t *testing.T) { + var s SchemaTreeState + disabled := false + changes := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + if SchemaTree(c, &s, schemaSample, SchemaTreeOptions{}).Changed() { + changes++ + } + }) + }, 520, 420) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + before, _ := s.Selected() + old := changes + disabled = true + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyDown) + if after, _ := s.Selected(); after != before || changes != old || tt.HasText("guests") { + t.Fatalf("disabled schema: before=%q after=%q events=%d old=%d texts=%q", before, after, changes, old, tt.Texts()) + } +} + +func TestSchemaTreeDisclosureHitArea(t *testing.T) { + var state SchemaTreeState + changes := 0 + tt := schemaTester(&state, schemaSample, &changes) + row, ok := tt.Find("court") + if !ok { + t.Fatal("database row missing") + } + tt.ClickAt(row.X+31, row.Y+8) + if !tt.HasText("guests") { + t.Fatal("disclosure missed 28 DIP hit area") + } +} + +func TestSchemaTreeClearsSelectionWhenEmpty(t *testing.T) { + var state SchemaTreeState + roots := schemaSample + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SchemaTree(c, &state, roots, SchemaTreeOptions{}) + }, 520, 420) + if err := tt.Click("court"); err != nil { + t.Fatal(err) + } + if _, ok := state.Selected(); !ok { + t.Fatal("database selection missing") + } + roots = nil + tt.Frame() + if _, ok := state.Selected(); ok || !tt.HasText("No databases") { + t.Fatal("empty schema retained selection") + } +} diff --git a/service_status.go b/service_status.go index fa646b4..95a0454 100644 --- a/service_status.go +++ b/service_status.go @@ -31,19 +31,25 @@ type ServiceStatusOptions struct { MaxHeight float32 } -// ServiceStatus lists services as operational, degraded or down, under an overall summary. +// ServiceStatus lists service health and 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)) + col := ui.Column(c).Shrink(0).Gap(Density(c).Unit() * 2) + summaryState := ui.Local(col, "mujica.services.summary", func() serviceSummary { return serviceSummary{key: "unset"} }) + if key := chartID(services); key != summaryState.key { + 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]++ } - worst = max(worst, s.Health) - counts[s.Health]++ + summaryState.key, summaryState.worst, summaryState.counts = key, worst, counts } + worst, counts := summaryState.worst, summaryState.counts maxH := opts.MaxHeight if maxH == 0 { maxH = 360 @@ -51,7 +57,7 @@ func ServiceStatus(c *ui.Context, services []ServiceItem, opts ServiceStatusOpti 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.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() { @@ -89,3 +95,9 @@ func serviceSeverity(h ServiceHealth) Severity { func serviceWord(c *ui.Context, h ServiceHealth) string { return msg(c, [...]string{"service_status.operational", "service_status.degraded", "service_status.outage"}[h]) } + +type serviceSummary struct { + key string + worst ServiceHealth + counts [3]int +} diff --git a/service_status_test.go b/service_status_test.go index 6d9a07b..14da4c5 100644 --- a/service_status_test.go +++ b/service_status_test.go @@ -55,3 +55,15 @@ func TestServiceStatusSnapshot(t *testing.T) { ui.Column(c).Padding(16).Children(func() { ServiceStatus(c, servicesSample, ServiceStatusOptions{}) }) }) } + +func TestServiceStatusRejectsUnknownHealth(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("unknown service health accepted") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ServiceStatus(c, []ServiceItem{{Name: "API", Health: 99}}, ServiceStatusOptions{}) + }, 400, 300) +} diff --git a/stat_card.go b/stat_card.go index 1a5bc7c..7b9fcec 100644 --- a/stat_card.go +++ b/stat_card.go @@ -14,7 +14,7 @@ import ( // 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 is relative; 0.12 means +12%, shown with HasChange. Change float64 HasChange bool // LowerIsBetter colors a fall as good, as for latency. @@ -25,14 +25,8 @@ type StatCardOptions struct { 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 { +// StatCard shows values, changes, trends, loading and errors. +func StatCard(c *ui.Context, value string, opts StatCardOptions) ChoiceResult { if opts.Label == "" { panic("mujica: StatCard needs a Label") } @@ -44,8 +38,8 @@ func StatCard(c *ui.Context, value string, opts StatCardOptions) StatCardResult } k := Tokens(c) u := Density(c).Unit() - var r StatCardResult - r.Element = agentCard(c).Height(116).MinWidth(180).Label(opts.Label) + var r ChoiceResult + r.Element = agentCard(c).MinHeight(FontSize(c, 116)).MinWidth(180).Label(opts.Label) r.Element.Children(func() { agentCaption(c, opts.Label) switch opts.Status { @@ -60,7 +54,7 @@ func StatCard(c *ui.Context, value string, opts StatCardOptions) StatCardResult 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) }) + ui.Row(c).Children(func() { r.changed = agentToggle(c, msg(c, "list.retry"), "refresh-cw", false) }) }) return } @@ -75,13 +69,13 @@ func StatCard(c *ui.Context, value string, opts StatCardOptions) StatCardResult } }) }) - if r.Retry { + if r.changed { slog.Debug("mujica: stat_card retry", "label", opts.Label) } return r } -// statChange shows a signed percentage with an arrow, toned good or bad. +// statChange shows a signed percentage and direction arrow. func statChange(c *ui.Context, change float64, lowerIsBetter bool) { k := Tokens(c) icon, tone, sign := "minus", k.TextMuted, "" diff --git a/stat_card_test.go b/stat_card_test.go index d843db7..6d83a8a 100644 --- a/stat_card_test.go +++ b/stat_card_test.go @@ -37,7 +37,7 @@ func TestStatCardLoadingErrorSameSize(t *testing.T) { 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 { + if r.Changed() { retries++ } }) @@ -63,7 +63,7 @@ func TestStatCardLoadingErrorSameSize(t *testing.T) { } func TestStatCardSnapshot(t *testing.T) { - snapshot(t, "stat_card", 640, 150, func(c *ui.Context) { + snapshot(t, "stat_card", 720, 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}}) @@ -71,3 +71,42 @@ func TestStatCardSnapshot(t *testing.T) { }) }) } + +func TestStatCardDisabledRetryAndKeyboardTrend(t *testing.T) { + status := DataReady + disabled := false + retries := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + if StatCard(c, "42", StatCardOptions{Label: "CPU", Trend: []float64{1, 4, 2}, Status: status, Error: "offline"}).Changed() { + retries++ + } + }) + }, 400, 200) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + announced := false + for _, value := range tt.Announcements() { + announced = announced || value == "2" + } + if !announced || !tt.HasText("2") { + t.Fatalf("trend boundary readout: %q", tt.Texts()) + } + status, disabled = DataFailed, true + tt.Frame() + _ = tt.Click("Retry") + tt.Key(0, ui.KeyEnter) + if retries != 0 { + t.Fatal("disabled retry") + } +} + +func TestStatCardTextScaleKeepsCardHeight(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); StatCard(c, "42", StatCardOptions{Label: "CPU"}) }, 600, 350) + tt.SetPreferences(ui.Preferences{TextScale: 2, ReduceMotion: true}) + bounds, ok := tt.Find("CPU") + if !ok || bounds.H < 232 { + t.Fatalf("scaled card height %v", bounds.H) + } +} diff --git a/system_monitor.go b/system_monitor.go index 52f2ff2..8b02c4c 100644 --- a/system_monitor.go +++ b/system_monitor.go @@ -2,6 +2,7 @@ package mujica import ( "fmt" + "log/slog" "math" "time" @@ -10,7 +11,7 @@ import ( "mujicaui/theme" ) -// MonitorMetric is one sampled resource; Samples line up with the shared times. +// MonitorMetric holds samples aligned with the shared timestamps. type MonitorMetric struct { Name string Unit string @@ -25,15 +26,9 @@ type SystemMonitorOptions struct { 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 { +// SystemMonitor combines resource charts sampled by the caller. +func SystemMonitor(c *ui.Context, times []time.Time, metrics []MonitorMetric, opts SystemMonitorOptions) ChoiceResult { + if opts.Height < 0 || opts.Status != DataReady && opts.Status != DataLoading && opts.Status != DataFailed { panic("mujica: SystemMonitor Height is negative") } for i, m := range metrics { @@ -47,11 +42,18 @@ func SystemMonitor(c *ui.Context, times []time.Time, metrics []MonitorMetric, op } k := Tokens(c) u := Density(c).Unit() - var r SystemMonitorResult + var r ChoiceResult r.Element = ui.Column(c).Shrink(0).Gap(u * 3) r.Element.Children(func() { + if len(metrics) == 0 { + ui.Column(c).Height(h).Children(func() { r.changed = dataPlaceholder(c, opts.Status, opts.Error, msg(c, "system_monitor.empty"), "") }) + return + } for _, m := range metrics { - ui.Column(c).Key(m.Name).Gap(u).Children(func() { + panel := ui.Column(c).Key(m.Name).Gap(u) + series := ui.Local(panel, "mujica.monitor.series", func() []ChartSeries { return []ChartSeries{{Name: m.Name, Values: m.Samples}} }) + (*series)[0].Name, (*series)[0].Values = m.Name, m.Samples + panel.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() @@ -64,13 +66,16 @@ func SystemMonitor(c *ui.Context, times []time.Time, metrics []MonitorMetric, op } }) unit := m.Unit - res := LineChart(c, []ChartSeries{{Name: m.Name, Values: m.Samples}}, LineChartOptions{ + res := LineChart(c, *series, 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() + r.changed = r.changed || res.Retried() }) } }) + if r.changed { + slog.Debug("mujica: system_monitor retry") + } return r } diff --git a/system_monitor_test.go b/system_monitor_test.go index cbc7edf..3ad5339 100644 --- a/system_monitor_test.go +++ b/system_monitor_test.go @@ -52,7 +52,7 @@ func TestSystemMonitorStatesAndRules(t *testing.T) { 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 { + if SystemMonitor(c, times, metrics[:1], SystemMonitorOptions{Status: DataFailed, Error: "agent offline"}).Changed() { retries++ } }, 600, 300) @@ -76,7 +76,48 @@ func TestSystemMonitorStatesAndRules(t *testing.T) { func TestSystemMonitorSnapshot(t *testing.T) { times, metrics := monitorSample(40) - snapshot(t, "system_monitor", 560, 380, func(c *ui.Context) { + snapshot(t, "system_monitor", 560, 460, func(c *ui.Context) { ui.Column(c).Padding(16).Children(func() { SystemMonitor(c, times, metrics, SystemMonitorOptions{}) }) }) } + +func TestSystemMonitorEmptyLoadingAndDisabled(t *testing.T) { + status := DataReady + retries := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(true).Children(func() { + if SystemMonitor(c, nil, nil, SystemMonitorOptions{Status: status, Error: "offline"}).Changed() { + retries++ + } + }) + }, 600, 300) + if !tt.HasText("No resources") { + t.Fatal("empty resources") + } + status = DataLoading + tt.Frame() + if !tt.HasText("Loading") { + t.Fatal("loading resources") + } + status = DataFailed + tt.Frame() + _ = tt.Click("Retry") + if retries != 0 { + t.Fatal("disabled retry") + } +} + +func TestSystemMonitorManySamples(t *testing.T) { + times, metrics := monitorSample(100000) + tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); SystemMonitor(c, times, metrics[:1], SystemMonitorOptions{}) }, 600, 420) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + if len(tt.Texts()) > 200 { + t.Fatal("unbounded sample elements") + } + if !tt.HasText(times[len(times)-1].Format("15:04:05")) { + t.Fatal("last sample readout") + } +} diff --git a/tabs.go b/tabs.go index 1b89d6d..9dc5441 100644 --- a/tabs.go +++ b/tabs.go @@ -54,6 +54,11 @@ func Tabs(c *ui.Context, selected *int, tabs []Tab, opts TabsOptions) ChoiceResu } } root := ui.Column(c).AlignItems(ui.Stretch) + if root.IsDisabled() { + for _, key := range []ui.Key{ui.KeyRight, ui.KeyDown, ui.KeyLeft, ui.KeyUp, ui.KeyHome, ui.KeyEnd} { + root.Shortcut(0, key) + } + } if opts.Vertical { root.Row() } @@ -96,7 +101,7 @@ func Tabs(c *ui.Context, selected *int, tabs []Tab, opts TabsOptions) ChoiceResu built[i] = tab } }) - if list.Changed() { + if !list.IsDisabled() && list.Changed() { prev := *selected *selected = enabled[at] res.changed = true diff --git a/tabs_test.go b/tabs_test.go index f6beb01..ff9b17f 100644 --- a/tabs_test.go +++ b/tabs_test.go @@ -201,3 +201,26 @@ func tabsMode(c *ui.Context) Mode { } return Light } + +func TestTabsAncestorDisabledNavigation(t *testing.T) { + for _, key := range []ui.Key{ui.KeyRight, ui.KeyDown, ui.KeyEnd} { + selected, changes := 0, 0 + disabled := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + if Tabs(c, &selected, tabsSample(), TabsOptions{}).Changed() { + changes++ + } + }) + }, 500, 200) + if err := tt.Click("General"); err != nil { + t.Fatal(err) + } + disabled = true + tt.Key(0, key) + if selected != 0 || changes != 0 { + t.Fatalf("disabled tab navigation: selected=%d events=%d", selected, changes) + } + } +} diff --git a/testdata/snapshots/darwin/incident_card-dark.png b/testdata/snapshots/darwin/incident_card-dark.png index 464c306..9c606cb 100644 Binary files a/testdata/snapshots/darwin/incident_card-dark.png 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 index 0a9a822..285c9fa 100644 Binary files a/testdata/snapshots/darwin/incident_card-light.png and b/testdata/snapshots/darwin/incident_card-light.png differ diff --git a/testdata/snapshots/darwin/key_value_input-dark.png b/testdata/snapshots/darwin/key_value_input-dark.png index 9a3c8a3..b91ba6a 100644 Binary files a/testdata/snapshots/darwin/key_value_input-dark.png 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 index e89ea5c..601d094 100644 Binary files a/testdata/snapshots/darwin/key_value_input-light.png 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 index 64003df..4823fa4 100644 Binary files a/testdata/snapshots/darwin/log_stream-dark.png 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 index 68839db..7f6462a 100644 Binary files a/testdata/snapshots/darwin/log_stream-light.png and b/testdata/snapshots/darwin/log_stream-light.png differ diff --git a/testdata/snapshots/darwin/response_viewer-dark.png b/testdata/snapshots/darwin/response_viewer-dark.png new file mode 100644 index 0000000..54c8f75 Binary files /dev/null and b/testdata/snapshots/darwin/response_viewer-dark.png differ diff --git a/testdata/snapshots/darwin/response_viewer-light.png b/testdata/snapshots/darwin/response_viewer-light.png new file mode 100644 index 0000000..58db5fa Binary files /dev/null and b/testdata/snapshots/darwin/response_viewer-light.png differ diff --git a/testdata/snapshots/darwin/schema_tree-dark.png b/testdata/snapshots/darwin/schema_tree-dark.png index b11c6b2..bee36d4 100644 Binary files a/testdata/snapshots/darwin/schema_tree-dark.png 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 index f1e1389..0962f6b 100644 Binary files a/testdata/snapshots/darwin/schema_tree-light.png and b/testdata/snapshots/darwin/schema_tree-light.png differ diff --git a/testdata/snapshots/darwin/stat_card-dark.png b/testdata/snapshots/darwin/stat_card-dark.png index 197be7a..7fdf78e 100644 Binary files a/testdata/snapshots/darwin/stat_card-dark.png 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 index 3cc3a9c..bf51e46 100644 Binary files a/testdata/snapshots/darwin/stat_card-light.png 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 index 8123bcf..859b137 100644 Binary files a/testdata/snapshots/darwin/system_monitor-dark.png 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 index f8eabe1..05429c0 100644 Binary files a/testdata/snapshots/darwin/system_monitor-light.png 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 index 637f862..b3bcf4e 100644 Binary files a/testdata/snapshots/darwin/trace_waterfall-dark.png 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 index 314480b..6f41d95 100644 Binary files a/testdata/snapshots/darwin/trace_waterfall-light.png 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 index 03b3e10..bb20022 100644 Binary files a/testdata/snapshots/darwin/uptime_bar-dark.png 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 index d01b930..f2faa05 100644 Binary files a/testdata/snapshots/darwin/uptime_bar-light.png and b/testdata/snapshots/darwin/uptime_bar-light.png differ diff --git a/trace_waterfall.go b/trace_waterfall.go index 5483fa4..82986a4 100644 --- a/trace_waterfall.go +++ b/trace_waterfall.go @@ -14,7 +14,7 @@ import ( "mujicaui/theme" ) -// WaterfallSpan is one timed operation of a request; Parent is "" for the root. +// WaterfallSpan holds timing; empty Parent marks a root. type WaterfallSpan struct { ID, Parent string Name string @@ -44,7 +44,7 @@ type traceState struct { services map[string]int // palette slot of each service } -// TraceWaterfall lays spans out by parent and time; choosing a span shows its detail. +// TraceWaterfall shows timed span hierarchies and selected details. func TraceWaterfall(c *ui.Context, spans []WaterfallSpan, opts TraceWaterfallOptions) ChoiceResult { if opts.Height < 0 { panic("mujica: TraceWaterfall Height is negative") @@ -88,7 +88,7 @@ func TraceWaterfall(c *ui.Context, spans []WaterfallSpan, opts TraceWaterfallOpt return r } -// traceLayout orders spans depth-first by start, only when the spans change. +// traceLayout caches spans in depth-first time order. func traceLayout(st *traceState, spans []WaterfallSpan) { key := fmt.Sprintf("%d", len(spans)) if len(spans) > 0 { @@ -134,16 +134,18 @@ func traceLayout(st *traceState, spans []WaterfallSpan) { } } -// traceSpanRow shows a span's name indented by depth beside its bar; it reports a choice. +// traceSpanRow shows span indentation, timing, selection and duration. func traceSpanRow(c *ui.Context, s WaterfallSpan, 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). + row := ui.ButtonBase(c).Key(s.ID).Justify(ui.Start).Gap(u*2).MinHeight(d.ControlHeight()).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) + row.Background(k.Selection).DrawOver(func(p *ui.Painter, r ui.Rect) { + p.Stroke(ui.Rect{X: r.X + 2, Y: r.Y + 2, W: r.W - 4, H: r.H - 4}, k.Focus, theme.ControlRadius, 1) + }) } else if row.Hovered() { row.Background(k.SurfaceHover) } @@ -174,5 +176,5 @@ func traceSpanRow(c *ui.Context, s WaterfallSpan, depth int, total time.Duration 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() + return !row.IsDisabled() && row.Clicked() } diff --git a/trace_waterfall_test.go b/trace_waterfall_test.go index 23779b1..22b021a 100644 --- a/trace_waterfall_test.go +++ b/trace_waterfall_test.go @@ -76,3 +76,24 @@ func TestTraceWaterfallSnapshot(t *testing.T) { ui.Column(c).Padding(16).Children(func() { TraceWaterfall(c, waterfallSample, TraceWaterfallOptions{Height: 180}) }) }) } + +func TestTraceWaterfallAncestorDisabled(t *testing.T) { + disabled := false + changes := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + if TraceWaterfall(c, traceSample, TraceWaterfallOptions{}).Changed() { + changes++ + } + }) + }, 700, 460) + _ = tt.Click("Charge") + disabled = true + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyEnter) + _ = tt.Click("ListGuests") + if changes != 1 || !tt.HasText("payments · starts at 230 ms · takes 80 ms") { + t.Fatal("disabled trace selection changed") + } +} diff --git a/uptime_bar.go b/uptime_bar.go index c92eda8..cfd74f2 100644 --- a/uptime_bar.go +++ b/uptime_bar.go @@ -9,7 +9,7 @@ import ( "mujicaui/internal" ) -// UptimeDay is one day's availability, 0 to 1; NaN means no data. +// UptimeDay holds availability; NaN marks missing data. type UptimeDay struct { Date Date Uptime float64 @@ -20,11 +20,11 @@ type UptimeDay struct { // 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 and Down default to 0.999 and 0.95. Degraded, Down float64 } -// UptimeBar draws one bar per day by availability; hover or arrows read a day's detail. +// UptimeBar shows daily availability with pointer and keyboard readouts. func UptimeBar(c *ui.Context, days []UptimeDay, opts UptimeBarOptions) ChartResult { deg, down := opts.Degraded, opts.Down if deg == 0 { @@ -36,17 +36,22 @@ func UptimeBar(c *ui.Context, days []UptimeDay, opts UptimeBarOptions) ChartResu 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) + cache := ui.Local(ch.res.Element, "mujica.uptime", func() uptimeCache { return uptimeCache{} }) + if cache.key != chartID(days) { + for i, d := range days { + if !math.IsNaN(d.Uptime) && (d.Uptime < 0 || d.Uptime > 1) { + panic(fmt.Sprintf("mujica: UptimeBar day %d outside 0..1", i)) + } + } + cache.key, cache.width = chartID(days), -1 + } 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 { @@ -59,19 +64,27 @@ func UptimeBar(c *ui.Context, days []UptimeDay, opts UptimeBarOptions) ChartResu } return 0 } + pal := internal.Palette(k) 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{ Marks: 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) + cache.prepare(days, max(1, int(math.Ceil(float64(r.W*p.Scale()))))) + for _, slot := range cache.slots { + d := days[slot.index] + x, w := internal.Snap(p, b.Start(slot.first)), max(b.Start(slot.last)+b.Width()-b.Start(slot.first), 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. + col := [...]ui.Color{pal[2], pal[3], k.Danger, k.TextMuted}[lv] + // Shorter bars distinguish degraded days and outages. 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 { + bar := ui.Rect{X: x, Y: r.Y + r.H - h, W: w, H: h} + if lv == 3 { + p.Stroke(bar, col, 1, 1) + } else { + p.Fill(bar, col, 1) + } + if active >= slot.first && active <= slot.last { p.Stroke(ui.Rect{X: x - 2, Y: r.Y - 2, W: w + 4, H: r.H + 4}, k.Focus, 2, 1.5) } } @@ -94,3 +107,29 @@ func UptimeBar(c *ui.Context, days []UptimeDay, opts UptimeBarOptions) ChartResu }) return res } + +type uptimeSlot struct{ first, last, index int } +type uptimeCache struct { + key string + width int + slots []uptimeSlot +} + +func (s *uptimeCache) prepare(days []UptimeDay, width int) { + if s.width == width { + return + } + s.width, s.slots = width, s.slots[:0] + n := len(days) + for first := 0; first < n; { + last := min(n-1, max(first, ((first*width/n+1)*n-1)/width)) + index := first + for i := first + 1; i <= last; i++ { + if math.IsNaN(days[index].Uptime) || !math.IsNaN(days[i].Uptime) && days[i].Uptime < days[index].Uptime { + index = i + } + } + s.slots = append(s.slots, uptimeSlot{first, last, index}) + first = last + 1 + } +} diff --git a/uptime_bar_test.go b/uptime_bar_test.go index fdbff9f..6382516 100644 --- a/uptime_bar_test.go +++ b/uptime_bar_test.go @@ -85,3 +85,41 @@ func TestUptimeBarSnapshot(t *testing.T) { }) }) } + +func TestUptimeBarManyDaysBounded(t *testing.T) { + days := uptimeSample(100000) + var pts []int + tt := uptimeTester(days, ChartFrame{}, &pts) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnd) + if pts[len(pts)-1] != 99999 { + t.Fatal("end boundary") + } + for i := range 20 { + tt.Key(0, ui.KeyRight) + _ = i + } + if pts[len(pts)-1] != 99999 { + t.Fatal("right boundary") + } +} + +func TestUptimeBarPixelBucketsPreserveOutages(t *testing.T) { + days := uptimeSample(100000) + var cache uptimeCache + cache.prepare(days, 500) + if len(cache.slots) > 500 || cache.slots[0].index != 7 { + t.Fatalf("%d buckets, first worst %d", len(cache.slots), cache.slots[0].index) + } + cache.prepare(days, 1000) + if len(cache.slots) > 1000 || cache.slots[len(cache.slots)-1].last != 99999 { + t.Fatal("resized buckets") + } + for _, n := range []int{1, 3, 12} { + cache.width = -1 + cache.prepare(days[:n], 500) + if len(cache.slots) != n { + t.Fatalf("%d days rendered as %d buckets", n, len(cache.slots)) + } + } +}