Merge ae66cd6d621c16060 (as built)

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 15:52:46 -04:00
commit 6e899d839f
80 files changed
+2749 -127

No files matched your search

+3 -3
View File
@@ -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)
+10
View File
@@ -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)
}
+18 -6
View File
@@ -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 = &copy
}
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 = &copy
} 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")
+32 -1
View File
@@ -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")
}
}
+9 -4
View File
@@ -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).
+34
View File
@@ -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)
}
+2 -2
View File
@@ -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() {
+39
View File
@@ -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,随桌面文本缩放增长;统计值由调用方计算。
+42
View File
@@ -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 激活。
## 限制
静默请求固定为一小时;告警数据由调用方管理。
+36
View File
@@ -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。
## 键盘操作
展示内容随调用方更新。
## 限制
时间线按输入顺序展示;调用方排序。
+42
View File
@@ -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 清除读取。
## 限制
大数据按像素列汇总;更新数据时提交新切片。
+36
View File
@@ -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 展示原因。
## 状态
正常、降级、中断、空列表;总体统计随数据更新。
## 事件
展示组件,调用方更新服务切片。
## 键盘操作
滚动列表浏览服务。
## 限制
状态来自调用方;每行使用图标和文字区分状态。更新服务数据时提交新切片。
+39
View File
@@ -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 激活暂停。
## 限制
追加或替换日志提交新切片;暂停冻结条数。
+38
View File
@@ -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 读取详情。
## 限制
更新调用链提交新切片;跨度父子关系须构成树。
+38
View File
@@ -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 聚焦仪表;方向键读取数值与阈值文字。
## 限制
资源采样与容量换算由调用方完成。
+39
View File
@@ -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 表示缺测。
@@ -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。
@@ -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 关闭弹层。
## 限制
过滤请求由调用方执行;选中值须存在于提供的选项中。
+40
View File
@@ -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 使用点号、数组索引和带引号的键;解析结果按数据变化缓存。
+39
View File
@@ -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 值;格式化使用两个空格缩进。
+40
View File
@@ -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 进入树;上下选择;左右展开折叠。
## 限制
路径以斜线分隔;节点名称须保持路径唯一;更新结构提交新切片。
@@ -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。
+38
View File
@@ -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 时使用树视图;其余正文使用文本。
+38
View File
@@ -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 激活增删。
## 限制
行顺序保持输入顺序;大量行采用虚拟化。
+218
View File
@@ -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)
}
+21 -1
View File
@@ -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 激活增删。"},
}
+175
View File
@@ -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)`
+133
View File
@@ -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")
}
})
})
}
+173
View File
@@ -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")
}
})
})
}
+127
View File
@@ -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")
}
})
})
}
+87
View File
@@ -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)
}
}
})
}
}
+4 -4
View File
@@ -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
+11
View File
@@ -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)
}
}
+23 -8
View File
@@ -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()
+60
View File
@@ -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")
}
}
+12 -7
View File
@@ -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
}
}
+45
View File
@@ -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")
}
}
+16 -5
View File
@@ -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 = &copy
}
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
+26
View File
@@ -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")
}
}
+11 -6
View File
@@ -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)
}
+47
View File
@@ -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")
}
}
+12 -2
View File
@@ -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", "没有告警"},
})
}
+6 -4
View File
@@ -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 {
+42
View File
@@ -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)
}
+23 -6
View File
@@ -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
}
+47
View File
@@ -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")
}
}
+127
View File
@@ -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)
}
+154
View File
@@ -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")
}
}
+23 -6
View File
@@ -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 = &copy
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) {
+58
View File
@@ -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")
}
}
+20 -8
View File
@@ -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
}
+12
View File
@@ -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)
}
+8 -14
View File
@@ -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, ""
+41 -2
View File
@@ -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)
}
}
+19 -14
View File
@@ -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
}
+43 -2
View File
@@ -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")
}
}
+6 -1
View File
@@ -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
+23
View File
@@ -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)
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

+9 -7
View File
@@ -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()
}
+21
View File
@@ -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")
}
}
+53 -14
View File
@@ -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
}
}
+38
View File
@@ -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))
}
}
}