Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI
This commit is contained in:
1822 files changed
+24568
-21314
No files matched your search
@@ -0,0 +1,131 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/agent"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// AlertLevel is an alert's level. The zero value is AlertInfo.
|
||||
type AlertLevel int
|
||||
|
||||
const (
|
||||
AlertInfo AlertLevel = iota
|
||||
AlertWarning
|
||||
AlertCritical
|
||||
)
|
||||
|
||||
// AlertItem is one firing alert; the caller owns it.
|
||||
type AlertItem struct {
|
||||
ID string
|
||||
Title string
|
||||
Level AlertLevel
|
||||
Source string
|
||||
At time.Time
|
||||
Acked bool
|
||||
Silence time.Time // silenced until; zero is not silenced
|
||||
}
|
||||
|
||||
// AlertListOptions configure an AlertList.
|
||||
type AlertListOptions struct {
|
||||
// MaxHeight caps the scrolling list; zero means 360.
|
||||
MaxHeight float32
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// AlertListResult reports this frame's request.
|
||||
type AlertListResult struct {
|
||||
Element *ui.Element
|
||||
// Ack and Silence report requested alert IDs; empty means idle.
|
||||
Ack, Silence string
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, a := range alerts {
|
||||
if a.ID == "" || a.Title == "" || seen[a.ID] || a.Level < AlertInfo || a.Level > AlertCritical {
|
||||
panic(fmt.Sprintf("mujica: AlertList alert %d needs a unique ID, a Title and a known Level", i))
|
||||
}
|
||||
seen[a.ID] = true
|
||||
}
|
||||
maxH := opts.MaxHeight
|
||||
if maxH == 0 {
|
||||
maxH = 360
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
var r AlertListResult
|
||||
r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled)
|
||||
locked := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
if len(alerts) == 0 {
|
||||
agent.AgentCaption(c, core.Msg(c, "alert_list.empty"))
|
||||
return
|
||||
}
|
||||
agent.AgentRows(c, len(alerts), func(i int) {
|
||||
ack, silence := alertRow(c, alerts[i], locked)
|
||||
if ack && !locked {
|
||||
r.Ack = alerts[i].ID
|
||||
}
|
||||
if silence && !locked {
|
||||
r.Silence = alerts[i].ID
|
||||
}
|
||||
}).Shrink(1).MaxHeight(maxH).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Radius(theme.CardRadius).Dividers(theme.BorderWidth, k.Border)
|
||||
})
|
||||
if r.Ack != "" || r.Silence != "" {
|
||||
slog.Debug("mujica: alert_list action", "ack", r.Ack, "silence", r.Silence)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// alertRow shows an alert with acknowledge and silence buttons.
|
||||
func alertRow(c *ui.Context, a AlertItem, locked bool) (ack, silence bool) {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
u := d.Unit()
|
||||
sev := [...]core.Severity{core.SeverityInfo, core.SeverityWarning, core.SeverityError}[a.Level]
|
||||
word := core.Msg(c, [...]string{"alert_list.info", "alert_list.warning", "alert_list.critical"}[a.Level])
|
||||
silenced := a.Silence.After(c.Now())
|
||||
row := ui.Row(c).Key(a.ID).Gap(u*3).AlignItems(ui.Center).MinHeight(d.ControlHeight()+u*2).Padding(u*2, u*3).
|
||||
Label(word + ", " + a.Title)
|
||||
if tone, _ := core.SeverityLook(k, sev); a.Level == AlertCritical && !a.Acked {
|
||||
row.DrawOver(func(p *ui.Painter, r ui.Rect) { p.Fill(ui.Rect{X: r.X, Y: r.Y, W: 3, H: r.H}, tone, 0) })
|
||||
}
|
||||
row.Children(func() {
|
||||
ui.Box(c).Width(96).Shrink(0).Children(func() { devtoolsSeverity(c, sev, word) })
|
||||
ui.Column(c).Grow(1).Shrink(1).Gap(u / 2).Children(func() {
|
||||
t := ui.Text(c, a.Title).FontWeight(600)
|
||||
if a.Acked || silenced {
|
||||
t.TextColor(k.TextMuted)
|
||||
}
|
||||
line := a.Source
|
||||
if !a.At.IsZero() {
|
||||
line += " · " + devtoolsAgo(c, a.At)
|
||||
}
|
||||
agent.AgentCaption(c, line)
|
||||
})
|
||||
switch {
|
||||
case silenced:
|
||||
agent.AgentCaption(c, fmt.Sprintf(core.Msg(c, "alert_list.silenced"), a.Silence.Format("15:04")))
|
||||
case a.Acked:
|
||||
agent.AgentCaption(c, core.Msg(c, "alert_list.acked"))
|
||||
default:
|
||||
ack = input.Button(c, core.Msg(c, "alert_list.ack"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked}).Clicked()
|
||||
}
|
||||
if !silenced {
|
||||
silence = agent.AgentToggle(c, core.Msg(c, "alert_list.silence"), "bell", locked)
|
||||
}
|
||||
})
|
||||
return ack, silence
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func alertSample() []AlertItem {
|
||||
now := time.Now()
|
||||
return []AlertItem{
|
||||
{ID: "a", Title: "Disk 92% full", Level: AlertCritical, Source: "db-1", At: now.Add(-5 * time.Minute)},
|
||||
{ID: "b", Title: "Latency above 500 ms", Level: AlertWarning, Source: "api", At: now.Add(-time.Hour), Acked: true},
|
||||
{ID: "c", Title: "Deploy finished", Level: AlertInfo, Source: "ci", Silence: now.Add(time.Hour)},
|
||||
}
|
||||
}
|
||||
|
||||
func alertTester(alerts []AlertItem, opts AlertListOptions, got *[]AlertListResult) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := AlertList(c, alerts, opts)
|
||||
if r.Ack != "" || r.Silence != "" {
|
||||
*got = append(*got, r)
|
||||
}
|
||||
}, 640, 400)
|
||||
}
|
||||
|
||||
func TestAlertListLevelsAndActions(t *testing.T) {
|
||||
var got []AlertListResult
|
||||
tt := alertTester(alertSample(), AlertListOptions{}, &got)
|
||||
for _, s := range []string{"Critical", "Warning", "Info", "db-1 · 5 m 00 s ago", "Acknowledged"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Fatalf("missing %q in %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
_ = tt.Click("Acknowledge")
|
||||
silences, _ := tt.Find("Silence 1 h")
|
||||
tt.ClickAt(silences.X+4, silences.Y+4)
|
||||
if len(got) != 2 || got[0].Ack != "a" || got[1].Silence != "a" {
|
||||
t.Fatalf("actions %+v", got)
|
||||
}
|
||||
tt = alertTester(alertSample(), AlertListOptions{}, &got)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(got) != 3 || got[2].Ack != "a" {
|
||||
t.Fatalf("keyboard ack: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertListDisabledEmptyMany(t *testing.T) {
|
||||
var got []AlertListResult
|
||||
tt := alertTester(alertSample(), AlertListOptions{Disabled: true}, &got)
|
||||
_ = tt.Click("Acknowledge")
|
||||
_ = tt.Click("Silence 1 h")
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("disabled list acted: %+v", got)
|
||||
}
|
||||
tt = alertTester(nil, AlertListOptions{}, &got)
|
||||
if !tt.HasText("No alerts") {
|
||||
t.Fatal("empty text missing")
|
||||
}
|
||||
many := make([]AlertItem, 100000)
|
||||
for i := range many {
|
||||
many[i] = AlertItem{ID: fmt.Sprint(i), Title: fmt.Sprintf("alert %d", i)}
|
||||
}
|
||||
tt = alertTester(many, AlertListOptions{}, &got)
|
||||
if n := len(tt.Texts()); n > 300 {
|
||||
t.Fatalf("%d texts for 100000 alerts", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertListSnapshot(t *testing.T) {
|
||||
snapshot(t, "alert_list", 640, 260, func(c *ui.Context) {
|
||||
alerts := alertSample()
|
||||
for i := range alerts {
|
||||
alerts[i].At = time.Time{}
|
||||
}
|
||||
alerts[2].Silence = time.Time{}
|
||||
ui.Column(c).Padding(16).Children(func() { AlertList(c, alerts, AlertListOptions{}) })
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/agent"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/code"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/navigation"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ApiRequest holds request data sent by the caller.
|
||||
type ApiRequest struct {
|
||||
// Method is GET when empty.
|
||||
Method string
|
||||
URL string
|
||||
Params []KeyValue
|
||||
Headers []KeyValue
|
||||
Body string
|
||||
}
|
||||
|
||||
// ApiRequestBuilderOptions configure an ApiRequestBuilder.
|
||||
type ApiRequestBuilderOptions struct {
|
||||
// Sending shows the request in flight and blocks another send.
|
||||
Sending bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ApiRequestResult reports this frame's events.
|
||||
type ApiRequestResult struct {
|
||||
Element *ui.Element
|
||||
// Send asks the caller to send the request.
|
||||
Send bool
|
||||
Changed bool
|
||||
}
|
||||
|
||||
type apiRequestState struct{ tab int }
|
||||
|
||||
var apiMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
|
||||
|
||||
// ApiRequestBuilder edits 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 == "" {
|
||||
copy := *req
|
||||
copy.Method = "GET"
|
||||
req = ©
|
||||
}
|
||||
if !slices.Contains(apiMethods, req.Method) {
|
||||
panic("mujica: ApiRequestBuilder method " + req.Method + " is not offered")
|
||||
}
|
||||
u := core.Density(c).Unit()
|
||||
var r ApiRequestResult
|
||||
r.Element = ui.Column(c).Shrink(0).Gap(u * 3).Disabled(opts.Disabled)
|
||||
st := ui.Local(r.Element, "mujica.apirequest", func() apiRequestState { return apiRequestState{} })
|
||||
locked := r.Element.IsDisabled()
|
||||
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() {
|
||||
methods := make([]input.SelectOption[string], len(apiMethods))
|
||||
for i, m := range apiMethods {
|
||||
methods[i] = input.SelectOption[string]{Value: m, Label: m}
|
||||
}
|
||||
r.Changed = input.Select(c, &req.Method, methods, input.SelectOptions{Label: core.Msg(c, "api_request_builder.method"), Disabled: locked}).Changed()
|
||||
shownErr := ""
|
||||
if req.URL != "" && urlErr != nil {
|
||||
shownErr = core.Msg(c, "api_request_builder.invalid_url")
|
||||
}
|
||||
in := input.TextInput(c, &req.URL, input.TextInputOptions{Placeholder: "https://", Label: core.Msg(c, "api_request_builder.url"), Disabled: locked, Error: shownErr})
|
||||
in.Element.Grow(1)
|
||||
r.Changed = r.Changed || in.Changed()
|
||||
ready := urlErr == nil && !opts.Sending && !locked
|
||||
send := input.Button(c, core.Msg(c, "api_request_builder.send"), input.ButtonOptions{Icon: icons.Must("upload"), Loading: opts.Sending, Disabled: locked || urlErr != nil})
|
||||
r.Send = ready && (send.Clicked() || in.Submitted())
|
||||
})
|
||||
if urlErr == nil {
|
||||
code.CodeMono(c, req.Method+" "+full).TextColor(core.Tokens(c).TextMuted)
|
||||
}
|
||||
tabs := []navigation.Tab{
|
||||
{Label: fmt.Sprintf(core.Msg(c, "api_request_builder.params"), len(keyValueEnabled(req.Params))), Panel: func() {
|
||||
r.Changed = KeyValueInput(c, &req.Params, KeyValueInputOptions{Label: core.Msg(c, "api_request_builder.params_label"), Disabled: locked}).Changed() || r.Changed
|
||||
}},
|
||||
{Label: fmt.Sprintf(core.Msg(c, "api_request_builder.headers"), len(keyValueEnabled(req.Headers))), Panel: func() {
|
||||
r.Changed = KeyValueInput(c, &req.Headers, KeyValueInputOptions{Label: core.Msg(c, "api_request_builder.headers_label"), Disabled: locked}).Changed() || r.Changed
|
||||
}},
|
||||
{Label: core.Msg(c, "api_request_builder.body"), Panel: func() {
|
||||
if req.Method == "GET" || req.Method == "HEAD" {
|
||||
agent.AgentCaption(c, core.Msg(c, "api_request_builder.no_body"))
|
||||
}
|
||||
body := input.TextArea(c, &req.Body, input.TextAreaOptions{Label: core.Msg(c, "api_request_builder.body"), MinHeight: 120, MaxHeight: 320, Disabled: locked})
|
||||
body.Input.Font(theme.MonoFont)
|
||||
r.Changed = r.Changed || body.Changed()
|
||||
}},
|
||||
}
|
||||
navigation.Tabs(c, &st.tab, tabs, navigation.TabsOptions{Label: core.Msg(c, "api_request_builder.parts")})
|
||||
})
|
||||
if r.Send {
|
||||
slog.Debug("mujica: api_request_builder send", "method", req.Method) // Request URLs may contain secrets.
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// apiRequestURL joins enabled query parameters with a validated URL.
|
||||
func apiRequestURL(req ApiRequest) (string, error) {
|
||||
if req.URL == "" {
|
||||
return "", fmt.Errorf("empty URL")
|
||||
}
|
||||
u, err := url.Parse(req.URL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return "", fmt.Errorf("the URL needs http:// or https:// and a host")
|
||||
}
|
||||
q := u.Query()
|
||||
for _, kv := range keyValueEnabled(req.Params) {
|
||||
q.Add(kv.Key, kv.Value)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func apiTester(req *ApiRequest, opts *ApiRequestBuilderOptions, sends *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(8).Children(func() {
|
||||
if ApiRequestBuilder(c, req, *opts).Send {
|
||||
*sends++
|
||||
}
|
||||
})
|
||||
}, 760, 520)
|
||||
}
|
||||
|
||||
func TestApiRequestBuilderBuildAndSend(t *testing.T) {
|
||||
var req ApiRequest
|
||||
opts := ApiRequestBuilderOptions{}
|
||||
sends := 0
|
||||
tt := apiTester(&req, &opts, &sends)
|
||||
if req.Method != "GET" {
|
||||
t.Fatalf("default method %q", req.Method)
|
||||
}
|
||||
_ = tt.Click("Send") // disabled without a URL
|
||||
_ = tt.Click("Request URL")
|
||||
tt.Type("ftp://court")
|
||||
if !tt.HasText("Enter a valid HTTP or HTTPS URL") || sends != 0 {
|
||||
t.Fatalf("invalid URL: %q", tt.Texts())
|
||||
}
|
||||
req.URL = ""
|
||||
tt.Frame()
|
||||
_ = tt.Click("Request URL")
|
||||
tt.Type("https://court.example/guests")
|
||||
_ = tt.Click("Add row")
|
||||
_ = tt.Click("Row 1 Key")
|
||||
tt.Type("page")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Type("2")
|
||||
if !tt.HasText("GET https://court.example/guests?page=2") || !tt.HasText("Params (1)") {
|
||||
t.Fatalf("preview: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Request URL")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
_ = tt.Click("Send")
|
||||
if sends != 2 {
|
||||
t.Fatalf("sends %d", sends)
|
||||
}
|
||||
_ = tt.Click("Method")
|
||||
_ = tt.Click("POST")
|
||||
_ = tt.Click("Body")
|
||||
if req.Method != "POST" || tt.HasText("GET and HEAD requests usually carry no body.") {
|
||||
t.Fatalf("method %q", req.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiRequestBuilderSendingAndDisabled(t *testing.T) {
|
||||
req := ApiRequest{URL: "https://court.example"}
|
||||
opts := ApiRequestBuilderOptions{Sending: true}
|
||||
sends := 0
|
||||
tt := apiTester(&req, &opts, &sends)
|
||||
_ = tt.Click("Request URL")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if sends != 0 {
|
||||
t.Fatal("sent while sending")
|
||||
}
|
||||
opts = ApiRequestBuilderOptions{Disabled: true}
|
||||
tt.Frame()
|
||||
_ = tt.Click("Send")
|
||||
_ = tt.Click("Request URL")
|
||||
tt.Type("x")
|
||||
if sends != 0 || req.URL != "https://court.example" {
|
||||
t.Fatalf("disabled: %q sends %d", req.URL, sends)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("unknown method did not panic")
|
||||
}
|
||||
}()
|
||||
apiTester(&ApiRequest{Method: "BREW"}, &ApiRequestBuilderOptions{}, &sends)
|
||||
}
|
||||
|
||||
func TestApiRequestBuilderSnapshot(t *testing.T) {
|
||||
snapshot(t, "api_request_builder", 720, 330, func(c *ui.Context) {
|
||||
req := ApiRequest{Method: "POST", URL: "https://court.example/guests",
|
||||
Params: []KeyValue{{Key: "notify", Value: "true", Enabled: true}},
|
||||
Headers: []KeyValue{{Key: "Accept", Value: "application/json", Enabled: true}}}
|
||||
ui.Column(c).Padding(16).Children(func() { ApiRequestBuilder(c, &req, ApiRequestBuilderOptions{}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestApiRequestBuilderDisabledPreservesDefault(t *testing.T) {
|
||||
var req ApiRequest
|
||||
sends := 0
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
// DashboardFilters holds filters; zero uses caller defaults.
|
||||
type DashboardFilters struct {
|
||||
// Range is the look-back; zero uses the caller's default.
|
||||
Range time.Duration
|
||||
Env string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// DashboardFilterBarOptions configure a DashboardFilterBar.
|
||||
type DashboardFilterBarOptions struct {
|
||||
// Ranges defaults to 15m, 1h, 24h and 7d.
|
||||
Ranges []time.Duration
|
||||
// Envs and Tags are the offered environments and tags.
|
||||
Envs, Tags []string
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
var dashboardRanges = []time.Duration{15 * time.Minute, time.Hour, 24 * time.Hour, 7 * 24 * time.Hour}
|
||||
|
||||
// DashboardFilterBar edits time, environment and tags with clear.
|
||||
func DashboardFilterBar(c *ui.Context, f *DashboardFilters, opts DashboardFilterBarOptions) core.ChoiceResult {
|
||||
if f == nil {
|
||||
panic("mujica: DashboardFilterBar needs filters")
|
||||
}
|
||||
ranges := opts.Ranges
|
||||
if ranges == nil {
|
||||
ranges = dashboardRanges
|
||||
}
|
||||
if f.Env != "" && !slices.Contains(opts.Envs, f.Env) {
|
||||
panic("mujica: DashboardFilterBar Env is not offered")
|
||||
}
|
||||
for _, tag := range f.Tags {
|
||||
if !slices.Contains(opts.Tags, tag) {
|
||||
panic("mujica: DashboardFilterBar tag is not offered")
|
||||
}
|
||||
}
|
||||
u := core.Density(c).Unit()
|
||||
var r core.ChoiceResult
|
||||
r.Element = ui.Row(c).Shrink(0).Wrap().Gap(u * 3).AlignItems(ui.Center).Disabled(opts.Disabled).
|
||||
Label(core.Msg(c, "dashboard_filter_bar.label"))
|
||||
locked := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
rs := []input.SelectOption[time.Duration]{{Value: 0, Label: core.Msg(c, "dashboard_filter_bar.default_range")}}
|
||||
for _, d := range ranges {
|
||||
rs = append(rs, input.SelectOption[time.Duration]{Value: d, Label: fmt.Sprintf(core.Msg(c, "dashboard_filter_bar.last"), dashboardRangeLabel(c, d))})
|
||||
}
|
||||
if f.Range != 0 && !slices.Contains(ranges, f.Range) {
|
||||
panic("mujica: DashboardFilterBar Range is not offered")
|
||||
}
|
||||
r = core.NewChoiceResult(r.Element, input.Select(c, &f.Range, rs, input.SelectOptions{Label: core.Msg(c, "dashboard_filter_bar.range"), Disabled: locked}).Changed())
|
||||
envs := []input.SelectOption[string]{{Value: "", Label: core.Msg(c, "dashboard_filter_bar.all_envs")}}
|
||||
for _, e := range opts.Envs {
|
||||
envs = append(envs, input.SelectOption[string]{Value: e, Label: e})
|
||||
}
|
||||
r = core.NewChoiceResult(r.Element, input.Select(c, &f.Env, envs, input.SelectOptions{Label: core.Msg(c, "dashboard_filter_bar.env"), Disabled: locked}).Changed() || r.Changed())
|
||||
if len(opts.Tags) > 0 {
|
||||
tags := make([]input.SelectOption[string], len(opts.Tags))
|
||||
for i, t := range opts.Tags {
|
||||
tags[i] = input.SelectOption[string]{Value: t, Label: t}
|
||||
}
|
||||
r = core.NewChoiceResult(r.Element, input.MultiSelect(c, &f.Tags, tags, input.MultiSelectOptions{Placeholder: core.Msg(c, "dashboard_filter_bar.tags"),
|
||||
Label: core.Msg(c, "dashboard_filter_bar.tags"), Disabled: locked}).Changed() || r.Changed())
|
||||
}
|
||||
empty := f.Range == 0 && f.Env == "" && len(f.Tags) == 0
|
||||
if input.Button(c, core.Msg(c, "dashboard_filter_bar.clear"), input.ButtonOptions{Variant: input.Ghost, Icon: icons.Must("x"), Disabled: locked || empty}).Clicked() && !locked {
|
||||
*f, r = DashboardFilters{}, core.NewChoiceResult(r.Element, true)
|
||||
}
|
||||
})
|
||||
if r.Changed() {
|
||||
slog.Debug("mujica: dashboard_filter_bar change", "range", f.Range, "env", f.Env, "tags", len(f.Tags))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// dashboardRangeLabel names a look-back, as "15 min" or "7 d".
|
||||
func dashboardRangeLabel(c *ui.Context, d time.Duration) string {
|
||||
switch {
|
||||
case d <= 0:
|
||||
case d%(24*time.Hour) == 0:
|
||||
return fmt.Sprintf(core.Msg(c, "dashboard_filter_bar.days"), int(d/(24*time.Hour)))
|
||||
case d%time.Hour == 0:
|
||||
return fmt.Sprintf(core.Msg(c, "dashboard_filter_bar.hours"), int(d/time.Hour))
|
||||
case d%time.Minute == 0:
|
||||
return fmt.Sprintf(core.Msg(c, "dashboard_filter_bar.minutes"), int(d/time.Minute))
|
||||
}
|
||||
panic("mujica: DashboardFilterBar ranges must be positive whole minutes")
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func filterTester(f *DashboardFilters, opts DashboardFilterBarOptions, changes *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(8).Children(func() {
|
||||
if DashboardFilterBar(c, f, opts).Changed() {
|
||||
*changes++
|
||||
}
|
||||
})
|
||||
}, 900, 420)
|
||||
}
|
||||
|
||||
var filterOpts = DashboardFilterBarOptions{Envs: []string{"production", "staging"}, Tags: []string{"api", "web", "db"}}
|
||||
|
||||
func TestDashboardFilterBarChooseAndClear(t *testing.T) {
|
||||
var f DashboardFilters
|
||||
n := 0
|
||||
tt := filterTester(&f, filterOpts, &n)
|
||||
_ = tt.Click("Time range")
|
||||
_ = tt.Click("Last 1 d")
|
||||
if f.Range != 24*time.Hour || n != 1 {
|
||||
t.Fatalf("range %v changes %d", f.Range, n)
|
||||
}
|
||||
_ = tt.Click("Environment")
|
||||
_ = tt.Click("staging")
|
||||
if f.Env != "staging" {
|
||||
t.Fatalf("env %q", f.Env)
|
||||
}
|
||||
_ = tt.Click("Tags")
|
||||
_ = tt.Click("db")
|
||||
if len(f.Tags) != 1 || f.Tags[0] != "db" {
|
||||
t.Fatalf("tags %v", f.Tags)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
_ = tt.Click("Clear filters")
|
||||
if f.Range != 0 || f.Env != "" || len(f.Tags) != 0 || !tt.HasText("All environments") {
|
||||
t.Fatalf("clear: %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardFilterBarDisabledAndRules(t *testing.T) {
|
||||
f := DashboardFilters{Env: "production"}
|
||||
n := 0
|
||||
tt := filterTester(&f, DashboardFilterBarOptions{Envs: filterOpts.Envs, Disabled: true}, &n)
|
||||
_ = tt.Click("Clear filters")
|
||||
_ = tt.Click("Time range")
|
||||
_ = tt.Click("Last 1 h")
|
||||
if n != 0 || f.Env != "production" || f.Range != 0 {
|
||||
t.Fatalf("disabled changed: %+v", f)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("unoffered env did not panic")
|
||||
}
|
||||
}()
|
||||
bad := DashboardFilters{Env: "moon"}
|
||||
filterTester(&bad, filterOpts, &n)
|
||||
}
|
||||
|
||||
func TestDashboardFilterBarSnapshot(t *testing.T) {
|
||||
snapshot(t, "dashboard_filter_bar", 860, 90, func(c *ui.Context) {
|
||||
f := DashboardFilters{Range: time.Hour, Env: "production", Tags: []string{"api"}}
|
||||
ui.Column(c).Padding(16).Children(func() { DashboardFilterBar(c, &f, filterOpts) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestDashboardFilterBarKeyboardAndAncestorDisabled(t *testing.T) {
|
||||
var f DashboardFilters
|
||||
disabled := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/agent"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// 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(core.Msg(c, "devtools.ago"), agent.AgentDuration(d))
|
||||
}
|
||||
|
||||
// devtoolsSeverity shows severity with an icon and word.
|
||||
func devtoolsSeverity(c *ui.Context, s core.Severity, word string) {
|
||||
tone, icon := core.SeverityLook(core.Tokens(c), s)
|
||||
ui.Row(c).Gap(core.Density(c).Unit()).AlignItems(ui.Center).Shrink(0).Children(func() {
|
||||
ui.Icon(c, icon).Size(14, 14).TextColor(tone)
|
||||
ui.Text(c, word).TextColor(tone).FontSize(core.FontSize(c, theme.CaptionSize+1)).FontWeight(600).SingleLine()
|
||||
})
|
||||
}
|
||||
|
||||
// devtoolsTitle is a serif panel title.
|
||||
func devtoolsTitle(c *ui.Context, s string) *ui.Element {
|
||||
return ui.Text(c, s).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).FontWeight(600)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package devtools holds MujicaUI dashboards and developer tools.
|
||||
package devtools
|
||||
@@ -0,0 +1,221 @@
|
||||
package devtools_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chart"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/code"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/devtools"
|
||||
)
|
||||
|
||||
func ExampleStatCard() {
|
||||
value := "12,480"
|
||||
opts := devtools.StatCardOptions{Label: "Requests", Change: 0.12, HasChange: true, Trend: []float64{3, 5, 4, 8}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if devtools.StatCard(c, value, opts).Changed() {
|
||||
fmt.Println("retry")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 720, 480, 1)
|
||||
}
|
||||
|
||||
func ExampleAlertList() {
|
||||
alerts := []devtools.AlertItem{{ID: "cpu", Title: "CPU usage high", Level: devtools.AlertWarning, Source: "worker", At: time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := devtools.AlertList(c, alerts, devtools.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 := devtools.Incident{Title: "API latency", Status: devtools.IncidentMonitoring, Impact: devtools.ImpactMinor, Updates: []devtools.IncidentUpdate{{Status: devtools.IncidentIdentified, Text: "Slow query located"}, {Status: devtools.IncidentMonitoring, Text: "Recovery observed"}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
devtools.IncidentCard(c, incident)
|
||||
}
|
||||
ui.Render(view, 720, 480, 1)
|
||||
}
|
||||
|
||||
func ExampleUptimeBar() {
|
||||
days := []devtools.UptimeDay{{Date: core.Date{Year: 2026, Month: 10, Day: 1}, Uptime: 1}, {Date: core.Date{Year: 2026, Month: 10, Day: 2}, Uptime: 0.91, Note: "Recovery"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := devtools.UptimeBar(c, days, devtools.UptimeBarOptions{Frame: chart.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 := []devtools.ServiceItem{{Name: "API", Health: devtools.ServiceOperational}, {Name: "Search", Health: devtools.ServiceDegraded, Note: "Slow queries"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
devtools.ServiceStatus(c, services, devtools.ServiceStatusOptions{})
|
||||
}
|
||||
ui.Render(view, 720, 480, 1)
|
||||
}
|
||||
|
||||
func ExampleLogStream() {
|
||||
lines := []code.LogLine{{Level: code.LogInfo, Text: "server ready"}, {Level: code.LogWarn, Text: "slow query"}}
|
||||
var state devtools.LogStreamState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if devtools.LogStream(c, lines, &state, devtools.LogStreamOptions{}).Changed() {
|
||||
fmt.Println("filter changed")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 720, 480, 1)
|
||||
}
|
||||
|
||||
func ExampleTraceWaterfall() {
|
||||
spans := []devtools.WaterfallSpan{{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) {
|
||||
core.Use(c, core.Settings{})
|
||||
if devtools.TraceWaterfall(c, spans, devtools.TraceWaterfallOptions{}).Changed() {
|
||||
fmt.Println("span selected")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 720, 480, 1)
|
||||
}
|
||||
|
||||
func ExampleResourceGauge() {
|
||||
readings := []devtools.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
if devtools.ResourceGauge(c, readings, devtools.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 := []devtools.MonitorMetric{{Name: "CPU", Unit: "%", Samples: []float64{42, 51}}, {Name: "Memory", Unit: "GB", Samples: []float64{3.2, 3.4}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if devtools.SystemMonitor(c, times, metrics, devtools.SystemMonitorOptions{}).Changed() {
|
||||
fmt.Println("retry")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 720, 480, 1)
|
||||
}
|
||||
|
||||
func ExampleRefreshIntervalSelector() {
|
||||
interval := 30 * time.Second
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := devtools.RefreshIntervalSelector(c, &interval, devtools.RefreshIntervalSelectorOptions{})
|
||||
if r.Refresh {
|
||||
fmt.Println("refresh")
|
||||
}
|
||||
if r.Changed {
|
||||
fmt.Println("interval", interval)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 720, 480, 1)
|
||||
}
|
||||
|
||||
func ExampleDashboardFilterBar() {
|
||||
var filters devtools.DashboardFilters
|
||||
opts := devtools.DashboardFilterBarOptions{Envs: []string{"production", "staging"}, Tags: []string{"api", "db"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if devtools.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 devtools.JsonViewerState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := devtools.JsonViewer(c, data, &state, devtools.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := devtools.JsonEditor(c, &text, devtools.JsonEditorOptions{})
|
||||
if r.Changed {
|
||||
fmt.Println("valid", r.Valid)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 720, 480, 1)
|
||||
}
|
||||
|
||||
func ExampleSchemaTree() {
|
||||
roots := []devtools.SchemaNode{{Name: "app", Children: []devtools.SchemaNode{{Name: "users", Kind: devtools.SchemaTable, Children: []devtools.SchemaNode{{Name: "id", Kind: devtools.SchemaColumn, Detail: "bigint"}, {Name: "users_pk", Kind: devtools.SchemaIndex, Detail: "(id)"}}}}}}
|
||||
var state devtools.SchemaTreeState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if devtools.SchemaTree(c, &state, roots, devtools.SchemaTreeOptions{}).Changed() {
|
||||
path, _ := state.Selected()
|
||||
fmt.Println("selected", path)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 720, 480, 1)
|
||||
}
|
||||
|
||||
func ExampleApiRequestBuilder() {
|
||||
request := devtools.ApiRequest{Method: "POST", URL: "https://example.com/api", Headers: []devtools.KeyValue{{Key: "Accept", Value: "application/json", Enabled: true}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := devtools.ApiRequestBuilder(c, &request, devtools.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 := &devtools.ApiResponse{Status: 200, Duration: 182 * time.Millisecond, Size: 32, Body: `{"ready":true}`, Headers: []devtools.KeyValue{{Key: "Content-Type", Value: "application/json"}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if devtools.ResponseViewer(c, response, devtools.ResponseViewerOptions{}).Changed() {
|
||||
fmt.Println("retry")
|
||||
}
|
||||
}
|
||||
ui.Render(view, 720, 480, 1)
|
||||
}
|
||||
|
||||
func ExampleKeyValueInput() {
|
||||
rows := []devtools.KeyValue{{Key: "Accept", Value: "application/json", Enabled: true}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if devtools.KeyValueInput(c, &rows, devtools.KeyValueInputOptions{}).Changed() {
|
||||
fmt.Println("rows", len(rows))
|
||||
}
|
||||
}
|
||||
ui.Render(view, 720, 480, 1)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
|
||||
)
|
||||
|
||||
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
|
||||
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
|
||||
t.Helper()
|
||||
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
|
||||
}
|
||||
|
||||
// overlayUntil draws frames until cond holds, for at most 5 s of wall time.
|
||||
func overlayUntil(tt *ui.Tester, cond func() bool) bool {
|
||||
end := time.Now().Add(5 * time.Second)
|
||||
for !cond() {
|
||||
if time.Now().After(end) {
|
||||
return false
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
tt.Frame()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// snapshotAfter is snapshot with drive sending input before the capture.
|
||||
func snapshotAfter(t *testing.T, name string, w, h int, view func(c *ui.Context), drive func(tt *ui.Tester)) {
|
||||
t.Helper()
|
||||
snaptest.Run(t, name, w, h, snapshotTheme, view, drive)
|
||||
}
|
||||
|
||||
func snapshotTheme(c *ui.Context, mode string) {
|
||||
m := core.Light
|
||||
if mode == "dark" {
|
||||
m = core.Dark
|
||||
}
|
||||
core.Use(c, core.Settings{Mode: m})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/agent"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// IncidentStatus tracks investigation; zero means IncidentInvestigating.
|
||||
type IncidentStatus int
|
||||
|
||||
const (
|
||||
IncidentInvestigating IncidentStatus = iota
|
||||
IncidentIdentified
|
||||
IncidentMonitoring
|
||||
IncidentResolved
|
||||
)
|
||||
|
||||
// IncidentImpact tracks severity; zero means ImpactNone.
|
||||
type IncidentImpact int
|
||||
|
||||
const (
|
||||
ImpactNone IncidentImpact = iota
|
||||
ImpactMinor
|
||||
ImpactMajor
|
||||
ImpactCritical
|
||||
)
|
||||
|
||||
// IncidentUpdate is one timeline entry, oldest first.
|
||||
type IncidentUpdate struct {
|
||||
At time.Time
|
||||
Status IncidentStatus
|
||||
Text string
|
||||
}
|
||||
|
||||
// Incident is what the caller reports about an incident.
|
||||
type Incident struct {
|
||||
Title string
|
||||
Status IncidentStatus
|
||||
Impact IncidentImpact
|
||||
Started time.Time
|
||||
Updates []IncidentUpdate
|
||||
}
|
||||
|
||||
// IncidentCard shows an incident's title, status, impact and update timeline.
|
||||
func IncidentCard(c *ui.Context, inc Incident) *ui.Element {
|
||||
if inc.Title == "" {
|
||||
panic("mujica: IncidentCard needs a Title")
|
||||
}
|
||||
status := incidentStatusWord(c, inc.Status)
|
||||
if inc.Impact < ImpactNone || inc.Impact > ImpactCritical {
|
||||
panic(fmt.Sprintf("mujica: unknown IncidentImpact %d", int(inc.Impact)))
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
sev := [...]core.Severity{core.SeverityInfo, core.SeverityWarning, core.SeverityError, core.SeverityError}[inc.Impact]
|
||||
impact := core.Msg(c, [...]string{"incident_card.none", "incident_card.minor", "incident_card.major", "incident_card.critical"}[inc.Impact])
|
||||
card := agent.AgentCard(c).Label(inc.Title + ", " + status)
|
||||
card.Children(func() {
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
devtoolsTitle(c, inc.Title).Grow(1).Shrink(1)
|
||||
if inc.Status == IncidentResolved {
|
||||
devtoolsSeverity(c, core.SeveritySuccess, status)
|
||||
} else {
|
||||
devtoolsSeverity(c, core.SeverityWarning, status)
|
||||
}
|
||||
})
|
||||
ui.Row(c).Gap(u * 3).AlignItems(ui.Center).Children(func() {
|
||||
devtoolsSeverity(c, sev, fmt.Sprintf(core.Msg(c, "incident_card.impact"), impact))
|
||||
if !inc.Started.IsZero() {
|
||||
agent.AgentCaption(c, fmt.Sprintf(core.Msg(c, "incident_card.started"), inc.Started.Format("2006-01-02 15:04")))
|
||||
}
|
||||
})
|
||||
if len(inc.Updates) > 0 {
|
||||
agent.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
|
||||
}
|
||||
|
||||
// incidentUpdate draws one timeline entry on a diamond rail.
|
||||
func incidentUpdate(c *ui.Context, k theme.Tokens, up IncidentUpdate, last bool) {
|
||||
u := core.Density(c).Unit()
|
||||
ui.Row(c).Gap(u * 3).AlignItems(ui.Stretch).Children(func() {
|
||||
ui.Box(c).Width(12).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
cx := internal.Snap(p, r.X+r.W/2)
|
||||
cy := internal.Snap(p, r.Y+9)
|
||||
if !last {
|
||||
p.Line(cx, cy+5, cx, r.Y+r.H, 1, k.ControlBorder)
|
||||
}
|
||||
p.FillPath(internal.Diamond(cx, cy, 9), k.Ornament)
|
||||
})
|
||||
ui.Column(c).Grow(1).Shrink(1).Gap(u/2).Padding(0, 0, u*2, 0).Children(func() {
|
||||
ui.Row(c).Gap(u * 2).Children(func() {
|
||||
ui.Text(c, incidentStatusWord(c, up.Status)).FontWeight(600)
|
||||
if !up.At.IsZero() {
|
||||
agent.AgentCaption(c, up.At.Format("15:04"))
|
||||
}
|
||||
})
|
||||
ui.Text(c, up.Text).TextColor(k.TextMuted)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func incidentStatusWord(c *ui.Context, s IncidentStatus) string {
|
||||
if s < IncidentInvestigating || s > IncidentResolved {
|
||||
panic(fmt.Sprintf("mujica: unknown IncidentStatus %d", int(s)))
|
||||
}
|
||||
return core.Msg(c, [...]string{"incident_card.investigating", "incident_card.identified", "incident_card.monitoring", "incident_card.resolved"}[s])
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func incidentSample() Incident {
|
||||
t0 := time.Date(2026, 10, 5, 21, 4, 0, 0, time.UTC)
|
||||
return Incident{Title: "Checkout errors in eu-west", Status: IncidentMonitoring, Impact: ImpactMajor, Started: t0,
|
||||
Updates: []IncidentUpdate{
|
||||
{At: t0, Status: IncidentInvestigating, Text: "Error rate rose to 8%."},
|
||||
{At: t0.Add(12 * time.Minute), Status: IncidentIdentified, Text: "A bad config push to the payment proxy."},
|
||||
{At: t0.Add(30 * time.Minute), Status: IncidentMonitoring, Text: "Rolled back; errors are falling."},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestIncidentCardShowsStatusImpactTimeline(t *testing.T) {
|
||||
inc := incidentSample()
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
IncidentCard(c, inc)
|
||||
}, 560, 500)
|
||||
for _, s := range []string{"Checkout errors in eu-west", "Monitoring", "Impact: major", "Started 2026-10-05 21:04",
|
||||
"Investigating", "21:16", "Rolled back; errors are falling."} {
|
||||
if !tt.HasText(s) {
|
||||
t.Fatalf("missing %q in %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
inc.Status = IncidentResolved
|
||||
tt.Frame()
|
||||
if !tt.HasText("Resolved") {
|
||||
t.Fatal("status did not update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncidentCardRejectsUnknownStatus(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
IncidentCard(c, Incident{Title: "x", Status: 9})
|
||||
}, 200, 200)
|
||||
}
|
||||
|
||||
func TestIncidentCardSnapshot(t *testing.T) {
|
||||
snapshot(t, "incident_card", 540, 360, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { IncidentCard(c, incidentSample()) })
|
||||
})
|
||||
}
|
||||
|
||||
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) { core.Use(c, core.Settings{}); IncidentCard(c, inc) }, 560, 500)
|
||||
if n := len(tt.Texts()); n > 500 {
|
||||
t.Fatalf("built %d texts for 100000 updates", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/code"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// JsonEditorOptions configure a JsonEditor.
|
||||
type JsonEditorOptions struct {
|
||||
// Label names the editor; empty uses "JSON".
|
||||
Label string
|
||||
// MinHeight and MaxHeight default to 160 and 400 DIPs.
|
||||
MinHeight, MaxHeight float32
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// JsonEditorResult reports this frame's events and the parse state.
|
||||
type JsonEditorResult struct {
|
||||
Element *ui.Element
|
||||
Changed bool
|
||||
// Valid reports *text parses as one JSON value.
|
||||
Valid bool
|
||||
// Line and Column locate errors; valid documents use zero.
|
||||
Line, Column int
|
||||
}
|
||||
|
||||
type jsonEditorCheck struct {
|
||||
src string
|
||||
err string
|
||||
line, column int
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = core.Msg(c, "json_editor.label")
|
||||
}
|
||||
minH, maxH := opts.MinHeight, opts.MaxHeight
|
||||
if minH == 0 {
|
||||
minH = 160
|
||||
}
|
||||
if maxH == 0 {
|
||||
maxH = 400
|
||||
}
|
||||
if minH > maxH {
|
||||
panic("mujica: JsonEditor MinHeight exceeds MaxHeight")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
var r JsonEditorResult
|
||||
r.Element = ui.Column(c).Shrink(0).Gap(u * 2).Disabled(opts.Disabled)
|
||||
chk := ui.Local(r.Element, "mujica.jsoneditor", func() jsonEditorCheck { return jsonEditorCheck{src: "\x00"} })
|
||||
locked := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
jsonEditorValidate(chk, *text)
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, label).FontWeight(600).Grow(1)
|
||||
if input.Button(c, core.Msg(c, "json_editor.format"), input.ButtonOptions{Variant: input.Secondary, Disabled: locked || chk.err != ""}).Clicked() && !locked {
|
||||
var b bytes.Buffer
|
||||
if json.Indent(&b, []byte(*text), "", " ") == nil && b.String() != *text {
|
||||
*text, r.Changed = b.String(), true
|
||||
slog.Debug("mujica: json_editor format")
|
||||
}
|
||||
}
|
||||
})
|
||||
in := input.TextArea(c, text, input.TextAreaOptions{Label: label, MinHeight: minH, MaxHeight: maxH, Disabled: locked, Error: chk.err})
|
||||
in.Input.Font(theme.MonoFont).FontSize(core.FontSize(c, code.CodeSize))
|
||||
r.Changed = r.Changed || in.Changed()
|
||||
jsonEditorValidate(chk, *text)
|
||||
if chk.err != "" {
|
||||
lines := strings.Split(*text, "\n")
|
||||
if chk.line >= 1 && chk.line <= len(lines) {
|
||||
jsonEditorExcerpt(c, lines[chk.line-1], chk.line, chk.column)
|
||||
}
|
||||
return
|
||||
}
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).Role(ui.RoleStatus).Children(func() {
|
||||
ui.Icon(c, icons.Must("circle-check")).Size(14, 14).TextColor(k.Success)
|
||||
ui.Text(c, core.Msg(c, "json_editor.valid")).TextColor(k.Success).FontSize(core.FontSize(c, theme.CaptionSize+1))
|
||||
})
|
||||
})
|
||||
r.Valid, r.Line, r.Column = chk.err == "", chk.line, chk.column
|
||||
return r
|
||||
}
|
||||
|
||||
// jsonEditorValidate parses text once per change and locates any error.
|
||||
func jsonEditorValidate(chk *jsonEditorCheck, text string) {
|
||||
if chk.src == text {
|
||||
return
|
||||
}
|
||||
chk.src, chk.err, chk.line, chk.column = text, "", 0, 0
|
||||
dec := json.NewDecoder(strings.NewReader(text))
|
||||
dec.UseNumber()
|
||||
var v any
|
||||
err := dec.Decode(&v)
|
||||
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
|
||||
}
|
||||
var syn *json.SyntaxError
|
||||
var typ *json.UnmarshalTypeError
|
||||
switch {
|
||||
case errors.As(err, &syn):
|
||||
offset = syn.Offset
|
||||
case errors.As(err, &typ):
|
||||
offset = typ.Offset
|
||||
case strings.TrimSpace(text) == "":
|
||||
err, offset = errors.New("empty document"), 0
|
||||
default:
|
||||
if offset == 0 {
|
||||
offset = int64(len(text))
|
||||
}
|
||||
}
|
||||
before := text[:min(int(offset), len(text))]
|
||||
chk.line = strings.Count(before, "\n") + 1
|
||||
chk.column = len([]rune(before[strings.LastIndex(before, "\n")+1:]))
|
||||
chk.column = max(chk.column, 1)
|
||||
chk.err = fmt.Sprintf("%d:%d %v", chk.line, chk.column, err)
|
||||
}
|
||||
|
||||
// jsonEditorExcerpt shows an error line and column caret.
|
||||
func jsonEditorExcerpt(c *ui.Context, line string, n, col int) {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
ui.Column(c).Padding(u*2, u*3).Radius(theme.ControlRadius).Background(k.Background).Border(theme.BorderWidth, k.Border).
|
||||
Label(fmt.Sprintf(core.Msg(c, "json_editor.at"), n, col)).Children(func() {
|
||||
spans := []ui.Span{{Text: fmt.Sprintf("%4d | ", n), Color: k.TextMuted}}
|
||||
toks, _ := internal.Highlight("json", line, false)
|
||||
for _, t := range toks {
|
||||
spans = append(spans, ui.Span{Text: t.Text, Color: code.CodeTokenColor(k, t.Kind)})
|
||||
}
|
||||
ui.RichText(c, spans...).Font(theme.MonoFont).FontSize(core.FontSize(c, code.CodeSize)).SingleLine()
|
||||
code.CodeMono(c, strings.Repeat(" ", 7+col-1)+"^").TextColor(k.Danger)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func jsonEditorTester(text *string, opts JsonEditorOptions, last *JsonEditorResult) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
*last = JsonEditor(c, text, opts)
|
||||
}, 600, 460)
|
||||
}
|
||||
|
||||
func TestJsonEditorFormatAndValid(t *testing.T) {
|
||||
text := `{"hall":"east","guests":[1,2]}`
|
||||
var r JsonEditorResult
|
||||
tt := jsonEditorTester(&text, JsonEditorOptions{}, &r)
|
||||
if !r.Valid || !tt.HasText("Valid JSON") {
|
||||
t.Fatalf("valid: %+v", r)
|
||||
}
|
||||
_ = tt.Click("Format")
|
||||
if text != "{\n \"hall\": \"east\",\n \"guests\": [\n 1,\n 2\n ]\n}" {
|
||||
t.Fatalf("formatted %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJsonEditorErrorLocation(t *testing.T) {
|
||||
text := "{\n \"hall\": \"east\",\n \"guests\": [1, 2,]\n}"
|
||||
var r JsonEditorResult
|
||||
tt := jsonEditorTester(&text, JsonEditorOptions{}, &r)
|
||||
if r.Valid || r.Line != 3 || r.Column != 19 {
|
||||
t.Fatalf("location %+v", r)
|
||||
}
|
||||
if !tt.HasText(" 3 | ") || !tt.HasText(" ^") {
|
||||
t.Fatalf("excerpt: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Format") // disabled while invalid
|
||||
if r.Changed {
|
||||
t.Fatal("formatted invalid JSON")
|
||||
}
|
||||
text = "{} {}"
|
||||
tt.Frame()
|
||||
if r.Valid || r.Line != 1 {
|
||||
t.Fatalf("trailing value: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJsonEditorTypingAndDisabled(t *testing.T) {
|
||||
text := ""
|
||||
var r JsonEditorResult
|
||||
tt := jsonEditorTester(&text, JsonEditorOptions{}, &r)
|
||||
if r.Valid {
|
||||
t.Fatal("empty text is valid")
|
||||
}
|
||||
tt.Key(0, ui.KeyTab) // Format is disabled while invalid
|
||||
tt.Type(`[true]`)
|
||||
if !r.Valid || text != "[true]" {
|
||||
t.Fatalf("typed %q valid %v", text, r.Valid)
|
||||
}
|
||||
tt = jsonEditorTester(&text, JsonEditorOptions{Disabled: true}, &r)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Type("x")
|
||||
if text != "[true]" {
|
||||
t.Fatalf("disabled edited: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJsonEditorSnapshot(t *testing.T) {
|
||||
snapshot(t, "json_editor", 560, 360, func(c *ui.Context) {
|
||||
text := "{\n \"hall\": \"east wing\",\n \"guests\": [1, 2,]\n}"
|
||||
ui.Column(c).Padding(16).Children(func() { JsonEditor(c, &text, JsonEditorOptions{MinHeight: 120}) })
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// jsonNode is one value of a parsed document, in document order.
|
||||
type jsonNode struct {
|
||||
key, path string
|
||||
// value is a scalar's JSON text, or "{}" / "[]" for containers.
|
||||
value string
|
||||
depth int
|
||||
parent int
|
||||
// end is the index after this node's subtree; count is its child count.
|
||||
end, count int
|
||||
}
|
||||
|
||||
func (n jsonNode) container() bool { return n.value == "{}" || n.value == "[]" }
|
||||
|
||||
func (n jsonNode) label() string {
|
||||
if n.key == "" {
|
||||
return n.value
|
||||
}
|
||||
return n.key + ": " + n.value
|
||||
}
|
||||
|
||||
// jsonTree is a document flattened depth-first.
|
||||
type jsonTree struct{ nodes []jsonNode }
|
||||
|
||||
var jsonIdent = regexp.MustCompile(`^[A-Za-z_$][A-Za-z0-9_$]*$`)
|
||||
|
||||
// parseJSONTree flattens data keeping key order; it fails on invalid or trailing JSON.
|
||||
func parseJSONTree(data string) (jsonTree, error) {
|
||||
dec := json.NewDecoder(bytes.NewReader([]byte(data)))
|
||||
dec.UseNumber()
|
||||
var t jsonTree
|
||||
if err := jsonParseValue(dec, &t, "", "$", 0, -1); err != nil {
|
||||
return jsonTree{}, err
|
||||
}
|
||||
if _, err := dec.Token(); err != io.EOF {
|
||||
return jsonTree{}, fmt.Errorf("unexpected data after the value")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func jsonParseValue(dec *json.Decoder, t *jsonTree, key, path string, depth, parent int) error {
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
at := len(t.nodes)
|
||||
t.nodes = append(t.nodes, jsonNode{key: key, path: path, depth: depth, parent: parent})
|
||||
switch v := tok.(type) {
|
||||
case json.Delim:
|
||||
t.nodes[at].value = map[json.Delim]string{'{': "{}", '[': "[]"}[v]
|
||||
for i := 0; dec.More(); i++ {
|
||||
childKey, childPath := "", fmt.Sprintf("%s[%d]", path, i)
|
||||
if v == '{' {
|
||||
kt, err := dec.Token()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
childKey = kt.(string)
|
||||
childPath = path + "." + childKey
|
||||
if !jsonIdent.MatchString(childKey) {
|
||||
childPath = path + "[" + strconv.Quote(childKey) + "]"
|
||||
}
|
||||
}
|
||||
if err := jsonParseValue(dec, t, childKey, childPath, depth+1, at); err != nil {
|
||||
return err
|
||||
}
|
||||
t.nodes[at].count++
|
||||
}
|
||||
if _, err := dec.Token(); err != nil { // the closing delimiter
|
||||
return err
|
||||
}
|
||||
case string:
|
||||
t.nodes[at].value = strconv.Quote(v)
|
||||
case json.Number:
|
||||
t.nodes[at].value = v.String()
|
||||
case bool:
|
||||
t.nodes[at].value = strconv.FormatBool(v)
|
||||
case nil:
|
||||
t.nodes[at].value = "null"
|
||||
}
|
||||
t.nodes[at].end = len(t.nodes)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/code"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// JsonViewerState is a JsonViewer's open nodes, search and choice.
|
||||
type JsonViewerState struct {
|
||||
// Query finds keys and values; matches open their ancestors.
|
||||
Query string
|
||||
// Current is the chosen match.
|
||||
Current int
|
||||
open map[string]bool
|
||||
tree jsonTree
|
||||
src string
|
||||
err error
|
||||
rows []int // visible node indexes
|
||||
stale bool // rows need rebuilding
|
||||
searched string
|
||||
matches []int
|
||||
sel int
|
||||
list ui.ListState
|
||||
}
|
||||
|
||||
// JsonViewerOptions configure a JsonViewer.
|
||||
type JsonViewerOptions struct {
|
||||
// Height of the tree; zero means 360.
|
||||
Height float32
|
||||
// Expanded opens every node at first.
|
||||
Expanded bool
|
||||
}
|
||||
|
||||
// JsonViewerResult reports this frame's events.
|
||||
type JsonViewerResult struct {
|
||||
Element *ui.Element
|
||||
// Copied is the path written to the clipboard, else "".
|
||||
Copied string
|
||||
}
|
||||
|
||||
// JsonViewer shows 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")
|
||||
}
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = 360
|
||||
}
|
||||
if s.open == nil || data != s.src {
|
||||
s.src, s.open, s.stale, s.searched = data, map[string]bool{"$": true}, true, "\x00"
|
||||
s.tree, s.err = parseJSONTree(data)
|
||||
if opts.Expanded {
|
||||
for _, n := range s.tree.nodes {
|
||||
s.open[n.path] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
u := core.Density(c).Unit()
|
||||
var r JsonViewerResult
|
||||
r.Element = code.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(core.Tokens(c).Danger).Role(ui.RoleStatus).Children(func() {
|
||||
ui.Icon(c, icons.Must("circle-alert")).Size(16, 16)
|
||||
ui.Text(c, fmt.Sprintf(core.Msg(c, "json_viewer.invalid"), s.err))
|
||||
})
|
||||
return
|
||||
}
|
||||
jsonViewerSearch(s)
|
||||
code.CodeHeader(c, func() {
|
||||
moved := input.SearchInput(c, &s.Query, input.SearchInputOptions{Find: true, Matches: len(s.matches), Current: &s.Current}).Moved()
|
||||
if moved && len(s.matches) > 0 {
|
||||
s.sel = s.matches[s.Current]
|
||||
s.stale = true
|
||||
}
|
||||
path := ""
|
||||
if s.sel >= 0 && s.sel < len(s.tree.nodes) {
|
||||
path = s.tree.nodes[s.sel].path
|
||||
}
|
||||
code.CodeMono(c, path).Grow(1).MinWidth(0).SingleLine()
|
||||
if input.Button(c, core.Msg(c, "json_viewer.copy"), input.ButtonOptions{Variant: input.Ghost, Icon: icons.Must("copy"), Disabled: path == ""}).Clicked() && path != "" {
|
||||
c.WriteClipboard(path)
|
||||
r.Copied = path
|
||||
slog.Debug("mujica: json_viewer copy path", "path", path)
|
||||
}
|
||||
})
|
||||
jsonViewerRows(s)
|
||||
at := -1
|
||||
for i, n := range s.rows {
|
||||
if n == s.sel {
|
||||
at = i
|
||||
}
|
||||
}
|
||||
was := at
|
||||
s.list.Selected = &at
|
||||
s.list.Label = func(i int) string { return s.tree.nodes[s.rows[i]].label() }
|
||||
list := code.CodeRows(c, &s.list, len(s.rows), func(i int) { jsonViewerRow(c, s, s.rows[i]) })
|
||||
if !list.IsDisabled() && at != was && at >= 0 {
|
||||
s.sel = s.rows[at]
|
||||
}
|
||||
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]
|
||||
s.stale = true
|
||||
}
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// jsonViewerSearch finds matches and opens their ancestors.
|
||||
func jsonViewerSearch(s *JsonViewerState) {
|
||||
if s.searched == s.Query {
|
||||
return
|
||||
}
|
||||
s.searched = s.Query
|
||||
s.matches = s.matches[:0]
|
||||
q := strings.ToLower(s.Query)
|
||||
if q != "" {
|
||||
for i, n := range s.tree.nodes {
|
||||
if strings.Contains(strings.ToLower(n.key), q) || strings.Contains(strings.ToLower(n.value), q) {
|
||||
s.matches = append(s.matches, i)
|
||||
for p := n.parent; p >= 0; p = s.tree.nodes[p].parent {
|
||||
s.open[s.tree.nodes[p].path] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.Current = max(0, min(s.Current, len(s.matches)-1))
|
||||
if len(s.matches) > 0 {
|
||||
s.sel = s.matches[s.Current]
|
||||
}
|
||||
s.stale = true
|
||||
}
|
||||
|
||||
// jsonViewerRows rebuilds visible indices when nodes change.
|
||||
func jsonViewerRows(s *JsonViewerState) {
|
||||
if !s.stale {
|
||||
return
|
||||
}
|
||||
s.stale = false
|
||||
s.rows = s.rows[:0]
|
||||
for i := 0; i < len(s.tree.nodes); {
|
||||
n := s.tree.nodes[i]
|
||||
s.rows = append(s.rows, i)
|
||||
if n.container() && !s.open[n.path] {
|
||||
i = n.end
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
if i := s.list.Selected; i != nil && *i >= 0 {
|
||||
for at, n := range s.rows {
|
||||
if n == s.sel {
|
||||
s.list.ScrollIntoView(at)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jsonViewerRow shows disclosure, key and value.
|
||||
func jsonViewerRow(c *ui.Context, s *JsonViewerState, i int) {
|
||||
k := core.Tokens(c)
|
||||
n := s.tree.nodes[i]
|
||||
ui.Box(c).Width(float32(n.depth) * 16).Shrink(0)
|
||||
mark := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Center().Shrink(0)
|
||||
if n.container() {
|
||||
name := "chevron-right"
|
||||
if s.open[n.path] {
|
||||
name = "chevron-down"
|
||||
}
|
||||
mark.Role(ui.RoleDisclosure).Label(n.path).Cursor(ui.CursorPointer)
|
||||
mark.Children(func() { ui.Icon(c, icons.Must(name)).Size(14, 14).TextColor(k.TextMuted) })
|
||||
if !mark.IsDisabled() && mark.Clicked() {
|
||||
s.open[n.path], s.stale = !s.open[n.path], true
|
||||
}
|
||||
}
|
||||
var spans []ui.Span
|
||||
if n.key != "" {
|
||||
for _, sp := range code.CodeMatchSpans(c, n.key, s.Query) {
|
||||
sp.Color, sp.Weight = k.AccentText, max(sp.Weight, 600)
|
||||
spans = append(spans, sp)
|
||||
}
|
||||
spans = append(spans, ui.Span{Text: ": ", Color: k.TextMuted})
|
||||
}
|
||||
switch {
|
||||
case n.container() && s.open[n.path]:
|
||||
spans = append(spans, ui.Span{Text: n.value[:1], Color: k.TextMuted})
|
||||
case n.container():
|
||||
spans = append(spans, ui.Span{Text: fmt.Sprintf(core.Msg(c, "json_viewer.items"), n.value[:1], n.count, n.value[1:]), Color: k.TextMuted})
|
||||
default:
|
||||
tok, _ := internal.Highlight("json", n.value, false)
|
||||
col := k.Text
|
||||
if len(tok) > 0 {
|
||||
col = code.CodeTokenColor(k, tok[0].Kind)
|
||||
}
|
||||
for _, sp := range code.CodeMatchSpans(c, n.value, s.Query) {
|
||||
sp.Color = col
|
||||
spans = append(spans, sp)
|
||||
}
|
||||
}
|
||||
ui.RichText(c, spans...).Font(theme.MonoFont).FontSize(core.FontSize(c, code.CodeSize)).SingleLine().Grow(1).MinWidth(0)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
const jsonSample = `{"ball":{"hall":"east wing","guests":[{"name":"Sakiko","seat":1},{"name":"Uika","seat":2}],"wine":null},"open":true,"two words":3.5}`
|
||||
|
||||
func jsonViewerTester(data *string, s *JsonViewerState, opts JsonViewerOptions, copied *[]string) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if r := JsonViewer(c, *data, s, opts); r.Copied != "" {
|
||||
*copied = append(*copied, r.Copied)
|
||||
}
|
||||
}, 640, 460)
|
||||
}
|
||||
|
||||
func TestJsonTreePaths(t *testing.T) {
|
||||
tr, err := parseJSONTree(jsonSample)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var paths []string
|
||||
for _, n := range tr.nodes {
|
||||
paths = append(paths, n.path)
|
||||
}
|
||||
want := `$ $.ball $.ball.hall $.ball.guests $.ball.guests[0] $.ball.guests[0].name $.ball.guests[0].seat $.ball.guests[1] $.ball.guests[1].name $.ball.guests[1].seat $.ball.wine $.open $["two words"]`
|
||||
if strings.Join(paths, " ") != want {
|
||||
t.Fatalf("paths %v", paths)
|
||||
}
|
||||
if tr.nodes[3].count != 2 || tr.nodes[3].end != 10 {
|
||||
t.Fatalf("guests node %+v", tr.nodes[3])
|
||||
}
|
||||
if _, err := parseJSONTree(`{"a":1} x`); err == nil {
|
||||
t.Fatal("trailing data accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJsonViewerFoldKeyboardCopy(t *testing.T) {
|
||||
data := jsonSample
|
||||
var s JsonViewerState
|
||||
var copied []string
|
||||
tt := jsonViewerTester(&data, &s, JsonViewerOptions{}, &copied)
|
||||
if !tt.HasText("ball: { 3 items }") || tt.HasText(`hall: "east wing"`) {
|
||||
t.Fatalf("closed: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("$.ball") // its fold mark
|
||||
if !tt.HasText(`hall: "east wing"`) || !tt.HasText("guests: [ 2 items ]") {
|
||||
t.Fatalf("open: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click(`hall: "east wing"`)
|
||||
_ = tt.Click("Copy path")
|
||||
if len(copied) != 1 || copied[0] != "$.ball.hall" || tt.Clipboard() != "$.ball.hall" {
|
||||
t.Fatalf("copy: %v", copied)
|
||||
}
|
||||
_ = tt.Click(`hall: "east wing"`) // back on the list
|
||||
tt.Key(0, ui.KeyDown) // guests
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !tt.HasText("[0]: { 2 items }") && !tt.HasText("{ 2 items }") {
|
||||
t.Fatalf("Right did not open guests: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
if tt.HasText("{ 2 items }") {
|
||||
t.Fatal("Left did not close guests")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJsonViewerSearchOpensMatches(t *testing.T) {
|
||||
data := jsonSample
|
||||
var s JsonViewerState
|
||||
var copied []string
|
||||
tt := jsonViewerTester(&data, &s, JsonViewerOptions{}, &copied)
|
||||
_ = tt.Click("Find")
|
||||
tt.Type("uika")
|
||||
if !tt.HasText("1 of 1") || !tt.HasText("$.ball.guests[1].name") {
|
||||
t.Fatalf("search: %q", tt.Texts())
|
||||
}
|
||||
data = `{"broken": }`
|
||||
tt.Frame()
|
||||
found := false
|
||||
for _, x := range tt.Texts() {
|
||||
found = found || strings.HasPrefix(x, "Invalid JSON")
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("parse error: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestJsonViewerLargeDocumentVirtualized(t *testing.T) {
|
||||
var b strings.Builder
|
||||
b.WriteString("[")
|
||||
for i := range 100000 {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, "%d", i)
|
||||
}
|
||||
b.WriteString("]")
|
||||
data := b.String()
|
||||
var s JsonViewerState
|
||||
var copied []string
|
||||
tt := jsonViewerTester(&data, &s, JsonViewerOptions{Expanded: true}, &copied)
|
||||
if n := len(tt.Texts()); n > 300 {
|
||||
t.Fatalf("%d texts for 100000 items", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJsonViewerSnapshot(t *testing.T) {
|
||||
snapshot(t, "json_viewer", 600, 360, func(c *ui.Context) {
|
||||
var s JsonViewerState
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
JsonViewer(c, jsonSample, &s, JsonViewerOptions{Expanded: true, Height: 320})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestJsonViewerAncestorDisabledKeepsTreeAndClipboard(t *testing.T) {
|
||||
data := jsonSample
|
||||
var s JsonViewerState
|
||||
disabled := false
|
||||
copied := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/agent"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
// KeyValue holds an editable pair and enable flag.
|
||||
type KeyValue struct {
|
||||
Key, Value string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// KeyValueInputOptions configure a KeyValueInput.
|
||||
type KeyValueInputOptions struct {
|
||||
// KeyPlaceholder and ValuePlaceholder default to "Key" and "Value".
|
||||
KeyPlaceholder, ValuePlaceholder string
|
||||
// Label names the editor for assistive technology.
|
||||
Label string
|
||||
Disabled bool
|
||||
// MaxHeight caps the rows; zero means 320.
|
||||
MaxHeight float32
|
||||
}
|
||||
|
||||
// KeyValueInput edits virtualized pairs with enable, add and remove.
|
||||
func KeyValueInput(c *ui.Context, rows *[]KeyValue, opts KeyValueInputOptions) core.ChoiceResult {
|
||||
if rows == nil || opts.MaxHeight < 0 {
|
||||
panic("mujica: KeyValueInput needs rows")
|
||||
}
|
||||
kp, vp := opts.KeyPlaceholder, opts.ValuePlaceholder
|
||||
if kp == "" {
|
||||
kp = core.Msg(c, "key_value_input.key")
|
||||
}
|
||||
if vp == "" {
|
||||
vp = core.Msg(c, "key_value_input.value")
|
||||
}
|
||||
u := core.Density(c).Unit()
|
||||
var r core.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() {
|
||||
agent.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(core.Msg(c, "key_value_input.row"), i+1)
|
||||
on := kv.Enabled
|
||||
box := input.Checkbox(c, &on, "", input.CheckboxOptions{Disabled: locked}).Label(name)
|
||||
if box.Changed() && !locked {
|
||||
kv.Enabled, r = on, core.NewChoiceResult(r.Element, true)
|
||||
}
|
||||
k := input.TextInput(c, &kv.Key, input.TextInputOptions{Placeholder: kp, Label: name + " " + kp, Disabled: locked})
|
||||
k.Element.Grow(1).Basis(0)
|
||||
v := input.TextInput(c, &kv.Value, input.TextInputOptions{Placeholder: vp, Label: name + " " + vp, Disabled: locked})
|
||||
v.Element.Grow(2).Basis(0)
|
||||
r = core.NewChoiceResult(r.Element, r.Changed() || k.Changed() || v.Changed())
|
||||
if core.IconAction(c, icons.Must("x"), fmt.Sprintf(core.Msg(c, "key_value_input.remove"), i+1)).Disabled(locked).Clicked() && !locked {
|
||||
remove = i
|
||||
}
|
||||
})
|
||||
}).Shrink(1).MaxHeight(maxH)
|
||||
if input.Button(c, core.Msg(c, "key_value_input.add"), input.ButtonOptions{Variant: input.Ghost, Icon: icons.Must("plus"), Disabled: locked}).AlignSelf(ui.Start).Clicked() && !locked {
|
||||
*rows = append(*rows, KeyValue{Enabled: true})
|
||||
r = core.NewChoiceResult(r.Element, true)
|
||||
}
|
||||
})
|
||||
if remove >= 0 {
|
||||
*rows = append((*rows)[:remove:remove], (*rows)[remove+1:]...)
|
||||
r = core.NewChoiceResult(r.Element, true)
|
||||
}
|
||||
if r.Changed() {
|
||||
slog.Debug("mujica: key_value_input change", "rows", len(*rows))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// keyValueEnabled returns the enabled pairs with a key.
|
||||
func keyValueEnabled(rows []KeyValue) []KeyValue {
|
||||
var out []KeyValue
|
||||
for _, kv := range rows {
|
||||
if kv.Enabled && kv.Key != "" {
|
||||
out = append(out, kv)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func kvTester(rows *[]KeyValue, opts KeyValueInputOptions, changes *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if KeyValueInput(c, rows, opts).Changed() {
|
||||
*changes++
|
||||
}
|
||||
}, 600, 300)
|
||||
}
|
||||
|
||||
func TestKeyValueInputEditToggleAddRemove(t *testing.T) {
|
||||
rows := []KeyValue{{Key: "page", Value: "1", Enabled: true}, {Key: "debug", Value: "true"}}
|
||||
n := 0
|
||||
tt := kvTester(&rows, KeyValueInputOptions{}, &n)
|
||||
_ = tt.Click("Row 2") // its enable box
|
||||
if !rows[1].Enabled || n != 1 {
|
||||
t.Fatalf("toggle: %+v", rows)
|
||||
}
|
||||
_ = tt.Click("Add row")
|
||||
if len(rows) != 3 || !rows[2].Enabled {
|
||||
t.Fatalf("add: %+v", rows)
|
||||
}
|
||||
_ = tt.Click("Row 3 Key")
|
||||
tt.Type("sort")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Type("name")
|
||||
if rows[2].Key != "sort" || rows[2].Value != "name" {
|
||||
t.Fatalf("typing: %+v", rows[2])
|
||||
}
|
||||
_ = tt.Click("Remove row 1")
|
||||
if len(rows) != 2 || rows[0].Key != "debug" {
|
||||
t.Fatalf("remove: %+v", rows)
|
||||
}
|
||||
if got := keyValueEnabled(rows); len(got) != 2 {
|
||||
t.Fatalf("enabled pairs: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyValueInputDisabled(t *testing.T) {
|
||||
rows := []KeyValue{{Key: "page", Value: "1"}}
|
||||
n := 0
|
||||
tt := kvTester(&rows, KeyValueInputOptions{Disabled: true}, &n)
|
||||
_ = tt.Click("Row 1")
|
||||
_ = tt.Click("Add row")
|
||||
_ = tt.Click("Remove row 1")
|
||||
_ = tt.Click("Row 1 Key")
|
||||
tt.Type("x")
|
||||
if n != 0 || len(rows) != 1 || rows[0].Enabled || rows[0].Key != "page" {
|
||||
t.Fatalf("disabled editor changed: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyValueInputSnapshot(t *testing.T) {
|
||||
snapshot(t, "key_value_input", 560, 200, func(c *ui.Context) {
|
||||
rows := []KeyValue{{Key: "Accept", Value: "application/json", Enabled: true}, {Key: "X-Trace", Value: "on"}}
|
||||
ui.Column(c).Padding(16).Children(func() { KeyValueInput(c, &rows, KeyValueInputOptions{}) })
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/code"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/navigation"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// LogStreamState is a LogStream's pause, level filter and place.
|
||||
type LogStreamState struct {
|
||||
Paused bool
|
||||
// MinLevel hides lines below it.
|
||||
MinLevel code.LogLevel
|
||||
// frozen holds the paused line count; later lines wait.
|
||||
frozen int
|
||||
shown []int
|
||||
scanned int
|
||||
min code.LogLevel
|
||||
rows ui.ListState
|
||||
src []code.LogLine
|
||||
started bool
|
||||
}
|
||||
|
||||
// LogStreamOptions configure a LogStream.
|
||||
type LogStreamOptions struct {
|
||||
// Height of the line well; zero means 320.
|
||||
Height float32
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// LogStream shows virtualized ANSI logs with pause and level filters.
|
||||
func LogStream(c *ui.Context, lines []code.LogLine, s *LogStreamState, opts LogStreamOptions) core.ChoiceResult {
|
||||
if s == nil {
|
||||
panic("mujica: LogStream needs a state")
|
||||
}
|
||||
if opts.Height < 0 || s.MinLevel < code.LogDebug || s.MinLevel > code.LogError {
|
||||
panic("mujica: LogStream needs Height >= 0 and a known MinLevel")
|
||||
}
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = 320
|
||||
}
|
||||
var r core.ChoiceResult
|
||||
r.Element = code.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() {
|
||||
code.CodeHeader(c, func() {
|
||||
was := s.Paused
|
||||
label := core.Msg(c, "log_stream.pause")
|
||||
if s.Paused {
|
||||
label = core.Msg(c, "log_stream.resume")
|
||||
}
|
||||
if input.Toggle(c, &s.Paused, label, input.ToggleOptions{Disabled: locked}).Changed() && !locked {
|
||||
r = core.NewChoiceResult(r.Element, true)
|
||||
} else {
|
||||
s.Paused = was
|
||||
}
|
||||
levels := make([]navigation.Segment, 4)
|
||||
for lv := code.LogDebug; lv <= code.LogError; lv++ {
|
||||
levels[lv] = navigation.Segment{Label: code.LogViewerName(c, lv)}
|
||||
}
|
||||
pick := int(s.MinLevel)
|
||||
navigation.SegmentedControl(c, &pick, levels, navigation.SegmentedControlOptions{Label: core.Msg(c, "log_stream.level"), Disabled: locked})
|
||||
if code.LogLevel(pick) != s.MinLevel && !locked {
|
||||
s.MinLevel, r = code.LogLevel(pick), core.NewChoiceResult(r.Element, true)
|
||||
}
|
||||
ui.Box(c).Grow(1)
|
||||
if s.Paused && len(lines) > s.frozen {
|
||||
code.CodeCaption(c, fmt.Sprintf(core.Msg(c, "log_stream.waiting"), len(lines)-s.frozen)).Role(ui.RoleStatus)
|
||||
}
|
||||
})
|
||||
if !s.Paused {
|
||||
s.frozen = len(lines)
|
||||
}
|
||||
logStreamFilter(lines[:min(s.frozen, len(lines))], s)
|
||||
if len(s.shown) == 0 {
|
||||
code.CodeCaption(c, core.Msg(c, "log_stream.empty")).Padding(core.Density(c).Unit() * 4)
|
||||
return
|
||||
}
|
||||
s.rows.FollowEnd = !s.Paused
|
||||
s.rows.Key = func(i int) any { return s.shown[i] }
|
||||
d := core.Density(c)
|
||||
ui.List(c, &s.rows, len(s.shown), func(i int) { logStreamRow(c, lines[s.shown[i]]) }).Grow(1).Padding(d.Unit(), 0)
|
||||
})
|
||||
if r.Changed() {
|
||||
slog.Debug("mujica: log_stream change", "paused", s.Paused, "min_level", s.MinLevel)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// logStreamFilter updates filtered indices when lines or filters change.
|
||||
func logStreamFilter(lines []code.LogLine, s *LogStreamState) {
|
||||
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++ {
|
||||
lv := lines[i].Level
|
||||
if lv < code.LogDebug || lv > code.LogError {
|
||||
panic("mujica: LogLine level out of range")
|
||||
}
|
||||
if lv >= s.MinLevel {
|
||||
s.shown = append(s.shown, i)
|
||||
}
|
||||
}
|
||||
s.scanned, s.src = len(lines), lines
|
||||
}
|
||||
|
||||
// logStreamRow shows one line: time, level word and ANSI text.
|
||||
func logStreamRow(c *ui.Context, l code.LogLine) {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
col := [...]ui.Color{k.TextMuted, k.Info, k.Warning, k.Danger}[l.Level]
|
||||
ui.Row(c).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() {
|
||||
if !l.Time.IsZero() {
|
||||
code.CodeMono(c, l.Time.Format("15:04:05.000")).TextColor(k.TextMuted).Shrink(0)
|
||||
}
|
||||
ui.Text(c, fmt.Sprintf("%-5s", code.LogViewerName(c, l.Level))).Font(theme.MonoFont).FontSize(core.FontSize(c, code.CodeSize)).
|
||||
FontWeight(700).TextColor(col).SingleLine().Shrink(0)
|
||||
ui.RichText(c, code.CodeAnsiSpans(c, l.Text)...).Font(theme.MonoFont).FontSize(core.FontSize(c, code.CodeSize)).Grow(1).MinWidth(0)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/code"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func logStreamLines(n int) []code.LogLine {
|
||||
out := make([]code.LogLine, n)
|
||||
for i := range out {
|
||||
out[i] = code.LogLine{Level: code.LogLevel(i % 4), Text: fmt.Sprintf("\x1b[1mline\x1b[0m %d", i)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func logStreamTester(lines *[]code.LogLine, s *LogStreamState, opts LogStreamOptions, changes *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if LogStream(c, *lines, s, opts).Changed() {
|
||||
*changes++
|
||||
}
|
||||
}, 700, 400)
|
||||
}
|
||||
|
||||
func TestLogStreamFollowPauseResume(t *testing.T) {
|
||||
lines := logStreamLines(10)
|
||||
var s LogStreamState
|
||||
n := 0
|
||||
tt := logStreamTester(&lines, &s, LogStreamOptions{}, &n)
|
||||
if !tt.HasText("line 9") || tt.HasText("\x1b[1mline") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Pause")
|
||||
lines = append(lines, logStreamLines(13)[10:]...)
|
||||
tt.Frame()
|
||||
if !s.Paused || tt.HasText("line 12") || !tt.HasText("3 new lines") {
|
||||
t.Fatalf("paused: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Resume")
|
||||
if s.Paused || !tt.HasText("line 12") || n != 2 {
|
||||
t.Fatalf("resume: %q changes %d", tt.Texts(), n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogStreamLevelFilter(t *testing.T) {
|
||||
lines := logStreamLines(8)
|
||||
var s LogStreamState
|
||||
n := 0
|
||||
tt := logStreamTester(&lines, &s, LogStreamOptions{}, &n)
|
||||
_ = tt.Click("WARN")
|
||||
if s.MinLevel != code.LogWarn || tt.HasText("line 5") || !tt.HasText("line 6") || !tt.HasText("line 7") {
|
||||
t.Fatalf("filter: %v %q", s.MinLevel, tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if s.MinLevel != code.LogError || tt.HasText("line 6") {
|
||||
t.Fatalf("keyboard filter: %v", s.MinLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogStreamDisabledEmptyMany(t *testing.T) {
|
||||
lines := logStreamLines(4)
|
||||
var s LogStreamState
|
||||
n := 0
|
||||
tt := logStreamTester(&lines, &s, LogStreamOptions{Disabled: true}, &n)
|
||||
_ = tt.Click("Pause")
|
||||
_ = tt.Click("WARN")
|
||||
if n != 0 || s.Paused || s.MinLevel != code.LogDebug {
|
||||
t.Fatalf("disabled changed: %+v", s)
|
||||
}
|
||||
none := []code.LogLine{}
|
||||
tt = logStreamTester(&none, &LogStreamState{}, LogStreamOptions{}, &n)
|
||||
if !tt.HasText("No log lines") {
|
||||
t.Fatal("empty text missing")
|
||||
}
|
||||
many := logStreamLines(100000)
|
||||
var ms LogStreamState
|
||||
tt = logStreamTester(&many, &ms, LogStreamOptions{}, &n)
|
||||
if got := len(tt.Texts()); got > 300 || !tt.HasText("line 99999") {
|
||||
t.Fatalf("%d texts; last line shown %v", got, tt.HasText("line 99999"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogStreamSnapshot(t *testing.T) {
|
||||
lines := []code.LogLine{
|
||||
{Level: code.LogInfo, Text: "server listening on :8080"},
|
||||
{Level: code.LogDebug, Text: "cache warm: 312 keys"},
|
||||
{Level: code.LogWarn, Text: "slow query \x1b[33m412 ms\x1b[0m on guests"},
|
||||
{Level: code.LogError, Text: "\x1b[31mpayment provider unreachable\x1b[0m"},
|
||||
{Level: code.LogInfo, Text: "retry 1 of 3 succeeded"},
|
||||
}
|
||||
snapshot(t, "log_stream", 640, 260, func(c *ui.Context) {
|
||||
var s LogStreamState
|
||||
ui.Column(c).Padding(16).Children(func() { LogStream(c, lines, &s, LogStreamOptions{Height: 220}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestLogStreamAncestorDisabledAndEmptyFilter(t *testing.T) {
|
||||
lines := []code.LogLine{{Level: code.LogInfo, Text: "ready"}}
|
||||
var s LogStreamState
|
||||
disabled := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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 != code.LogWarn {
|
||||
t.Fatal("empty level filter")
|
||||
}
|
||||
disabled = true
|
||||
tt.Key(0, ui.KeyRight)
|
||||
_ = tt.Click("Pause")
|
||||
if s.MinLevel != code.LogWarn || s.Paused {
|
||||
t.Fatal("disabled stream changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogStreamReplacedLevels(t *testing.T) {
|
||||
lines := []code.LogLine{{Level: code.LogInfo, Text: "ready"}}
|
||||
s := LogStreamState{MinLevel: code.LogWarn}
|
||||
changes := 0
|
||||
tt := logStreamTester(&lines, &s, LogStreamOptions{}, &changes)
|
||||
lines = []code.LogLine{{Level: code.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, code.LogLine{Level: code.LogInfo, Text: "later"})
|
||||
tt.Frame()
|
||||
if tt.HasText("later") || !tt.HasText("1 new lines") {
|
||||
t.Fatal("initial pause did not freeze lines")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
// RefreshIntervalSelectorOptions configure a RefreshIntervalSelector.
|
||||
type RefreshIntervalSelectorOptions struct {
|
||||
// Intervals defaults to 5s, 10s, 30s, 1m and 5m.
|
||||
Intervals []time.Duration
|
||||
// Refreshing shows the refresh in progress and holds the timer.
|
||||
Refreshing bool
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// RefreshResult reports this frame's events.
|
||||
type RefreshResult struct {
|
||||
Element *ui.Element
|
||||
// Refresh requests caller loading, manually or after the interval.
|
||||
Refresh bool
|
||||
// Changed reports a new interval.
|
||||
Changed bool
|
||||
}
|
||||
|
||||
type refreshState struct{ last time.Time }
|
||||
|
||||
var refreshDefaults = []time.Duration{5 * time.Second, 10 * time.Second, 30 * time.Second, time.Minute, 5 * time.Minute}
|
||||
|
||||
// RefreshIntervalSelector 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")
|
||||
}
|
||||
choices := opts.Intervals
|
||||
if choices == nil {
|
||||
choices = refreshDefaults
|
||||
}
|
||||
options := []input.SelectOption[time.Duration]{{Value: 0, Label: core.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 || seen[d] {
|
||||
panic("mujica: RefreshIntervalSelector intervals must be whole seconds")
|
||||
}
|
||||
seen[d] = true
|
||||
known = known || d == *interval
|
||||
label := fmt.Sprintf(core.Msg(c, "refresh_interval_selector.seconds"), int(d/time.Second))
|
||||
if d%time.Minute == 0 {
|
||||
label = fmt.Sprintf(core.Msg(c, "refresh_interval_selector.minutes"), int(d/time.Minute))
|
||||
}
|
||||
options = append(options, input.SelectOption[time.Duration]{Value: d, Label: label})
|
||||
}
|
||||
if !known {
|
||||
panic("mujica: RefreshIntervalSelector interval is not offered")
|
||||
}
|
||||
u := core.Density(c).Unit()
|
||||
var r RefreshResult
|
||||
r.Element = ui.Row(c).Shrink(0).Gap(u * 2).AlignItems(ui.Center).Disabled(opts.Disabled)
|
||||
st := ui.Local(r.Element, "mujica.refresh", func() refreshState { return refreshState{last: c.Now()} })
|
||||
locked := r.Element.IsDisabled()
|
||||
r.Element.Children(func() {
|
||||
label := core.Msg(c, "refresh_interval_selector.refresh")
|
||||
if opts.Refreshing {
|
||||
core.Spinner(c, core.SpinnerOptions{Size: 14, Label: core.Msg(c, "refresh_interval_selector.refreshing")})
|
||||
}
|
||||
if input.Button(c, label, input.ButtonOptions{Variant: input.Secondary, Icon: icons.Must("refresh-cw"), Disabled: locked || opts.Refreshing}).Clicked() && !locked {
|
||||
r.Refresh = true
|
||||
}
|
||||
r.Changed = input.Select(c, interval, options, input.SelectOptions{Label: core.Msg(c, "refresh_interval_selector.label"), Disabled: locked}).Changed()
|
||||
})
|
||||
if r.Changed || opts.Refreshing {
|
||||
st.last = c.Now()
|
||||
}
|
||||
if *interval > 0 && !locked && !opts.Refreshing && !r.Refresh {
|
||||
if wait := *interval - c.Now().Sub(st.last); wait > 0 {
|
||||
c.After(wait)
|
||||
} else {
|
||||
r.Refresh = true
|
||||
}
|
||||
}
|
||||
if r.Refresh {
|
||||
st.last = c.Now()
|
||||
slog.Debug("mujica: refresh_interval_selector refresh", "interval", *interval)
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func refreshTester(interval *time.Duration, opts *RefreshIntervalSelectorOptions, refreshes, changes *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := RefreshIntervalSelector(c, interval, *opts)
|
||||
if r.Refresh {
|
||||
*refreshes++
|
||||
}
|
||||
if r.Changed {
|
||||
*changes++
|
||||
}
|
||||
}, 500, 300)
|
||||
}
|
||||
|
||||
func TestRefreshIntervalSelectorManualAndAuto(t *testing.T) {
|
||||
interval := time.Duration(0)
|
||||
opts := RefreshIntervalSelectorOptions{Intervals: []time.Duration{time.Second, time.Minute}}
|
||||
refreshes, changes := 0, 0
|
||||
tt := refreshTester(&interval, &opts, &refreshes, &changes)
|
||||
_ = tt.Click("Refresh")
|
||||
if refreshes != 1 {
|
||||
t.Fatalf("manual refreshes %d", refreshes)
|
||||
}
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if refreshes != 1 {
|
||||
t.Fatal("refreshed while off")
|
||||
}
|
||||
_ = tt.Click("Refresh interval")
|
||||
_ = tt.Click("Every 1 s")
|
||||
if interval != time.Second || changes != 1 {
|
||||
t.Fatalf("interval %v changes %d", interval, changes)
|
||||
}
|
||||
start := time.Now()
|
||||
if !overlayUntil(tt, func() bool { return refreshes == 2 }) {
|
||||
t.Fatal("no automatic refresh")
|
||||
}
|
||||
if time.Since(start) < 900*time.Millisecond {
|
||||
t.Fatal("refreshed before the interval")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshIntervalSelectorRefreshingAndDisabled(t *testing.T) {
|
||||
interval := time.Second
|
||||
opts := RefreshIntervalSelectorOptions{Intervals: []time.Duration{time.Second}, Refreshing: true}
|
||||
refreshes, changes := 0, 0
|
||||
tt := refreshTester(&interval, &opts, &refreshes, &changes)
|
||||
_ = tt.Click("Refresh")
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if refreshes != 0 || !tt.HasText("Refresh") {
|
||||
t.Fatalf("refreshing: %d", refreshes)
|
||||
}
|
||||
opts = RefreshIntervalSelectorOptions{Intervals: []time.Duration{time.Second}, Disabled: true}
|
||||
tt.Frame()
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
tt.Frame()
|
||||
_ = tt.Click("Refresh")
|
||||
if refreshes != 0 {
|
||||
t.Fatalf("disabled refreshed %d", refreshes)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("unoffered interval did not panic")
|
||||
}
|
||||
}()
|
||||
bad := 7 * time.Second
|
||||
refreshTester(&bad, &RefreshIntervalSelectorOptions{}, &refreshes, &changes)
|
||||
}
|
||||
|
||||
func TestRefreshIntervalSelectorSnapshot(t *testing.T) {
|
||||
snapshot(t, "refresh_interval_selector", 420, 80, func(c *ui.Context) {
|
||||
interval := 30 * time.Second
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
RefreshIntervalSelector(c, &interval, RefreshIntervalSelectorOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestRefreshIntervalSelectorKeyboardAncestorDisable(t *testing.T) {
|
||||
interval := time.Duration(0)
|
||||
disabled := false
|
||||
changes, refreshes := 0, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/agent"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chart"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
)
|
||||
|
||||
// ResourceReading is one resource's current use.
|
||||
type ResourceReading struct {
|
||||
// Name is required, as "CPU".
|
||||
Name string
|
||||
Used, Total float64
|
||||
// Unit follows the amounts, as "GB"; empty shows percent only.
|
||||
Unit string
|
||||
}
|
||||
|
||||
// ResourceGaugeOptions configure a ResourceGauge; thresholds are fractions of Total.
|
||||
type ResourceGaugeOptions struct {
|
||||
// Warn and Critical default to 0.7 and 0.9.
|
||||
Warn, Critical float64
|
||||
// Height of each dial; zero means 150.
|
||||
Height float32
|
||||
Status data.DataStatus
|
||||
Error string
|
||||
}
|
||||
|
||||
// ResourceGauge shows resource usage with named threshold bands.
|
||||
func ResourceGauge(c *ui.Context, readings []ResourceReading, opts ResourceGaugeOptions) core.ChoiceResult {
|
||||
if opts.Status != data.DataReady && opts.Status != data.DataLoading && opts.Status != data.DataFailed {
|
||||
panic("mujica: ResourceGauge needs a known status")
|
||||
}
|
||||
warn, crit := opts.Warn, opts.Critical
|
||||
if warn == 0 {
|
||||
warn = 0.7
|
||||
}
|
||||
if crit == 0 {
|
||||
crit = 0.9
|
||||
}
|
||||
if !(0 < warn && warn < crit && crit < 1) || opts.Height < 0 {
|
||||
panic("mujica: ResourceGauge needs 0 < Warn < Critical < 1 and Height >= 0")
|
||||
}
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = 150
|
||||
}
|
||||
u := core.Density(c).Unit()
|
||||
row := ui.Row(c).Wrap().Gap(u * 4).Shrink(0)
|
||||
result := core.ChoiceResult{Element: row}
|
||||
row.Children(func() {
|
||||
if len(readings) == 0 {
|
||||
ui.Column(c).Height(h).Children(func() {
|
||||
result = core.NewChoiceResult(result.Element, data.DataPlaceholder(c, opts.Status, opts.Error, core.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) || 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 := chart.Gauge(c, frac*100, chart.GaugeOptions{
|
||||
Frame: chart.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: []chart.GaugeSegment{
|
||||
{To: warn * 100, Label: core.Msg(c, "resource_gauge.normal"), Tone: chart.GaugeGood},
|
||||
{To: crit * 100, Label: core.Msg(c, "resource_gauge.high"), Tone: chart.GaugeWarning},
|
||||
{To: 100, Label: core.Msg(c, "resource_gauge.critical"), Tone: chart.GaugeDanger},
|
||||
},
|
||||
})
|
||||
result = core.NewChoiceResult(result.Element, result.Changed() || gauge.Retried())
|
||||
if r.Unit != "" {
|
||||
agent.AgentCaption(c, fmt.Sprintf("%s / %s %s", agent.SandboxNum(r.Used), agent.SandboxNum(r.Total), r.Unit))
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
if result.Changed() {
|
||||
slog.Debug("mujica: resource_gauge retry")
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
)
|
||||
|
||||
var resourceSample = []ResourceReading{
|
||||
{Name: "CPU", Used: 45, Total: 100},
|
||||
{Name: "Memory", Used: 6.4, Total: 8, Unit: "GB"},
|
||||
{Name: "Disk", Used: 470, Total: 500, Unit: "GB"},
|
||||
}
|
||||
|
||||
func TestResourceGaugeThresholdsAndKeyboard(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ResourceGauge(c, resourceSample, ResourceGaugeOptions{})
|
||||
}, 700, 300)
|
||||
if !tt.HasText("6.4 / 8 GB") || !tt.HasText("470 / 500 GB") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyTab) // CPU dial
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !tt.HasText("45 % · Normal") {
|
||||
t.Fatalf("CPU readout: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !tt.HasText("80 % · High") {
|
||||
t.Fatalf("memory readout: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !tt.HasText("94 % · Critical") {
|
||||
t.Fatalf("disk readout: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceGaugeRejectsBadReading(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ResourceGauge(c, []ResourceReading{{Name: "CPU", Used: 5, Total: 4}}, ResourceGaugeOptions{})
|
||||
}, 200, 200)
|
||||
}
|
||||
|
||||
func TestResourceGaugeSnapshot(t *testing.T) {
|
||||
snapshot(t, "resource_gauge", 680, 250, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { ResourceGauge(c, resourceSample, ResourceGaugeOptions{}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestResourceGaugeRejectsInfiniteTotal(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("infinite capacity accepted")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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) { core.Use(c, core.Settings{}); ResourceGauge(c, nil, ResourceGaugeOptions{}) }, 600, 300)
|
||||
if !tt.HasText("No resources") {
|
||||
t.Fatal("empty resource state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceGaugeLoadingErrorAndDisabledRetry(t *testing.T) {
|
||||
status, disabled := data.DataLoading, false
|
||||
retries := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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 = data.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,132 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/agent"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/code"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/navigation"
|
||||
"github.com/ZacharyZhang-NY/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 data.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) core.ChoiceResult {
|
||||
if opts.Status != data.DataReady && opts.Status != data.DataLoading && opts.Status != data.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 := core.Density(c).Unit()
|
||||
var r core.ChoiceResult
|
||||
r.Element = ui.Column(c).Shrink(0).Gap(u * 3).Label(core.Msg(c, "response_viewer.label"))
|
||||
st := ui.Local(r.Element, "mujica.response", func() responseViewerState { return responseViewerState{} })
|
||||
r.Element.Children(func() {
|
||||
if opts.Status != data.DataReady || resp == nil {
|
||||
ui.Column(c).Height(h).Children(func() {
|
||||
r = core.NewChoiceResult(r.Element, data.DataPlaceholder(c, opts.Status, opts.Error, core.Msg(c, "response_viewer.none"), ""))
|
||||
})
|
||||
return
|
||||
}
|
||||
ui.Row(c).Gap(u * 4).AlignItems(ui.Center).Role(ui.RoleStatus).Children(func() {
|
||||
responseStatus(c, resp.Status)
|
||||
agent.AgentCaption(c, agent.AgentDuration(resp.Duration))
|
||||
agent.AgentCaption(c, code.ProcessListBytes(uint64(resp.Size)))
|
||||
})
|
||||
if st.body != resp.Body {
|
||||
st.body, st.isJSON = resp.Body, json.Valid([]byte(resp.Body))
|
||||
}
|
||||
tabs := []navigation.Tab{
|
||||
{Label: core.Msg(c, "response_viewer.body"), Panel: func() {
|
||||
ui.Column(c).Height(h).Children(func() {
|
||||
switch {
|
||||
case resp.Body == "":
|
||||
agent.AgentCaption(c, core.Msg(c, "response_viewer.empty"))
|
||||
case st.isJSON:
|
||||
JsonViewer(c, resp.Body, &st.json, JsonViewerOptions{Height: h})
|
||||
default:
|
||||
agent.AgentCode(c, "", resp.Body, h)
|
||||
}
|
||||
})
|
||||
}},
|
||||
{Label: fmt.Sprintf(core.Msg(c, "response_viewer.headers"), len(resp.Headers)), Panel: func() {
|
||||
ui.Column(c).Height(h).Children(func() { responseHeaders(c, resp.Headers, h) })
|
||||
}},
|
||||
}
|
||||
navigation.Tabs(c, &st.tab, tabs, navigation.TabsOptions{Label: core.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 := core.SeveritySuccess
|
||||
switch {
|
||||
case code >= 500:
|
||||
sev = core.SeverityError
|
||||
case code >= 400:
|
||||
sev = core.SeverityWarning
|
||||
case code >= 300 || code < 200:
|
||||
sev = core.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 := core.Tokens(c)
|
||||
if len(headers) == 0 {
|
||||
agent.AgentCaption(c, core.Msg(c, "response_viewer.no_headers"))
|
||||
return
|
||||
}
|
||||
agent.AgentWellRows(c, len(headers), func(i int) {
|
||||
kv := headers[i]
|
||||
ui.Row(c).MinHeight(core.Density(c).ControlHeight()).Gap(core.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,157 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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: data.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) {
|
||||
core.Use(c, core.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 = data.DataLoading
|
||||
tt.Frame()
|
||||
if !tt.HasText("Loading") || tt.HasText("X-Trace") {
|
||||
t.Fatal("loading state")
|
||||
}
|
||||
opts.Status = data.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: data.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) {
|
||||
core.Use(c, core.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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/code"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// SchemaKind classifies nodes; zero means SchemaDatabase.
|
||||
type SchemaKind int
|
||||
|
||||
const (
|
||||
SchemaDatabase SchemaKind = iota
|
||||
SchemaTable
|
||||
SchemaView
|
||||
SchemaColumn
|
||||
SchemaIndex
|
||||
)
|
||||
|
||||
// SchemaNode describes an object; Detail holds type or index columns.
|
||||
type SchemaNode struct {
|
||||
Name string
|
||||
Kind SchemaKind
|
||||
Detail string
|
||||
Children []SchemaNode
|
||||
}
|
||||
|
||||
// SchemaTreeState is a SchemaTree's open nodes and choice.
|
||||
type SchemaTreeState struct {
|
||||
outline ui.OutlineState[string]
|
||||
selected int
|
||||
nodes map[string]*SchemaNode
|
||||
kids map[string][]string
|
||||
roots []string
|
||||
src []SchemaNode
|
||||
}
|
||||
|
||||
// Selected returns the chosen path and whether it exists.
|
||||
func (s *SchemaTreeState) Selected() (string, bool) {
|
||||
if s.selected < 0 || s.selected >= s.outline.Rows() {
|
||||
return "", false
|
||||
}
|
||||
return s.outline.Item(s.selected), true
|
||||
}
|
||||
|
||||
// SchemaTreeOptions configure a SchemaTree.
|
||||
type SchemaTreeOptions struct {
|
||||
// Height of the tree; zero means 360.
|
||||
Height float32
|
||||
}
|
||||
|
||||
// SchemaTree shows database objects in a virtualized keyboard tree.
|
||||
func SchemaTree(c *ui.Context, s *SchemaTreeState, roots []SchemaNode, opts SchemaTreeOptions) core.ChoiceResult {
|
||||
if s == nil || opts.Height < 0 {
|
||||
panic("mujica: SchemaTree needs a state and Height >= 0")
|
||||
}
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = 360
|
||||
}
|
||||
schemaIndex(s, roots)
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
var r core.ChoiceResult
|
||||
r.Element = code.CodePanel(c).Height(h).Label(core.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 {
|
||||
code.CodeCaption(c, core.Msg(c, "schema_tree.empty")).Padding(d.Unit() * 4)
|
||||
return
|
||||
}
|
||||
was := s.selected
|
||||
s.outline.List.Selected = &s.selected
|
||||
s.outline.List.Label = func(i int) string { return s.nodes[s.outline.Item(i)].Name }
|
||||
restore := data.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).
|
||||
Label(fmt.Sprintf("%s %s %s", schemaWord(c, n.Kind), n.Name, n.Detail)).Children(func() {
|
||||
sel, _ := s.Selected()
|
||||
code.CodeSelectMark(c, sel == p)
|
||||
letter, col := schemaBadge(k, n.Kind)
|
||||
code.CodeBadge(c, letter, col)
|
||||
ui.Text(c, n.Name).SingleLine().Shrink(0)
|
||||
if n.Detail != "" {
|
||||
code.CodeMono(c, n.Detail).TextColor(k.TextMuted).MinWidth(0)
|
||||
}
|
||||
})
|
||||
}).Grow(1)
|
||||
restore()
|
||||
internal.Ring(e, k.Focus, theme.ControlRadius)
|
||||
r = core.NewChoiceResult(r.Element, !locked && s.selected != was)
|
||||
})
|
||||
if r.Changed() {
|
||||
p, _ := s.Selected()
|
||||
slog.Debug("mujica: schema_tree select", "path", p)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// schemaIndex maps paths to nodes once per new schema.
|
||||
func schemaIndex(s *SchemaTreeState, roots []SchemaNode) {
|
||||
if s.nodes != nil && len(roots) == len(s.src) && (len(roots) == 0 || &roots[0] == &s.src[0]) {
|
||||
return
|
||||
}
|
||||
if s.nodes == nil {
|
||||
s.selected = -1 // nothing chosen at first
|
||||
}
|
||||
s.src, s.nodes, s.kids, s.roots = roots, map[string]*SchemaNode{}, map[string][]string{}, nil
|
||||
var walk func(list []SchemaNode, parent string) []string
|
||||
walk = func(list []SchemaNode, parent string) []string {
|
||||
var paths []string
|
||||
for i := range list {
|
||||
n := &list[i]
|
||||
if n.Name == "" || n.Kind < SchemaDatabase || n.Kind > SchemaIndex {
|
||||
panic(fmt.Sprintf("mujica: SchemaTree node %q needs a Name and a known Kind", n.Name))
|
||||
}
|
||||
p := n.Name
|
||||
if parent != "" {
|
||||
p = parent + "/" + n.Name
|
||||
}
|
||||
if _, dup := s.nodes[p]; dup {
|
||||
panic("mujica: SchemaTree has two nodes at " + p)
|
||||
}
|
||||
s.nodes[p] = n
|
||||
paths = append(paths, p)
|
||||
if len(n.Children) > 0 {
|
||||
s.kids[p] = walk(n.Children, p)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
s.roots = walk(roots, "")
|
||||
if len(s.roots) == 0 {
|
||||
s.outline = ui.OutlineState[string]{}
|
||||
s.selected = -1
|
||||
}
|
||||
}
|
||||
|
||||
func schemaBadge(k theme.Tokens, kind SchemaKind) (string, ui.Color) {
|
||||
switch kind {
|
||||
case SchemaDatabase:
|
||||
return "D", k.AccentText
|
||||
case SchemaTable:
|
||||
return "T", k.Info
|
||||
case SchemaView:
|
||||
return "V", k.Success
|
||||
case SchemaIndex:
|
||||
return "I", k.Warning
|
||||
}
|
||||
return "C", k.TextMuted
|
||||
}
|
||||
|
||||
func schemaWord(c *ui.Context, kind SchemaKind) string {
|
||||
return core.Msg(c, [...]string{"schema_tree.database", "schema_tree.table", "schema_tree.view", "schema_tree.column", "schema_tree.index"}[kind])
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var schemaSample = []SchemaNode{{Name: "court", Kind: SchemaDatabase, Children: []SchemaNode{
|
||||
{Name: "guests", Kind: SchemaTable, Children: []SchemaNode{
|
||||
{Name: "id", Kind: SchemaColumn, Detail: "bigint primary key"},
|
||||
{Name: "name", Kind: SchemaColumn, Detail: "text not null"},
|
||||
{Name: "guests_name_idx", Kind: SchemaIndex, Detail: "(name)"},
|
||||
}},
|
||||
{Name: "seating", Kind: SchemaView},
|
||||
}}}
|
||||
|
||||
func schemaTester(s *SchemaTreeState, roots []SchemaNode, changes *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if SchemaTree(c, s, roots, SchemaTreeOptions{}).Changed() {
|
||||
*changes++
|
||||
}
|
||||
}, 520, 420)
|
||||
}
|
||||
|
||||
func TestSchemaTreeKeyboardOpenAndChoose(t *testing.T) {
|
||||
var s SchemaTreeState
|
||||
n := 0
|
||||
tt := schemaTester(&s, schemaSample, &n)
|
||||
if _, ok := s.Selected(); ok || tt.HasText("guests") {
|
||||
t.Fatalf("start: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyDown) // court
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyDown) // guests
|
||||
tt.Key(0, ui.KeyRight)
|
||||
for _, s := range []string{"guests", "seating", "bigint primary key", "(name)"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Fatalf("missing %q in %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if p, _ := s.Selected(); p != "court/guests/id" || n == 0 {
|
||||
t.Fatalf("selected %q changes %d", p, n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaTreeEmptyRulesMany(t *testing.T) {
|
||||
var s SchemaTreeState
|
||||
n := 0
|
||||
tt := schemaTester(&s, nil, &n)
|
||||
if !tt.HasText("No databases") {
|
||||
t.Fatal("empty text")
|
||||
}
|
||||
cols := make([]SchemaNode, 100000)
|
||||
for i := range cols {
|
||||
cols[i] = SchemaNode{Name: fmt.Sprint("c", i), Kind: SchemaColumn}
|
||||
}
|
||||
big := []SchemaNode{{Name: "wide", Kind: SchemaTable, Children: cols}}
|
||||
var bs SchemaTreeState
|
||||
tt = schemaTester(&bs, big, &n)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if got := len(tt.Texts()); got > 400 || !tt.HasText("c0") {
|
||||
t.Fatalf("%d texts for 100000 columns", got)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("duplicate path did not panic")
|
||||
}
|
||||
}()
|
||||
var ds SchemaTreeState
|
||||
schemaTester(&ds, []SchemaNode{{Name: "a"}, {Name: "a"}}, &n)
|
||||
}
|
||||
|
||||
func TestSchemaTreeSnapshot(t *testing.T) {
|
||||
var s SchemaTreeState
|
||||
snapshotAfter(t, "schema_tree", 500, 300, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { SchemaTree(c, &s, schemaSample, SchemaTreeOptions{Height: 260}) })
|
||||
}, func(tt *ui.Tester) {
|
||||
s = SchemaTreeState{}
|
||||
for _, k := range []ui.Key{ui.KeyTab, ui.KeyDown, ui.KeyRight, ui.KeyDown, ui.KeyRight, ui.KeyDown} {
|
||||
tt.Key(0, k)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSchemaTreeAncestorDisabled(t *testing.T) {
|
||||
var s SchemaTreeState
|
||||
disabled := false
|
||||
changes := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/agent"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chart"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ServiceHealth is a service's state. The zero value is ServiceOperational.
|
||||
type ServiceHealth int
|
||||
|
||||
const (
|
||||
ServiceOperational ServiceHealth = iota
|
||||
ServiceDegraded
|
||||
ServiceOutage
|
||||
)
|
||||
|
||||
// ServiceItem is one service the caller reports.
|
||||
type ServiceItem struct {
|
||||
Name string
|
||||
Health ServiceHealth
|
||||
// Note explains a degraded or down service.
|
||||
Note string
|
||||
}
|
||||
|
||||
// ServiceStatusOptions configure a ServiceStatus.
|
||||
type ServiceStatusOptions struct {
|
||||
// MaxHeight caps the scrolling list; zero means 360.
|
||||
MaxHeight float32
|
||||
}
|
||||
|
||||
// ServiceStatus lists 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")
|
||||
}
|
||||
col := ui.Column(c).Shrink(0).Gap(core.Density(c).Unit() * 2)
|
||||
summaryState := ui.Local(col, "mujica.services.summary", func() serviceSummary { return serviceSummary{key: "unset"} })
|
||||
if key := chart.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]++
|
||||
}
|
||||
summaryState.key, summaryState.worst, summaryState.counts = key, worst, counts
|
||||
}
|
||||
worst, counts := summaryState.worst, summaryState.counts
|
||||
maxH := opts.MaxHeight
|
||||
if maxH == 0 {
|
||||
maxH = 360
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
summary := core.Msg(c, [...]string{"service_status.all_ok", "service_status.some_degraded", "service_status.some_down"}[worst])
|
||||
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() {
|
||||
devtoolsSeverity(c, serviceSeverity(worst), summary)
|
||||
ui.Box(c).Grow(1)
|
||||
agent.AgentCaption(c, fmt.Sprintf(core.Msg(c, "service_status.counts"), counts[0], counts[1], counts[2]))
|
||||
})
|
||||
if len(services) == 0 {
|
||||
agent.AgentCaption(c, core.Msg(c, "service_status.empty"))
|
||||
return
|
||||
}
|
||||
agent.AgentRows(c, len(services), func(i int) {
|
||||
s := services[i]
|
||||
word := serviceWord(c, s.Health)
|
||||
ui.Row(c).Key(i).Gap(u*3).AlignItems(ui.Center).MinHeight(core.Density(c).ControlHeight()).Padding(u, u*3).
|
||||
Label(s.Name + ", " + word).Children(func() {
|
||||
ui.Column(c).Grow(1).Shrink(1).Children(func() {
|
||||
ui.Text(c, s.Name).SingleLine()
|
||||
if s.Note != "" {
|
||||
agent.AgentCaption(c, s.Note)
|
||||
}
|
||||
})
|
||||
devtoolsSeverity(c, serviceSeverity(s.Health), word)
|
||||
})
|
||||
}).Shrink(1).MaxHeight(maxH).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Radius(theme.CardRadius).Dividers(theme.BorderWidth, k.Border)
|
||||
})
|
||||
return col
|
||||
}
|
||||
|
||||
func serviceSeverity(h ServiceHealth) core.Severity {
|
||||
return [...]core.Severity{core.SeveritySuccess, core.SeverityWarning, core.SeverityError}[h]
|
||||
}
|
||||
|
||||
func serviceWord(c *ui.Context, h ServiceHealth) string {
|
||||
return core.Msg(c, [...]string{"service_status.operational", "service_status.degraded", "service_status.outage"}[h])
|
||||
}
|
||||
|
||||
type serviceSummary struct {
|
||||
key string
|
||||
worst ServiceHealth
|
||||
counts [3]int
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var servicesSample = []ServiceItem{
|
||||
{Name: "API", Health: ServiceOperational},
|
||||
{Name: "Search", Health: ServiceDegraded, Note: "Indexing is slow"},
|
||||
{Name: "Payments", Health: ServiceOutage, Note: "Provider unreachable"},
|
||||
}
|
||||
|
||||
func TestServiceStatusSummaryAndRows(t *testing.T) {
|
||||
services := servicesSample
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ServiceStatus(c, services, ServiceStatusOptions{})
|
||||
}, 520, 400)
|
||||
for _, s := range []string{"Some services down", "1 up · 1 degraded · 1 down", "Degraded", "Outage", "Provider unreachable"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Fatalf("missing %q in %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
services = servicesSample[:1]
|
||||
tt.Frame()
|
||||
if !tt.HasText("All systems operational") {
|
||||
t.Fatalf("all ok: %q", tt.Texts())
|
||||
}
|
||||
services = nil
|
||||
tt.Frame()
|
||||
if !tt.HasText("No services") {
|
||||
t.Fatal("empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceStatusManyVirtualized(t *testing.T) {
|
||||
many := make([]ServiceItem, 100000)
|
||||
for i := range many {
|
||||
many[i] = ServiceItem{Name: fmt.Sprint("svc ", i)}
|
||||
}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ServiceStatus(c, many, ServiceStatusOptions{})
|
||||
}, 520, 400)
|
||||
if n := len(tt.Texts()); n > 300 {
|
||||
t.Fatalf("%d texts", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceStatusSnapshot(t *testing.T) {
|
||||
snapshot(t, "service_status", 520, 280, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { ServiceStatus(c, servicesSample, ServiceStatusOptions{}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestServiceStatusRejectsUnknownHealth(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("unknown service health accepted")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ServiceStatus(c, []ServiceItem{{Name: "API", Health: 99}}, ServiceStatusOptions{})
|
||||
}, 400, 300)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/agent"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chart"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// StatCardOptions configure a StatCard. Label is required.
|
||||
type StatCardOptions struct {
|
||||
Label string
|
||||
// Change is relative; 0.12 means +12%, shown with HasChange.
|
||||
Change float64
|
||||
HasChange bool
|
||||
// LowerIsBetter colors a fall as good, as for latency.
|
||||
LowerIsBetter bool
|
||||
// Trend draws a sparkline; nil hides it.
|
||||
Trend []float64
|
||||
Status data.DataStatus
|
||||
Error string
|
||||
}
|
||||
|
||||
// StatCard shows values, changes, trends, loading and errors.
|
||||
func StatCard(c *ui.Context, value string, opts StatCardOptions) core.ChoiceResult {
|
||||
if opts.Label == "" {
|
||||
panic("mujica: StatCard needs a Label")
|
||||
}
|
||||
if math.IsNaN(opts.Change) || math.IsInf(opts.Change, 0) {
|
||||
panic("mujica: StatCard Change must be finite")
|
||||
}
|
||||
if opts.Status != data.DataReady && opts.Status != data.DataLoading && opts.Status != data.DataFailed {
|
||||
panic(fmt.Sprintf("mujica: StatCard Status %d is not ready, loading or failed", int(opts.Status)))
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
var r core.ChoiceResult
|
||||
r.Element = agent.AgentCard(c).MinHeight(core.FontSize(c, 116)).MinWidth(180).Label(opts.Label)
|
||||
r.Element.Children(func() {
|
||||
agent.AgentCaption(c, opts.Label)
|
||||
switch opts.Status {
|
||||
case data.DataLoading:
|
||||
ui.Column(c).Grow(1).Justify(ui.Center).Children(func() {
|
||||
core.Spinner(c, core.SpinnerOptions{Label: core.Msg(c, "list.loading"), ShowLabel: true})
|
||||
})
|
||||
return
|
||||
case data.DataFailed:
|
||||
ui.Column(c).Grow(1).Justify(ui.Center).Gap(u).Children(func() {
|
||||
ui.Row(c).Gap(u * 1.5).AlignItems(ui.Center).TextColor(k.Danger).Children(func() {
|
||||
ui.Icon(c, icons.Must("circle-alert")).Size(16, 16)
|
||||
ui.Text(c, data.DataError(c, opts.Error)).Role(ui.RoleStatus).FontSize(core.FontSize(c, theme.CaptionSize+1))
|
||||
})
|
||||
ui.Row(c).Children(func() {
|
||||
r = core.NewChoiceResult(r.Element, agent.AgentToggle(c, core.Msg(c, "list.retry"), "refresh-cw", false))
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
ui.Text(c, value).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H2Size)).FontWeight(600).FontFeatures("tnum").SingleLine()
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Children(func() {
|
||||
if opts.HasChange {
|
||||
statChange(c, opts.Change, opts.LowerIsBetter)
|
||||
}
|
||||
ui.Box(c).Grow(1)
|
||||
if len(opts.Trend) > 0 {
|
||||
chart.Sparkline(c, opts.Trend, chart.SparklineOptions{Frame: chart.ChartFrame{Label: opts.Label}, Width: 88})
|
||||
}
|
||||
})
|
||||
})
|
||||
if r.Changed() {
|
||||
slog.Debug("mujica: stat_card retry", "label", opts.Label)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// statChange shows a signed percentage and direction arrow.
|
||||
func statChange(c *ui.Context, change float64, lowerIsBetter bool) {
|
||||
k := core.Tokens(c)
|
||||
icon, tone, sign := "minus", k.TextMuted, ""
|
||||
switch {
|
||||
case change > 0:
|
||||
icon, tone, sign = "arrow-up", k.Success, "+"
|
||||
case change < 0:
|
||||
icon, tone = "arrow-down", k.Danger
|
||||
}
|
||||
if lowerIsBetter && change != 0 {
|
||||
if tone == k.Success {
|
||||
tone = k.Danger
|
||||
} else {
|
||||
tone = k.Success
|
||||
}
|
||||
}
|
||||
text := fmt.Sprintf("%s%.1f%%", sign, change*100)
|
||||
ui.Row(c).Gap(core.Density(c).Unit()).AlignItems(ui.Center).Label(fmt.Sprintf(core.Msg(c, "stat_card.change"), text)).Children(func() {
|
||||
ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(tone)
|
||||
ui.Text(c, text).TextColor(tone).FontSize(core.FontSize(c, theme.CaptionSize+1)).FontFeatures("tnum")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
)
|
||||
|
||||
func TestStatCardValueChangeTrend(t *testing.T) {
|
||||
opts := StatCardOptions{Label: "Requests", Change: 0.124, HasChange: true, Trend: []float64{3, 5, 4, 8, 9}}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
StatCard(c, "12,480", opts)
|
||||
}, 400, 200)
|
||||
for _, s := range []string{"Requests", "12,480", "+12.4%"} {
|
||||
if !tt.HasText(s) {
|
||||
t.Fatalf("missing %q in %q", s, tt.Texts())
|
||||
}
|
||||
}
|
||||
opts.Change = -0.05
|
||||
tt.Frame()
|
||||
if !tt.HasText("-5.0%") {
|
||||
t.Fatalf("fall: %q", tt.Texts())
|
||||
}
|
||||
opts.HasChange = false
|
||||
tt.Frame()
|
||||
if tt.HasText("-5.0%") {
|
||||
t.Fatal("change shown without HasChange")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatCardLoadingErrorSameSize(t *testing.T) {
|
||||
status, retries := data.DataReady, 0
|
||||
var h []float32
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).AlignItems(ui.Start).Children(func() {
|
||||
r := StatCard(c, "42 ms", StatCardOptions{Label: "Latency", Status: status, Error: "timeout"})
|
||||
h = append(h, r.Element.Bounds().H)
|
||||
if r.Changed() {
|
||||
retries++
|
||||
}
|
||||
})
|
||||
}, 400, 300)
|
||||
tt.Frame()
|
||||
ready := h[len(h)-1]
|
||||
status = data.DataLoading
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
if !tt.HasText("Loading") || h[len(h)-1] != ready {
|
||||
t.Fatalf("loading: %q height %v vs %v", tt.Texts(), h[len(h)-1], ready)
|
||||
}
|
||||
status = data.DataFailed
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
if !tt.HasText("timeout") || h[len(h)-1] != ready {
|
||||
t.Fatalf("failed: %q height %v vs %v", tt.Texts(), h[len(h)-1], ready)
|
||||
}
|
||||
_ = tt.Click("Retry")
|
||||
if retries != 1 {
|
||||
t.Fatalf("retries %d", retries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatCardSnapshot(t *testing.T) {
|
||||
snapshot(t, "stat_card", 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}})
|
||||
StatCard(c, "", StatCardOptions{Label: "Errors", Status: data.DataFailed, Error: "Source unreachable"})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestStatCardDisabledRetryAndKeyboardTrend(t *testing.T) {
|
||||
status := data.DataReady
|
||||
disabled := false
|
||||
retries := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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 = data.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) { core.Use(c, core.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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/agent"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chart"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// MonitorMetric holds samples aligned with the shared timestamps.
|
||||
type MonitorMetric struct {
|
||||
Name string
|
||||
Unit string
|
||||
Samples []float64
|
||||
}
|
||||
|
||||
// SystemMonitorOptions configure a SystemMonitor.
|
||||
type SystemMonitorOptions struct {
|
||||
// Height of each chart; zero means 110.
|
||||
Height float32
|
||||
Status data.DataStatus
|
||||
Error string
|
||||
}
|
||||
|
||||
// SystemMonitor combines resource charts sampled by the caller.
|
||||
func SystemMonitor(c *ui.Context, times []time.Time, metrics []MonitorMetric, opts SystemMonitorOptions) core.ChoiceResult {
|
||||
if opts.Height < 0 || opts.Status != data.DataReady && opts.Status != data.DataLoading && opts.Status != data.DataFailed {
|
||||
panic("mujica: SystemMonitor Height is negative")
|
||||
}
|
||||
for i, m := range metrics {
|
||||
if m.Name == "" || len(m.Samples) != len(times) {
|
||||
panic(fmt.Sprintf("mujica: SystemMonitor metric %d needs a Name and one sample per time", i))
|
||||
}
|
||||
}
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = 110
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
var r core.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 = core.NewChoiceResult(r.Element, data.DataPlaceholder(c, opts.Status, opts.Error, core.Msg(c, "system_monitor.empty"), ""))
|
||||
})
|
||||
return
|
||||
}
|
||||
for _, m := range metrics {
|
||||
panel := ui.Column(c).Key(m.Name).Gap(u)
|
||||
series := ui.Local(panel, "mujica.monitor.series", func() []chart.ChartSeries { return []chart.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()
|
||||
if n := len(m.Samples); n > 0 {
|
||||
last = m.Samples[n-1]
|
||||
}
|
||||
if !math.IsNaN(last) && opts.Status == data.DataReady {
|
||||
ui.Text(c, fmt.Sprintf("%s %s", agent.SandboxNum(last), m.Unit)).FontFeatures("tnum").
|
||||
FontSize(core.FontSize(c, theme.CaptionSize+1)).TextColor(k.Text).Role(ui.RoleStatus)
|
||||
}
|
||||
})
|
||||
unit := m.Unit
|
||||
res := chart.LineChart(c, *series, chart.LineChartOptions{
|
||||
Frame: chart.ChartFrame{Label: m.Name, Height: h, Status: opts.Status, Error: opts.Error},
|
||||
Times: times, Format: func(v float64) string { return agent.SandboxNum(v) + " " + unit },
|
||||
})
|
||||
r = core.NewChoiceResult(r.Element, r.Changed() || res.Retried())
|
||||
})
|
||||
}
|
||||
})
|
||||
if r.Changed() {
|
||||
slog.Debug("mujica: system_monitor retry")
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
)
|
||||
|
||||
func monitorSample(n int) ([]time.Time, []MonitorMetric) {
|
||||
t0 := time.Date(2026, 10, 5, 21, 0, 0, 0, time.UTC)
|
||||
times := make([]time.Time, n)
|
||||
cpu, mem := make([]float64, n), make([]float64, n)
|
||||
for i := range n {
|
||||
times[i] = t0.Add(time.Duration(i) * 5 * time.Second)
|
||||
cpu[i] = 40 + 20*math.Sin(float64(i)/4)
|
||||
mem[i] = 3.2 + float64(i)*0.02
|
||||
}
|
||||
return times, []MonitorMetric{{Name: "CPU", Unit: "%", Samples: cpu}, {Name: "Memory", Unit: "GB", Samples: mem}}
|
||||
}
|
||||
|
||||
func TestSystemMonitorLiveAndKeyboard(t *testing.T) {
|
||||
times, metrics := monitorSample(20)
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
SystemMonitor(c, times, metrics, SystemMonitorOptions{})
|
||||
}, 600, 420)
|
||||
if !tt.HasText("3.6 GB") || !tt.HasText("20.0 %") {
|
||||
t.Fatalf("latest samples missing: %q", tt.Texts())
|
||||
}
|
||||
times, metrics = monitorSample(21)
|
||||
tt.Frame()
|
||||
if !tt.HasText("20.8 %") || tt.HasText("20.0 %") {
|
||||
t.Fatalf("new sample: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyTab) // the legend key
|
||||
tt.Key(0, ui.KeyTab) // the plot
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
found := false
|
||||
for _, s := range tt.Texts() {
|
||||
found = found || strings.Contains(s, "21:01:40")
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("keyboard readout: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemMonitorStatesAndRules(t *testing.T) {
|
||||
times, metrics := monitorSample(5)
|
||||
retries := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if SystemMonitor(c, times, metrics[:1], SystemMonitorOptions{Status: data.DataFailed, Error: "agent offline"}).Changed() {
|
||||
retries++
|
||||
}
|
||||
}, 600, 300)
|
||||
if !tt.HasText("agent offline") {
|
||||
t.Fatalf("error: %q", tt.Texts())
|
||||
}
|
||||
_ = tt.Click("Retry")
|
||||
if retries != 1 {
|
||||
t.Fatal("retry not reported")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("mismatched samples did not panic")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
SystemMonitor(c, times, []MonitorMetric{{Name: "x", Samples: []float64{1}}}, SystemMonitorOptions{})
|
||||
}, 200, 200)
|
||||
}
|
||||
|
||||
func TestSystemMonitorSnapshot(t *testing.T) {
|
||||
times, metrics := monitorSample(40)
|
||||
snapshot(t, "system_monitor", 560, 460, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { SystemMonitor(c, times, metrics, SystemMonitorOptions{}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestSystemMonitorEmptyLoadingAndDisabled(t *testing.T) {
|
||||
status := data.DataReady
|
||||
retries := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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 = data.DataLoading
|
||||
tt.Frame()
|
||||
if !tt.HasText("Loading") {
|
||||
t.Fatal("loading resources")
|
||||
}
|
||||
status = data.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) {
|
||||
core.Use(c, core.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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/agent"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/code"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// WaterfallSpan holds timing; empty Parent marks a root.
|
||||
type WaterfallSpan struct {
|
||||
ID, Parent string
|
||||
Name string
|
||||
Service string
|
||||
// Start is the offset from the trace start.
|
||||
Start, Duration time.Duration
|
||||
Error bool
|
||||
}
|
||||
|
||||
// TraceWaterfallOptions configure a TraceWaterfall.
|
||||
type TraceWaterfallOptions struct {
|
||||
// Height of the span list; zero means 320.
|
||||
Height float32
|
||||
}
|
||||
|
||||
// traceRow is a span in tree order with its depth.
|
||||
type traceRow struct {
|
||||
span int
|
||||
depth int
|
||||
}
|
||||
|
||||
type traceState struct {
|
||||
key string
|
||||
rows []traceRow
|
||||
total time.Duration
|
||||
selected string
|
||||
services map[string]int // palette slot of each service
|
||||
}
|
||||
|
||||
// TraceWaterfall shows timed span hierarchies and selected details.
|
||||
func TraceWaterfall(c *ui.Context, spans []WaterfallSpan, opts TraceWaterfallOptions) core.ChoiceResult {
|
||||
if opts.Height < 0 {
|
||||
panic("mujica: TraceWaterfall Height is negative")
|
||||
}
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = 320
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
var r core.ChoiceResult
|
||||
r.Element = code.CodePanel(c).Label(core.Msg(c, "trace_waterfall.label"))
|
||||
st := ui.Local(r.Element, "mujica.trace", func() traceState { return traceState{} })
|
||||
traceLayout(st, spans)
|
||||
r.Element.Children(func() {
|
||||
if len(st.rows) == 0 {
|
||||
code.CodeCaption(c, core.Msg(c, "trace_waterfall.empty")).Padding(u * 4)
|
||||
return
|
||||
}
|
||||
code.CodeHeader(c, func() {
|
||||
code.CodeCaption(c, fmt.Sprintf(core.Msg(c, "trace_waterfall.summary"), len(spans), agent.AgentDuration(st.total))).Grow(1)
|
||||
})
|
||||
agent.AgentRows(c, len(st.rows), func(i int) {
|
||||
row := st.rows[i]
|
||||
s := spans[row.span]
|
||||
if traceSpanRow(c, s, row.depth, st.total, st.services[s.Service], s.ID == st.selected) {
|
||||
st.selected, r = s.ID, core.NewChoiceResult(r.Element, true)
|
||||
slog.Debug("mujica: trace_waterfall select", "span", s.ID)
|
||||
}
|
||||
}).Height(h).FocusGroup(ui.Vertical)
|
||||
for _, s := range spans {
|
||||
if s.ID == st.selected {
|
||||
ui.Column(c).Shrink(0).Gap(u).Padding(u*2, u*3).BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border).
|
||||
Role(ui.RoleStatus).Children(func() {
|
||||
ui.Text(c, s.Name).FontWeight(600)
|
||||
code.CodeCaption(c, fmt.Sprintf(core.Msg(c, "trace_waterfall.detail"), s.Service, agent.AgentDuration(s.Start), agent.AgentDuration(s.Duration)))
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
// traceLayout caches spans in depth-first time order.
|
||||
func traceLayout(st *traceState, spans []WaterfallSpan) {
|
||||
key := fmt.Sprintf("%d", len(spans))
|
||||
if len(spans) > 0 {
|
||||
key += fmt.Sprintf("%p", &spans[0])
|
||||
}
|
||||
if key == st.key {
|
||||
return
|
||||
}
|
||||
st.key, st.rows, st.total, st.services = key, st.rows[:0], 0, map[string]int{}
|
||||
index := make(map[string]int, len(spans))
|
||||
kids := map[string][]int{}
|
||||
for i, s := range spans {
|
||||
if s.ID == "" || s.Name == "" || s.Start < 0 || s.Duration < 0 {
|
||||
panic(fmt.Sprintf("mujica: TraceWaterfall span %d needs an ID, a Name and non-negative times", i))
|
||||
}
|
||||
if _, dup := index[s.ID]; dup {
|
||||
panic("mujica: TraceWaterfall span IDs must be unique: " + s.ID)
|
||||
}
|
||||
index[s.ID] = i
|
||||
if _, ok := st.services[s.Service]; !ok {
|
||||
st.services[s.Service] = len(st.services)
|
||||
}
|
||||
st.total = max(st.total, s.Start+s.Duration)
|
||||
}
|
||||
for i, s := range spans {
|
||||
if _, ok := index[s.Parent]; s.Parent != "" && !ok {
|
||||
panic("mujica: TraceWaterfall span " + s.ID + " has an unknown Parent")
|
||||
}
|
||||
kids[s.Parent] = append(kids[s.Parent], i)
|
||||
}
|
||||
var walk func(parent string, depth int)
|
||||
walk = func(parent string, depth int) {
|
||||
list := kids[parent]
|
||||
slices.SortStableFunc(list, func(a, b int) int { return cmp.Compare(spans[a].Start, spans[b].Start) })
|
||||
for _, i := range list {
|
||||
st.rows = append(st.rows, traceRow{i, depth})
|
||||
walk(spans[i].ID, depth+1)
|
||||
}
|
||||
}
|
||||
walk("", 0)
|
||||
if len(st.rows) != len(spans) {
|
||||
panic("mujica: TraceWaterfall spans form a cycle")
|
||||
}
|
||||
}
|
||||
|
||||
// traceSpanRow shows span indentation, timing, selection and duration.
|
||||
func traceSpanRow(c *ui.Context, s WaterfallSpan, depth int, total time.Duration, slot int, selected bool) bool {
|
||||
k := core.Tokens(c)
|
||||
d := core.Density(c)
|
||||
u := d.Unit()
|
||||
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, agent.AgentDuration(s.Duration)))
|
||||
internal.Ring(row, k.Focus, theme.ControlRadius)
|
||||
if selected {
|
||||
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)
|
||||
}
|
||||
pal := internal.Palette(k)
|
||||
col := pal[slot%len(pal)]
|
||||
if s.Error {
|
||||
col = k.Danger
|
||||
}
|
||||
row.Children(func() {
|
||||
ui.Row(c).Width(220).Shrink(0).Gap(u).Padding(0, 0, 0, float32(depth)*u*4).AlignItems(ui.Center).Children(func() {
|
||||
if s.Error {
|
||||
ui.Icon(c, icons.Must("circle-x")).Size(12, 12).TextColor(k.Danger).Label(core.Msg(c, "trace_waterfall.error"))
|
||||
}
|
||||
ui.Text(c, s.Name).FontSize(core.FontSize(c, theme.CaptionSize+1)).SingleLine().Shrink(1)
|
||||
})
|
||||
ui.Box(c).Grow(1).Height(14).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
span := max(float64(total), 1)
|
||||
x0 := r.X + r.W*float32(float64(s.Start)/span)
|
||||
w := max(r.W*float32(float64(s.Duration)/span), 2)
|
||||
bar := ui.Rect{X: internal.Snap(p, x0), Y: r.Y, W: w, H: r.H}
|
||||
if s.Error { // hollow, so errors differ by shape too
|
||||
p.Fill(bar, col.Alpha(0.25), 2)
|
||||
p.Stroke(bar, col, 2, 1.5)
|
||||
return
|
||||
}
|
||||
p.Fill(bar, col, 2)
|
||||
})
|
||||
ui.Text(c, agent.AgentDuration(s.Duration)).Width(64).TextAlign(ui.End).FontSize(core.FontSize(c, theme.CaptionSize)).
|
||||
TextColor(k.TextMuted).FontFeatures("tnum").Shrink(0)
|
||||
})
|
||||
return !row.IsDisabled() && row.Clicked()
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var waterfallSample = []WaterfallSpan{
|
||||
{ID: "db", Parent: "api", Name: "SELECT guests", Service: "postgres", Start: 40 * time.Millisecond, Duration: 90 * time.Millisecond},
|
||||
{ID: "root", Name: "GET /ball", Service: "gateway", Duration: 320 * time.Millisecond},
|
||||
{ID: "api", Parent: "root", Name: "ListGuests", Service: "api", Start: 20 * time.Millisecond, Duration: 200 * time.Millisecond},
|
||||
{ID: "pay", Parent: "root", Name: "Charge", Service: "payments", Start: 230 * time.Millisecond, Duration: 80 * time.Millisecond, Error: true},
|
||||
}
|
||||
|
||||
func traceTester(spans []WaterfallSpan, changes *int) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if TraceWaterfall(c, spans, TraceWaterfallOptions{}).Changed() {
|
||||
*changes++
|
||||
}
|
||||
}, 700, 460)
|
||||
}
|
||||
|
||||
func TestTraceWaterfallOrderAndDetail(t *testing.T) {
|
||||
n := 0
|
||||
tt := traceTester(waterfallSample, &n)
|
||||
if !tt.HasText("4 spans · 320 ms") {
|
||||
t.Fatalf("summary: %q", tt.Texts())
|
||||
}
|
||||
root, _ := tt.Find("GET /ball")
|
||||
api, _ := tt.Find("ListGuests")
|
||||
db, _ := tt.Find("SELECT guests")
|
||||
pay, _ := tt.Find("Charge")
|
||||
if !(root.Y < api.Y && api.Y < db.Y && db.Y < pay.Y && db.X > api.X && api.X > root.X) {
|
||||
t.Fatalf("tree order: root %v api %v db %v pay %v", root, api, db, pay)
|
||||
}
|
||||
_ = tt.Click("Charge")
|
||||
if n != 1 || !tt.HasText("payments · starts at 230 ms · takes 80 ms") {
|
||||
t.Fatalf("detail: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyUp)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if !tt.HasText("postgres · starts at 40 ms · takes 90 ms") {
|
||||
t.Fatalf("keyboard detail: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceWaterfallRulesAndMany(t *testing.T) {
|
||||
n := 0
|
||||
tt := traceTester(nil, &n)
|
||||
if !tt.HasText("No spans") {
|
||||
t.Fatal("empty")
|
||||
}
|
||||
many := make([]WaterfallSpan, 100000)
|
||||
many[0] = WaterfallSpan{ID: "0", Name: "root", Duration: time.Second}
|
||||
for i := 1; i < len(many); i++ {
|
||||
many[i] = WaterfallSpan{ID: fmt.Sprint(i), Parent: "0", Name: fmt.Sprint("span ", i), Start: time.Duration(i) * time.Microsecond, Duration: time.Microsecond}
|
||||
}
|
||||
tt = traceTester(many, &n)
|
||||
if got := len(tt.Texts()); got > 400 {
|
||||
t.Fatalf("%d texts for 100000 spans", got)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("cycle did not panic")
|
||||
}
|
||||
}()
|
||||
traceTester([]WaterfallSpan{{ID: "a", Parent: "b", Name: "a"}, {ID: "b", Parent: "a", Name: "b"}}, &n)
|
||||
}
|
||||
|
||||
func TestTraceWaterfallSnapshot(t *testing.T) {
|
||||
snapshot(t, "trace_waterfall", 680, 280, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() { TraceWaterfall(c, waterfallSample, TraceWaterfallOptions{Height: 180}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestTraceWaterfallAncestorDisabled(t *testing.T) {
|
||||
disabled := false
|
||||
changes := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Disabled(disabled).Children(func() {
|
||||
if TraceWaterfall(c, waterfallSample, 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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chart"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
)
|
||||
|
||||
// UptimeDay holds availability; NaN marks missing data.
|
||||
type UptimeDay struct {
|
||||
Date core.Date
|
||||
Uptime float64
|
||||
// Note says what happened, as "12 min outage".
|
||||
Note string
|
||||
}
|
||||
|
||||
// UptimeBarOptions configure an UptimeBar; Frame.Height 0 means 36.
|
||||
type UptimeBarOptions struct {
|
||||
Frame chart.ChartFrame
|
||||
// Degraded and Down default to 0.999 and 0.95.
|
||||
Degraded, Down float64
|
||||
}
|
||||
|
||||
// UptimeBar shows daily availability with pointer and keyboard readouts.
|
||||
func UptimeBar(c *ui.Context, days []UptimeDay, opts UptimeBarOptions) chart.ChartResult {
|
||||
deg, down := opts.Degraded, opts.Down
|
||||
if deg == 0 {
|
||||
deg = 0.999
|
||||
}
|
||||
if down == 0 {
|
||||
down = 0.95
|
||||
}
|
||||
if !(down < deg && deg <= 1 && down > 0) {
|
||||
panic("mujica: UptimeBar needs 0 < Down < Degraded <= 1")
|
||||
}
|
||||
|
||||
if opts.Frame.Height == 0 {
|
||||
opts.Frame.Height = 36
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
ch := chart.NewChart(c, opts.Frame)
|
||||
n := len(days)
|
||||
cache := ui.Local(ch.Res.Element, "mujica.uptime", func() uptimeCache { return uptimeCache{} })
|
||||
if cache.key != chart.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 = chart.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 {
|
||||
case math.IsNaN(v):
|
||||
return 3
|
||||
case v < down:
|
||||
return 2
|
||||
case v < deg:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
pal := internal.Palette(k)
|
||||
words := [...]string{core.Msg(c, "uptime_bar.ok"), core.Msg(c, "uptime_bar.degraded"), core.Msg(c, "uptime_bar.down"), core.Msg(c, "uptime_bar.nodata")}
|
||||
res, _ := ch.Build(chart.ChartSpec{
|
||||
Marks: n,
|
||||
Draw: func(p *ui.Painter, r ui.Rect, active int) {
|
||||
b := band(r)
|
||||
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{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]
|
||||
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)
|
||||
}
|
||||
}
|
||||
},
|
||||
Pick: func(r ui.Rect, x, _ float32) int {
|
||||
b := band(r)
|
||||
return max(0, min(n-1, int((x-r.X)/b.Step())))
|
||||
},
|
||||
Readout: func(i int) string {
|
||||
d := days[i]
|
||||
s := fmt.Sprintf("%s · %s", d.Date.String(), words[level(d.Uptime)])
|
||||
if !math.IsNaN(d.Uptime) {
|
||||
s += fmt.Sprintf(" · %.2f%%", d.Uptime*100)
|
||||
}
|
||||
if d.Note != "" {
|
||||
s += " · " + d.Note
|
||||
}
|
||||
return s
|
||||
},
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package devtools
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chart"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
)
|
||||
|
||||
func uptimeSample(n int) []UptimeDay {
|
||||
days := make([]UptimeDay, n)
|
||||
start := core.Date{Year: 2026, Month: 9, Day: 1}
|
||||
for i := range days {
|
||||
days[i] = UptimeDay{Date: start.AddDays(i), Uptime: 1}
|
||||
}
|
||||
days[3] = UptimeDay{Date: start.AddDays(3), Uptime: 0.9975, Note: "slow search"}
|
||||
days[7] = UptimeDay{Date: start.AddDays(7), Uptime: 0.91, Note: "2 h outage"}
|
||||
days[9].Uptime = math.NaN()
|
||||
return days
|
||||
}
|
||||
|
||||
func uptimeTester(days []UptimeDay, frame chart.ChartFrame, points *[]int) *ui.Tester {
|
||||
if frame.Label == "" {
|
||||
frame.Label = "API uptime"
|
||||
}
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(10).Children(func() {
|
||||
r := UptimeBar(c, days, UptimeBarOptions{Frame: frame})
|
||||
*points = append(*points, r.Point)
|
||||
})
|
||||
}, 520, 140)
|
||||
}
|
||||
|
||||
func TestUptimeBarKeyboardReadout(t *testing.T) {
|
||||
var pts []int
|
||||
tt := uptimeTester(uptimeSample(30), chart.ChartFrame{}, &pts)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
for range 7 {
|
||||
tt.Key(0, ui.KeyRight)
|
||||
}
|
||||
if !tt.HasText("2026-09-08 · Outage · 91.00% · 2 h outage") {
|
||||
t.Fatalf("readout: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !tt.HasText("2026-09-10 · No data") {
|
||||
t.Fatalf("no data readout: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUptimeBarHover(t *testing.T) {
|
||||
var pts []int
|
||||
tt := uptimeTester(uptimeSample(30), chart.ChartFrame{}, &pts)
|
||||
// The 4th of 30 bars across 500 DIPs starting at x = 10.
|
||||
tt.Move(10+500*3.5/30, 30)
|
||||
if pts[len(pts)-1] != 3 || !tt.HasText("2026-09-04 · Degraded · 99.75% · slow search") {
|
||||
t.Fatalf("hover point %d: %q", pts[len(pts)-1], tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUptimeBarStatesAndRules(t *testing.T) {
|
||||
var pts []int
|
||||
tt := uptimeTester(nil, chart.ChartFrame{}, &pts)
|
||||
if !tt.HasText("No data") {
|
||||
t.Fatalf("empty: %q", tt.Texts())
|
||||
}
|
||||
tt = uptimeTester(uptimeSample(12), chart.ChartFrame{Status: data.DataLoading}, &pts)
|
||||
if !tt.HasText("Loading") {
|
||||
t.Fatalf("loading: %q", tt.Texts())
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("uptime above 1 did not panic")
|
||||
}
|
||||
}()
|
||||
uptimeTester([]UptimeDay{{Uptime: 1.2}}, chart.ChartFrame{}, &pts)
|
||||
}
|
||||
|
||||
func TestUptimeBarSnapshot(t *testing.T) {
|
||||
snapshot(t, "uptime_bar", 520, 110, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(10).Children(func() {
|
||||
UptimeBar(c, uptimeSample(45), UptimeBarOptions{Frame: chart.ChartFrame{Label: "API uptime"}})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestUptimeBarManyDaysBounded(t *testing.T) {
|
||||
days := uptimeSample(100000)
|
||||
var pts []int
|
||||
tt := uptimeTester(days, chart.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user