Merge ae66cd6d621c16060 (as built)
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ApiRequest is an HTTP request being built; the caller sends it.
|
||||
// ApiRequest holds request data sent by the caller.
|
||||
type ApiRequest struct {
|
||||
// Method is GET when empty.
|
||||
Method string
|
||||
@@ -41,13 +41,16 @@ type apiRequestState struct{ tab int }
|
||||
|
||||
var apiMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
|
||||
|
||||
// ApiRequestBuilder edits method, URL, query parameters, headers and body, and reports a send.
|
||||
// ApiRequestBuilder edits requests and reports send actions.
|
||||
func ApiRequestBuilder(c *ui.Context, req *ApiRequest, opts ApiRequestBuilderOptions) ApiRequestResult {
|
||||
if req == nil {
|
||||
panic("mujica: ApiRequestBuilder needs a request")
|
||||
}
|
||||
original := req
|
||||
if req.Method == "" {
|
||||
req.Method = "GET"
|
||||
copy := *req
|
||||
copy.Method = "GET"
|
||||
req = ©
|
||||
}
|
||||
if !slices.Contains(apiMethods, req.Method) {
|
||||
panic("mujica: ApiRequestBuilder method " + req.Method + " is not offered")
|
||||
@@ -57,6 +60,15 @@ func ApiRequestBuilder(c *ui.Context, req *ApiRequest, opts ApiRequestBuilderOpt
|
||||
r.Element = ui.Column(c).Shrink(0).Gap(u * 3).Disabled(opts.Disabled)
|
||||
st := ui.Local(r.Element, "mujica.apirequest", func() apiRequestState { return apiRequestState{} })
|
||||
locked := r.Element.IsDisabled()
|
||||
if locked {
|
||||
copy := *req
|
||||
copy.Params = slices.Clone(req.Params)
|
||||
copy.Headers = slices.Clone(req.Headers)
|
||||
req = ©
|
||||
} else {
|
||||
*original = *req
|
||||
req = original
|
||||
}
|
||||
full, urlErr := apiRequestURL(*req)
|
||||
r.Element.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() {
|
||||
@@ -67,7 +79,7 @@ func ApiRequestBuilder(c *ui.Context, req *ApiRequest, opts ApiRequestBuilderOpt
|
||||
r.Changed = Select(c, &req.Method, methods, SelectOptions{Label: msg(c, "api_request_builder.method"), Disabled: locked}).Changed()
|
||||
shownErr := ""
|
||||
if req.URL != "" && urlErr != nil {
|
||||
shownErr = urlErr.Error()
|
||||
shownErr = msg(c, "api_request_builder.invalid_url")
|
||||
}
|
||||
in := TextInput(c, &req.URL, TextInputOptions{Placeholder: "https://", Label: msg(c, "api_request_builder.url"), Disabled: locked, Error: shownErr})
|
||||
in.Element.Grow(1)
|
||||
@@ -98,12 +110,12 @@ func ApiRequestBuilder(c *ui.Context, req *ApiRequest, opts ApiRequestBuilderOpt
|
||||
Tabs(c, &st.tab, tabs, TabsOptions{Label: msg(c, "api_request_builder.parts")})
|
||||
})
|
||||
if r.Send {
|
||||
slog.Debug("mujica: api_request_builder send", "method", req.Method) // never the URL: it may hold a token
|
||||
slog.Debug("mujica: api_request_builder send", "method", req.Method) // Request URLs may contain secrets.
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// apiRequestURL joins the URL with its enabled query parameters, or says why it is invalid.
|
||||
// apiRequestURL joins enabled query parameters with a validated URL.
|
||||
func apiRequestURL(req ApiRequest) (string, error) {
|
||||
if req.URL == "" {
|
||||
return "", fmt.Errorf("empty URL")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,随桌面文本缩放增长;统计值由调用方计算。
|
||||
@@ -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 激活。
|
||||
|
||||
## 限制
|
||||
|
||||
静默请求固定为一小时;告警数据由调用方管理。
|
||||
@@ -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。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
展示内容随调用方更新。
|
||||
|
||||
## 限制
|
||||
|
||||
时间线按输入顺序展示;调用方排序。
|
||||
@@ -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 清除读取。
|
||||
|
||||
## 限制
|
||||
|
||||
大数据按像素列汇总;更新数据时提交新切片。
|
||||
@@ -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 展示原因。
|
||||
|
||||
## 状态
|
||||
|
||||
正常、降级、中断、空列表;总体统计随数据更新。
|
||||
|
||||
## 事件
|
||||
|
||||
展示组件,调用方更新服务切片。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
滚动列表浏览服务。
|
||||
|
||||
## 限制
|
||||
|
||||
状态来自调用方;每行使用图标和文字区分状态。更新服务数据时提交新切片。
|
||||
@@ -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 激活暂停。
|
||||
|
||||
## 限制
|
||||
|
||||
追加或替换日志提交新切片;暂停冻结条数。
|
||||
@@ -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 读取详情。
|
||||
|
||||
## 限制
|
||||
|
||||
更新调用链提交新切片;跨度父子关系须构成树。
|
||||
@@ -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 聚焦仪表;方向键读取数值与阈值文字。
|
||||
|
||||
## 限制
|
||||
|
||||
资源采样与容量换算由调用方完成。
|
||||
@@ -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 关闭弹层。
|
||||
|
||||
## 限制
|
||||
|
||||
过滤请求由调用方执行;选中值须存在于提供的选项中。
|
||||
@@ -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 使用点号、数组索引和带引号的键;解析结果按数据变化缓存。
|
||||
@@ -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 值;格式化使用两个空格缩进。
|
||||
@@ -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。
|
||||
@@ -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 时使用树视图;其余正文使用文本。
|
||||
@@ -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 激活增删。
|
||||
|
||||
## 限制
|
||||
|
||||
行顺序保持输入顺序;大量行采用虚拟化。
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 激活增删。"},
|
||||
}
|
||||
@@ -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)`
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,14 @@ package mujica
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
// KeyValue is one editable pair; disabled pairs are kept but not used.
|
||||
// KeyValue holds an editable pair and enable flag.
|
||||
type KeyValue struct {
|
||||
Key, Value string
|
||||
Enabled bool
|
||||
@@ -22,11 +23,13 @@ type KeyValueInputOptions struct {
|
||||
// Label names the editor for assistive technology.
|
||||
Label string
|
||||
Disabled bool
|
||||
// MaxHeight caps the rows; zero means 320.
|
||||
MaxHeight float32
|
||||
}
|
||||
|
||||
// KeyValueInput edits rows of pairs with an enable switch, add and remove.
|
||||
// KeyValueInput edits virtualized pairs with enable, add and remove.
|
||||
func KeyValueInput(c *ui.Context, rows *[]KeyValue, opts KeyValueInputOptions) ChoiceResult {
|
||||
if rows == nil {
|
||||
if rows == nil || opts.MaxHeight < 0 {
|
||||
panic("mujica: KeyValueInput needs rows")
|
||||
}
|
||||
kp, vp := opts.KeyPlaceholder, opts.ValuePlaceholder
|
||||
@@ -40,9 +43,17 @@ func KeyValueInput(c *ui.Context, rows *[]KeyValue, opts KeyValueInputOptions) C
|
||||
var r ChoiceResult
|
||||
r.Element = ui.Column(c).Shrink(0).Gap(u * 2).Disabled(opts.Disabled).Label(opts.Label)
|
||||
locked := r.Element.IsDisabled()
|
||||
if locked {
|
||||
copy := slices.Clone(*rows)
|
||||
rows = ©
|
||||
}
|
||||
maxH := opts.MaxHeight
|
||||
if maxH == 0 {
|
||||
maxH = 320
|
||||
}
|
||||
remove := -1
|
||||
r.Element.Children(func() {
|
||||
for i := range *rows {
|
||||
agentRows(c, len(*rows), func(i int) {
|
||||
kv := &(*rows)[i]
|
||||
ui.Row(c).Key(i).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
name := fmt.Sprintf(msg(c, "key_value_input.row"), i+1)
|
||||
@@ -60,7 +71,7 @@ func KeyValueInput(c *ui.Context, rows *[]KeyValue, opts KeyValueInputOptions) C
|
||||
remove = i
|
||||
}
|
||||
})
|
||||
}
|
||||
}).Shrink(1).MaxHeight(maxH)
|
||||
if Button(c, msg(c, "key_value_input.add"), ButtonOptions{Variant: Ghost, Icon: icons.Must("plus"), Disabled: locked}).AlignSelf(ui.Start).Clicked() && !locked {
|
||||
*rows = append(*rows, KeyValue{Enabled: true})
|
||||
r.changed = true
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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", "没有告警"},
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// SchemaKind is what a schema node is. The zero value is SchemaDatabase.
|
||||
// SchemaKind classifies nodes; zero means SchemaDatabase.
|
||||
type SchemaKind int
|
||||
|
||||
const (
|
||||
@@ -21,7 +21,7 @@ const (
|
||||
SchemaIndex
|
||||
)
|
||||
|
||||
// SchemaNode is one database object; Detail is a column type or index columns.
|
||||
// SchemaNode describes an object; Detail holds type or index columns.
|
||||
type SchemaNode struct {
|
||||
Name string
|
||||
Kind SchemaKind
|
||||
@@ -39,7 +39,7 @@ type SchemaTreeState struct {
|
||||
src []SchemaNode
|
||||
}
|
||||
|
||||
// Selected returns the chosen node's path, as "shop/orders/id", and whether one is chosen.
|
||||
// Selected returns the chosen path and whether it exists.
|
||||
func (s *SchemaTreeState) Selected() (string, bool) {
|
||||
if s.selected < 0 || s.selected >= s.outline.Rows() {
|
||||
return "", false
|
||||
@@ -53,7 +53,7 @@ type SchemaTreeOptions struct {
|
||||
Height float32
|
||||
}
|
||||
|
||||
// SchemaTree shows databases, tables, views, columns and indexes as a keyboard tree, virtualized.
|
||||
// SchemaTree shows database objects in a virtualized keyboard tree.
|
||||
func SchemaTree(c *ui.Context, s *SchemaTreeState, roots []SchemaNode, opts SchemaTreeOptions) ChoiceResult {
|
||||
if s == nil || opts.Height < 0 {
|
||||
panic("mujica: SchemaTree needs a state and Height >= 0")
|
||||
@@ -67,6 +67,19 @@ func SchemaTree(c *ui.Context, s *SchemaTreeState, roots []SchemaNode, opts Sche
|
||||
d := Density(c)
|
||||
var r ChoiceResult
|
||||
r.Element = codePanel(c).Height(h).Label(msg(c, "schema_tree.label"))
|
||||
locked := r.Element.IsDisabled()
|
||||
if locked {
|
||||
copy := *s
|
||||
copy.outline.Open = ui.Selection[string]{}
|
||||
for path := range s.outline.Open.All() {
|
||||
copy.outline.Open.Add(path)
|
||||
}
|
||||
s = ©
|
||||
for _, mods := range []ui.Modifiers{0, ui.Alt, ui.Shift} {
|
||||
r.Element.Shortcut(mods, ui.KeyLeft)
|
||||
r.Element.Shortcut(mods, ui.KeyRight)
|
||||
}
|
||||
}
|
||||
r.Element.Children(func() {
|
||||
if len(s.roots) == 0 {
|
||||
codeCaption(c, msg(c, "schema_tree.empty")).Padding(d.Unit() * 4)
|
||||
@@ -75,7 +88,7 @@ func SchemaTree(c *ui.Context, s *SchemaTreeState, roots []SchemaNode, opts Sche
|
||||
was := s.selected
|
||||
s.outline.List.Selected = &s.selected
|
||||
s.outline.List.Label = func(i int) string { return s.nodes[s.outline.Item(i)].Name }
|
||||
restore := dataSelectTheme(c, 0)
|
||||
restore := dataSelectTheme(c, theme.MinHitSize/4)
|
||||
e := ui.Outline(c, &s.outline, s.roots, func(p string) []string { return s.kids[p] }, func(p string) {
|
||||
n := s.nodes[p]
|
||||
ui.Row(c).Grow(1).MinWidth(0).MinHeight(d.ControlHeight() - 2*d.Unit()).Gap(d.Unit() * 2).AlignItems(ui.Center).
|
||||
@@ -92,7 +105,7 @@ func SchemaTree(c *ui.Context, s *SchemaTreeState, roots []SchemaNode, opts Sche
|
||||
}).Grow(1)
|
||||
restore()
|
||||
internal.Ring(e, k.Focus, theme.ControlRadius)
|
||||
r.changed = s.selected != was
|
||||
r.changed = !locked && s.selected != was
|
||||
})
|
||||
if r.changed {
|
||||
p, _ := s.Selected()
|
||||
@@ -134,6 +147,10 @@ func schemaIndex(s *SchemaTreeState, roots []SchemaNode) {
|
||||
return paths
|
||||
}
|
||||
s.roots = walk(roots, "")
|
||||
if len(s.roots) == 0 {
|
||||
s.outline = ui.OutlineState[string]{}
|
||||
s.selected = -1
|
||||
}
|
||||
}
|
||||
|
||||
func schemaBadge(k theme.Tokens, kind SchemaKind) (string, ui.Color) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||