diff --git a/eos/client_test.go b/eos/client_test.go index 8bcb918..cf4f99e 100644 --- a/eos/client_test.go +++ b/eos/client_test.go @@ -1347,6 +1347,119 @@ func TestIOShapingPolicySetArgsForGroupDisable(t *testing.T) { } } +func TestIOShapingNodesUsesNodesFlag(t *testing.T) { + runner := &recordingRunner{out: []byte(`[{"id":"node1","type":"node","window_sec":5,"read_rate_bps":1000,"write_rate_bps":2000,"read_iops":3,"write_iops":4}]`)} + client := &Client{timeout: time.Minute, runner: runner} + + records, err := client.IOShaping(context.Background(), IOShapingNodes) + if err != nil { + t.Fatalf("IOShaping(nodes) error: %v", err) + } + if len(records) != 1 || records[0].ID != "node1" || records[0].ReadBps != 1000 { + t.Fatalf("unexpected node shaping records: %+v", records) + } + got := append([]string{runner.calls[0].name}, runner.calls[0].args...) + want := []string{"eos", "io", "shaping", "ls", "--nodes", "--json", "--window", "5"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("unexpected io shaping nodes args: got %v want %v", got, want) + } +} + +func TestIOShapingPressureParsesRecords(t *testing.T) { + runner := &recordingRunner{out: []byte(`[{ + "type":"app_node_pressure", + "app":"bench", + "node_id":"node1:1095", + "node_io_pressure":0.25, + "has_node_io_pressure":true, + "read_rate_bps":1000, + "write_rate_bps":2000, + "global_read_rate_bps":3000, + "global_write_rate_bps":4000, + "reservation_read_bytes_per_sec":5000, + "reservation_write_bytes_per_sec":6000, + "read_reservation_deficit_bps":7000, + "write_reservation_deficit_bps":8000, + "read_pressure_active":true, + "write_pressure_active":false, + "read_reservation_deficit_active":true, + "write_reservation_deficit_active":false, + "read_triggers_competitor_throttling":false, + "write_triggers_competitor_throttling":true, + "node_has_pressured_read_reservation":true, + "node_has_pressured_write_reservation":false + }]`)} + client := &Client{timeout: time.Minute, runner: runner} + + records, err := client.IOShapingPressure(context.Background()) + if err != nil { + t.Fatalf("IOShapingPressure() error: %v", err) + } + if len(records) != 1 { + t.Fatalf("expected one pressure record, got %d", len(records)) + } + gotRecord := records[0] + if gotRecord.App != "bench" || gotRecord.NodeID != "node1:1095" || gotRecord.NodeIOPressure != 0.25 { + t.Fatalf("unexpected pressure record identity: %+v", gotRecord) + } + if !gotRecord.ReadPressureActive || !gotRecord.ReadReservationDeficitActive || !gotRecord.WriteTriggersCompetitorThrottling || !gotRecord.NodeHasPressuredReadReservation { + t.Fatalf("expected pressure booleans to parse, got %+v", gotRecord) + } + got := append([]string{runner.calls[0].name}, runner.calls[0].args...) + want := []string{"eos", "io", "shaping", "pressure", "ls", "--json"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("unexpected io shaping pressure args: got %v want %v", got, want) + } +} + +func TestIOShapingConfigParsesLimitsEnabled(t *testing.T) { + runner := &recordingRunner{out: []byte(`{"limits_enabled":true}`)} + client := &Client{timeout: time.Minute, runner: runner} + + config, err := client.IOShapingConfig(context.Background()) + if err != nil { + t.Fatalf("IOShapingConfig() error: %v", err) + } + if !config.LimitsEnabled { + t.Fatalf("expected LimitsEnabled=true") + } + if len(runner.calls) != 1 { + t.Fatalf("expected one command, got %d", len(runner.calls)) + } + got := append([]string{runner.calls[0].name}, runner.calls[0].args...) + want := []string{"eos", "io", "shaping", "config", "ls", "--json"} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("unexpected io shaping config args: got %v want %v", got, want) + } +} + +func TestSetIOShapingLimitsEnabledArgs(t *testing.T) { + for _, tc := range []struct { + enabled bool + want string + }{ + {enabled: true, want: "enabled"}, + {enabled: false, want: "disabled"}, + } { + t.Run(tc.want, func(t *testing.T) { + runner := &recordingRunner{} + client := &Client{timeout: time.Minute, runner: runner} + + if err := client.SetIOShapingLimitsEnabled(context.Background(), tc.enabled); err != nil { + t.Fatalf("SetIOShapingLimitsEnabled() error: %v", err) + } + if len(runner.calls) != 1 { + t.Fatalf("expected one command, got %d", len(runner.calls)) + } + got := append([]string{runner.calls[0].name}, runner.calls[0].args...) + want := []string{"eos", "io", "shaping", "config", "set", "--limits", tc.want} + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("unexpected io shaping limits args: got %v want %v", got, want) + } + }) + } +} + func TestGroupSetArgsForDrain(t *testing.T) { got, err := groupSetArgs("default.1", "drain") if err != nil { diff --git a/eos/fetch_ioshaping.go b/eos/fetch_ioshaping.go index 37355e2..22a3af2 100644 --- a/eos/fetch_ioshaping.go +++ b/eos/fetch_ioshaping.go @@ -35,6 +35,20 @@ func looksUnsupported(err error, output []byte) bool { return !strings.Contains(text, "io shaping") && !strings.Contains(text, "io_shaping") } +func looksPressureUnsupported(err error, output []byte) bool { + if err == nil { + return false + } + if !strings.Contains(err.Error(), "exit status 22") { + return false + } + text := string(output) + if !strings.Contains(text, "usage:") { + return false + } + return !strings.Contains(text, "pressure ls") && !strings.Contains(text, "io shaping pressure") +} + func (c *Client) IOShaping(ctx context.Context, mode IOShapingMode) ([]IOShapingRecord, error) { flag := "--apps" switch mode { @@ -42,6 +56,8 @@ func (c *Client) IOShaping(ctx context.Context, mode IOShapingMode) ([]IOShaping flag = "--users" case IOShapingGroups: flag = "--groups" + case IOShapingNodes: + flag = "--nodes" } output, err := c.runCommandContext(ctx, "eos", "io", "shaping", "ls", flag, "--json", "--window", "5") if err != nil { @@ -79,6 +95,71 @@ func (c *Client) IOShaping(ctx context.Context, mode IOShapingMode) ([]IOShaping return records, nil } +func (c *Client) IOShapingPressure(ctx context.Context) ([]IOShapingPressureRecord, error) { + output, err := c.runCommandContext(ctx, "eos", "io", "shaping", "pressure", "ls", "--json") + if err != nil { + if looksUnsupported(err, output) || looksPressureUnsupported(err, output) { + return nil, ErrIOShapingUnsupported + } + return nil, fmt.Errorf("io shaping pressure ls: %w: %s", err, strings.TrimSpace(string(output))) + } + + var raw []struct { + Type string `json:"type"` + App string `json:"app"` + NodeID string `json:"node_id"` + NodeIOPressure float64 `json:"node_io_pressure"` + HasNodeIOPressure bool `json:"has_node_io_pressure"` + ReadRateBps float64 `json:"read_rate_bps"` + WriteRateBps float64 `json:"write_rate_bps"` + GlobalReadRateBps float64 `json:"global_read_rate_bps"` + GlobalWriteRateBps float64 `json:"global_write_rate_bps"` + ReservationReadBytesPerSec float64 `json:"reservation_read_bytes_per_sec"` + ReservationWriteBytesPerSec float64 `json:"reservation_write_bytes_per_sec"` + ReadReservationDeficitBps float64 `json:"read_reservation_deficit_bps"` + WriteReservationDeficitBps float64 `json:"write_reservation_deficit_bps"` + ReadPressureActive bool `json:"read_pressure_active"` + WritePressureActive bool `json:"write_pressure_active"` + ReadReservationDeficitActive bool `json:"read_reservation_deficit_active"` + WriteReservationDeficitActive bool `json:"write_reservation_deficit_active"` + ReadTriggersCompetitorThrottling bool `json:"read_triggers_competitor_throttling"` + WriteTriggersCompetitorThrottling bool `json:"write_triggers_competitor_throttling"` + NodeHasPressuredReadReservation bool `json:"node_has_pressured_read_reservation"` + NodeHasPressuredWriteReservation bool `json:"node_has_pressured_write_reservation"` + } + if err := json.Unmarshal(stripEOSPreamble(output), &raw); err != nil { + return nil, fmt.Errorf("parse io shaping pressure: %w", err) + } + + records := make([]IOShapingPressureRecord, len(raw)) + for i, r := range raw { + records[i] = IOShapingPressureRecord{ + Type: r.Type, + App: r.App, + NodeID: r.NodeID, + NodeIOPressure: r.NodeIOPressure, + HasNodeIOPressure: r.HasNodeIOPressure, + ReadRateBps: r.ReadRateBps, + WriteRateBps: r.WriteRateBps, + GlobalReadRateBps: r.GlobalReadRateBps, + GlobalWriteRateBps: r.GlobalWriteRateBps, + ReservationReadBytesPerSec: r.ReservationReadBytesPerSec, + ReservationWriteBytesPerSec: r.ReservationWriteBytesPerSec, + ReadReservationDeficitBps: r.ReadReservationDeficitBps, + WriteReservationDeficitBps: r.WriteReservationDeficitBps, + ReadPressureActive: r.ReadPressureActive, + WritePressureActive: r.WritePressureActive, + ReadReservationDeficitActive: r.ReadReservationDeficitActive, + WriteReservationDeficitActive: r.WriteReservationDeficitActive, + ReadTriggersCompetitorThrottling: r.ReadTriggersCompetitorThrottling, + WriteTriggersCompetitorThrottling: r.WriteTriggersCompetitorThrottling, + NodeHasPressuredReadReservation: r.NodeHasPressuredReadReservation, + NodeHasPressuredWriteReservation: r.NodeHasPressuredWriteReservation, + } + } + return records, nil +} + func (c *Client) IOShapingPolicies(ctx context.Context) ([]IOShapingPolicyRecord, error) { output, err := c.runCommandContext(ctx, "eos", "io", "shaping", "policy", "ls", "--json") if err != nil { @@ -116,6 +197,36 @@ func (c *Client) IOShapingPolicies(ctx context.Context) ([]IOShapingPolicyRecord return records, nil } +func (c *Client) IOShapingConfig(ctx context.Context) (IOShapingConfig, error) { + output, err := c.runCommandContext(ctx, "eos", "io", "shaping", "config", "ls", "--json") + if err != nil { + if looksUnsupported(err, output) { + return IOShapingConfig{}, ErrIOShapingUnsupported + } + return IOShapingConfig{}, fmt.Errorf("io shaping config ls: %w: %s", err, strings.TrimSpace(string(output))) + } + + var raw struct { + LimitsEnabled bool `json:"limits_enabled"` + } + if err := json.Unmarshal(stripEOSPreamble(output), &raw); err != nil { + return IOShapingConfig{}, fmt.Errorf("parse io shaping config: %w", err) + } + + return IOShapingConfig{LimitsEnabled: raw.LimitsEnabled}, nil +} + +func (c *Client) SetIOShapingLimitsEnabled(ctx context.Context, enabled bool) error { + state := "disabled" + if enabled { + state = "enabled" + } + if _, err := c.runCommandContext(ctx, "eos", "io", "shaping", "config", "set", "--limits", state); err != nil { + return fmt.Errorf("eos io shaping config set --limits %s: %w", state, err) + } + return nil +} + func (c *Client) SetIOShapingPolicy(ctx context.Context, update IOShapingPolicyUpdate) error { args, err := ioShapingPolicySetArgs(update) if err != nil { diff --git a/eos/types.go b/eos/types.go index 61d0dc1..b380275 100644 --- a/eos/types.go +++ b/eos/types.go @@ -259,6 +259,8 @@ const ( IOShapingApps IOShapingMode = iota IOShapingUsers IOShapingGroups + IOShapingNodes + IOShapingPressure ) type IOShapingRecord struct { @@ -281,6 +283,34 @@ type IOShapingPolicyRecord struct { ReservationWriteBytesPerSec float64 } +type IOShapingPressureRecord struct { + Type string + App string + NodeID string + NodeIOPressure float64 + HasNodeIOPressure bool + ReadRateBps float64 + WriteRateBps float64 + GlobalReadRateBps float64 + GlobalWriteRateBps float64 + ReservationReadBytesPerSec float64 + ReservationWriteBytesPerSec float64 + ReadReservationDeficitBps float64 + WriteReservationDeficitBps float64 + ReadPressureActive bool + WritePressureActive bool + ReadReservationDeficitActive bool + WriteReservationDeficitActive bool + ReadTriggersCompetitorThrottling bool + WriteTriggersCompetitorThrottling bool + NodeHasPressuredReadReservation bool + NodeHasPressuredWriteReservation bool +} + +type IOShapingConfig struct { + LimitsEnabled bool +} + type IOShapingPolicyUpdate struct { Mode IOShapingMode ID string diff --git a/go.mod b/go.mod index 2d2df46..17bd944 100644 --- a/go.mod +++ b/go.mod @@ -14,19 +14,23 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/golden v0.0.0-20260519012233-798e623c8447 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect ) diff --git a/go.sum b/go.sum index 4b2f066..389a127 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -16,8 +16,8 @@ github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dA github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20260519012233-798e623c8447 h1:Vf/iZjiVpcDL0s8PUfwD0UiPNeJqqj8VTiZi84WBxIo= +github.com/charmbracelet/x/exp/golden v0.0.0-20260519012233-798e623c8447/go.mod h1:6fMpcW6iwN/kX+xJ52eqVWsDiBTe0UJD24JLoHFe+P0= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= @@ -26,10 +26,12 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= @@ -42,13 +44,14 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.2 h1:kdSkz23lx1meNjEl+SLJULeSbjTI4Dn14K/YxdGrIww= +github.com/sahilm/fuzzy v0.1.2/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= +golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= diff --git a/ui/commands.go b/ui/commands.go index 0356265..49a158b 100644 --- a/ui/commands.go +++ b/ui/commands.go @@ -206,6 +206,29 @@ func loadIOShapingCmd(client *eos.Client, mode eos.IOShapingMode) tea.Cmd { } } +func loadIOShapingPressureCmd(client *eos.Client) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + records, err := client.IOShapingPressure(ctx) + return ioShapingPressureLoadedMsg{records: records, mode: eos.IOShapingPressure, err: err} + } +} + +func loadIOShapingViewCmd(client *eos.Client, mode eos.IOShapingMode) tea.Cmd { + if mode == eos.IOShapingPressure { + return loadIOShapingPressureCmd(client) + } + return loadIOShapingCmd(client, mode) +} + +func loadIOShapingPolicyDataCmd(client *eos.Client, mode eos.IOShapingMode) tea.Cmd { + if !ioShapingModeHasPolicies(mode) { + return loadIOShapingConfigCmd(client) + } + return tea.Batch(loadIOShapingPoliciesCmd(client), loadIOShapingConfigCmd(client)) +} + func loadIOShapingPoliciesCmd(client *eos.Client) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -215,6 +238,15 @@ func loadIOShapingPoliciesCmd(client *eos.Client) tea.Cmd { } } +func loadIOShapingConfigCmd(client *eos.Client) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + config, err := client.IOShapingConfig(ctx) + return ioShapingConfigLoadedMsg{config: config, err: err} + } +} + func runIOShapingPolicySetCmd(client *eos.Client, update eos.IOShapingPolicyUpdate) tea.Cmd { return func() tea.Msg { err := client.SetIOShapingPolicy(context.Background(), update) @@ -222,6 +254,13 @@ func runIOShapingPolicySetCmd(client *eos.Client, update eos.IOShapingPolicyUpda } } +func runIOShapingLimitsToggleCmd(client *eos.Client, enabled bool) tea.Cmd { + return func() tea.Msg { + err := client.SetIOShapingLimitsEnabled(context.Background(), enabled) + return ioShapingLimitsToggleResultMsg{enabled: enabled, err: err} + } +} + func runIOShapingPolicyRemoveCmd(client *eos.Client, mode eos.IOShapingMode, id string) tea.Cmd { return func() tea.Msg { err := client.RemoveIOShapingPolicy(context.Background(), mode, id) diff --git a/ui/io_shaping_edit.go b/ui/io_shaping_edit.go index a83d3e5..decf744 100644 --- a/ui/io_shaping_edit.go +++ b/ui/io_shaping_edit.go @@ -99,16 +99,28 @@ func (m model) ioShapingPolicyEditForTarget(targetID string, createMode bool) io } func (m model) startIOShapingPolicyEdit() (tea.Model, tea.Cmd) { + if !ioShapingModeHasPolicies(m.ioShapingMode) { + m.status = "This IO shaping view is read-only" + return m, nil + } row, ok := m.selectedIOShapingRow() if !ok { return m, nil } + if row.total { + m.status = "The total IO shaping row is read-only" + return m, nil + } m.ioShapingEdit = m.ioShapingPolicyEditForTarget(row.id, false) return m, nil } func (m model) startIOShapingPolicyCreate() (tea.Model, tea.Cmd) { + if !ioShapingModeHasPolicies(m.ioShapingMode) { + m.status = "Switch to apps, users, or groups to create IO shaping policies" + return m, nil + } input := newIOShapingEditInput(ioShapingTargetPrompt(m.ioShapingMode)) cmd := input.Focus() m.ioShapingEdit = ioShapingPolicyEdit{ @@ -122,8 +134,16 @@ func (m model) startIOShapingPolicyCreate() (tea.Model, tea.Cmd) { } func (m model) startIOShapingPolicyDeleteConfirm() (tea.Model, tea.Cmd) { + if !ioShapingModeHasPolicies(m.ioShapingMode) { + m.status = "This IO shaping view is read-only" + return m, nil + } row, ok := m.selectedIOShapingRow() if !ok || row.policy == nil { + if ok && row.total { + m.status = "The total IO shaping row is read-only" + return m, nil + } m.alert = errorAlert{ active: true, message: "No IO shaping policy is configured for the selected row.", @@ -187,6 +207,12 @@ func (m model) updateIOShapingPolicyEditKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd m.ioShapingEdit.stage = ioShapingEditStageDeleteConfirm m.ioShapingEdit.button = buttonCancel return m, nil + case "e": + m.ioShapingEdit.enabled = !m.ioShapingEdit.enabled + m.ioShapingEdit.err = "" + return m, nil + case "y": + return m.applyIOShapingPolicyEdit() case "up", "k": if m.ioShapingEdit.selected > ioShapingEditFieldEnabled { m.ioShapingEdit.selected-- @@ -203,18 +229,10 @@ func (m model) updateIOShapingPolicyEditKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd return m, nil case ioShapingEditFieldLimitRead, ioShapingEditFieldLimitWrite, ioShapingEditFieldReservationRead, ioShapingEditFieldReservationWrite: m.ioShapingEdit.stage = ioShapingEditStageInput - m.ioShapingEdit.input.SetValue(m.ioShapingEdit.valueForField(m.ioShapingEdit.selected)) + m.ioShapingEdit.input.SetValue("") return m, m.ioShapingEdit.input.Focus() case ioShapingEditFieldApply: - update, err := m.ioShapingEdit.policyUpdate() - if err != nil { - m.ioShapingEdit.err = err.Error() - return m, nil - } - m.ioShapingEdit.active = false - m.ioShapingLoading = true - m.status = fmt.Sprintf("Updating IO shaping policy for %s", update.ID) - return m, runIOShapingPolicySetCmd(m.client, update) + return m.applyIOShapingPolicyEdit() } } case ioShapingEditStageDeleteConfirm: @@ -273,6 +291,18 @@ func (m model) updateIOShapingPolicyEditKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd return m, nil } +func (m model) applyIOShapingPolicyEdit() (tea.Model, tea.Cmd) { + update, err := m.ioShapingEdit.policyUpdate() + if err != nil { + m.ioShapingEdit.err = err.Error() + return m, nil + } + m.ioShapingEdit.active = false + m.ioShapingLoading = true + m.status = fmt.Sprintf("Updating IO shaping policy for %s", update.ID) + return m, runIOShapingPolicySetCmd(m.client, update) +} + func (edit ioShapingPolicyEdit) policyUpdate() (eos.IOShapingPolicyUpdate, error) { limitRead, err := parseIOShapingRate(edit.limitRead) if err != nil { @@ -411,7 +441,7 @@ func (m model) renderIOShapingPolicyEditPopup() string { m.styles.status.Render("g cancel • G delete • enter confirm • esc close"), } } else { - lines = append(lines, "", m.styles.status.Render("↑↓ select • g/G home/end • enter edit/toggle/apply • d delete • esc cancel")) + lines = append(lines, "", m.styles.status.Render("↑↓ select • e toggle enabled • y apply • enter edit/toggle/apply • d delete • esc cancel")) } if m.ioShapingEdit.stage != ioShapingEditStageDeleteConfirm { lines = append(lines, m.styles.status.Render("values accept raw bytes/s or KB/MB/GB suffixes")) diff --git a/ui/keys.go b/ui/keys.go index b81ca49..e23e3f9 100644 --- a/ui/keys.go +++ b/ui/keys.go @@ -560,51 +560,80 @@ func (m model) updateSpaceStatusKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m model) updateIOShapingKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { half := max(1, m.height/6) - n := len(m.ioShapingMergedRows()) + rowCount := len(m.ioShapingMergedRows()) + if m.ioShapingMode == eos.IOShapingPressure { + rowCount = len(m.ioShapingPressure) + } switch msg.String() { case "a": if m.ioShapingMode != eos.IOShapingApps { m.ioShapingMode = eos.IOShapingApps m.ioShapingSelected = 0 m.ioShapingLoading = true - return m, tea.Batch(loadIOShapingCmd(m.client, m.ioShapingMode), loadIOShapingPoliciesCmd(m.client)) + return m, tea.Batch(loadIOShapingViewCmd(m.client, m.ioShapingMode), loadIOShapingPolicyDataCmd(m.client, m.ioShapingMode)) } case "u": if m.ioShapingMode != eos.IOShapingUsers { m.ioShapingMode = eos.IOShapingUsers m.ioShapingSelected = 0 m.ioShapingLoading = true - return m, tea.Batch(loadIOShapingCmd(m.client, m.ioShapingMode), loadIOShapingPoliciesCmd(m.client)) + return m, tea.Batch(loadIOShapingViewCmd(m.client, m.ioShapingMode), loadIOShapingPolicyDataCmd(m.client, m.ioShapingMode)) } case "g": if m.ioShapingMode != eos.IOShapingGroups { m.ioShapingMode = eos.IOShapingGroups m.ioShapingSelected = 0 m.ioShapingLoading = true - return m, tea.Batch(loadIOShapingCmd(m.client, m.ioShapingMode), loadIOShapingPoliciesCmd(m.client)) + return m, tea.Batch(loadIOShapingViewCmd(m.client, m.ioShapingMode), loadIOShapingPolicyDataCmd(m.client, m.ioShapingMode)) } case "n": + if m.ioShapingMode != eos.IOShapingNodes { + m.ioShapingMode = eos.IOShapingNodes + m.ioShapingSelected = 0 + m.ioShapingLoading = true + return m, tea.Batch(loadIOShapingViewCmd(m.client, m.ioShapingMode), loadIOShapingPolicyDataCmd(m.client, m.ioShapingMode)) + } + case "p": + if m.ioShapingMode != eos.IOShapingPressure { + m.ioShapingMode = eos.IOShapingPressure + m.ioShapingSelected = 0 + m.ioShapingLoading = true + return m, tea.Batch(loadIOShapingViewCmd(m.client, m.ioShapingMode), loadIOShapingPolicyDataCmd(m.client, m.ioShapingMode)) + } + case "N": return m.startIOShapingPolicyCreate() + case "m": + if !m.ioShapingConfigLoaded { + m.status = "Loading IO shaping controller limits state..." + return m, loadIOShapingConfigCmd(m.client) + } + nextEnabled := !m.ioShapingConfig.LimitsEnabled + if nextEnabled { + m.status = "Enabling IO shaping controller limits..." + } else { + m.status = "Disabling IO shaping controller limits..." + } + return m, runIOShapingLimitsToggleCmd(m.client, nextEnabled) case "up", "k": if m.ioShapingSelected > 0 { m.ioShapingSelected-- } case "down", "j": - if m.ioShapingSelected < n-1 { + if m.ioShapingSelected < rowCount-1 { m.ioShapingSelected++ } case "ctrl+u": m.ioShapingSelected = max(0, m.ioShapingSelected-half) case "ctrl+d": - m.ioShapingSelected = min(n-1, m.ioShapingSelected+half) + m.ioShapingSelected = min(rowCount-1, m.ioShapingSelected+half) case "G": - m.ioShapingSelected = max(0, n-1) + m.ioShapingSelected = max(0, rowCount-1) case "enter": return m.startIOShapingPolicyEdit() case "d": return m.startIOShapingPolicyDeleteConfirm() } - m.ioShapingSelected = clampIndex(m.ioShapingSelected, n) + m.ioShapingSelected = clampIndex(m.ioShapingSelected, rowCount) return m, nil } diff --git a/ui/model.go b/ui/model.go index b87099e..e51f1ee 100644 --- a/ui/model.go +++ b/ui/model.go @@ -114,7 +114,7 @@ func (m model) Init() tea.Cmd { case viewGroups: cmds = append(cmds, loadGroupsCmd(m.client)) case viewIOShaping: - cmds = append(cmds, loadIOShapingCmd(m.client, m.ioShapingMode), loadIOShapingPoliciesCmd(m.client), ioShapingTickCmd(), ioShapingPolicyTickCmd()) + cmds = append(cmds, loadIOShapingViewCmd(m.client, m.ioShapingMode), loadIOShapingPolicyDataCmd(m.client, m.ioShapingMode), ioShapingTickCmd(), ioShapingPolicyTickCmd()) case viewVID: cmds = append(cmds, loadVIDCmd(m.client, m.vidMode)) case viewAccess: @@ -679,19 +679,40 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { delayedReloadMGMVersionsCmd(m.client, qdbCoupRefreshDelay), ) case ioShapingLoadedMsg: + if msg.mode != m.ioShapingMode { + return m, nil + } m.ioShapingLoading = false if msg.err != nil { m.ioShapingErr = msg.err - } else if msg.mode == m.ioShapingMode { + } else { m.ioShaping = msg.records m.ioShapingErr = nil m.ioShapingSelected = clampIndex(m.ioShapingSelected, len(m.ioShapingMergedRows())) } + case ioShapingPressureLoadedMsg: + if msg.mode != m.ioShapingMode { + return m, nil + } + m.ioShapingLoading = false + if msg.err != nil { + m.ioShapingErr = msg.err + } else { + m.ioShapingPressure = msg.records + m.ioShapingErr = nil + m.ioShapingSelected = clampIndex(m.ioShapingSelected, len(m.ioShapingPressure)) + } case ioShapingPoliciesLoadedMsg: if msg.err == nil { m.ioShapingPolicies = msg.records m.ioShapingSelected = clampIndex(m.ioShapingSelected, len(m.ioShapingMergedRows())) } + case ioShapingConfigLoadedMsg: + m.ioShapingConfigErr = msg.err + if msg.err == nil { + m.ioShapingConfig = msg.config + m.ioShapingConfigLoaded = true + } case ioShapingPolicyResultMsg: if msg.err != nil { m.alert = errorAlert{ @@ -705,17 +726,33 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.status = fmt.Sprintf("Updated IO shaping policy for %s", msg.id) } - return m, tea.Batch(loadIOShapingCmd(m.client, m.ioShapingMode), loadIOShapingPoliciesCmd(m.client)) + return m, tea.Batch(loadIOShapingViewCmd(m.client, m.ioShapingMode), loadIOShapingPolicyDataCmd(m.client, m.ioShapingMode)) + case ioShapingLimitsToggleResultMsg: + if msg.err != nil { + m.alert = errorAlert{ + active: true, + message: fmt.Sprintf("io shaping controller limits toggle failed: %v", msg.err), + } + return m, loadIOShapingConfigCmd(m.client) + } + m.ioShapingConfig.LimitsEnabled = msg.enabled + m.ioShapingConfigLoaded = true + if msg.enabled { + m.status = "Enabled IO shaping controller limits" + } else { + m.status = "Disabled IO shaping controller limits" + } + return m, tea.Batch(loadIOShapingViewCmd(m.client, m.ioShapingMode), loadIOShapingPolicyDataCmd(m.client, m.ioShapingMode)) case ioShapingTickMsg: if m.activeView == viewIOShaping && !m.ioShapingLoading { m.ioShapingLoading = true - return m, tea.Batch(loadIOShapingCmd(m.client, m.ioShapingMode), ioShapingTickCmd()) + return m, tea.Batch(loadIOShapingViewCmd(m.client, m.ioShapingMode), ioShapingTickCmd()) } else if m.activeView == viewIOShaping { return m, ioShapingTickCmd() } case ioShapingPolicyTickMsg: if m.activeView == viewIOShaping { - return m, tea.Batch(loadIOShapingPoliciesCmd(m.client), ioShapingPolicyTickCmd()) + return m, tea.Batch(loadIOShapingPolicyDataCmd(m.client, m.ioShapingMode), ioShapingPolicyTickCmd()) } case eosCheckResultMsg: if msg.err != nil { @@ -974,7 +1011,7 @@ func (m model) onViewChanged() (tea.Model, tea.Cmd) { case viewIOShaping: m.ioShapingLoading = true m.ioShapingErr = nil - return m, tea.Batch(loadIOShapingCmd(m.client, m.ioShapingMode), ioShapingTickCmd(), loadIOShapingPoliciesCmd(m.client), ioShapingPolicyTickCmd()) + return m, tea.Batch(loadIOShapingViewCmd(m.client, m.ioShapingMode), ioShapingTickCmd(), loadIOShapingPolicyDataCmd(m.client, m.ioShapingMode), ioShapingPolicyTickCmd()) default: return m, nil } @@ -1022,7 +1059,7 @@ func (m model) refreshActiveView() (tea.Model, tea.Cmd) { m.ioShapingLoading = true m.ioShapingErr = nil m.status = "Refreshing IO shaping..." - return m, tea.Batch(loadIOShapingCmd(m.client, m.ioShapingMode), loadIOShapingPoliciesCmd(m.client)) + return m, tea.Batch(loadIOShapingViewCmd(m.client, m.ioShapingMode), loadIOShapingPolicyDataCmd(m.client, m.ioShapingMode)) case viewVID: m.vidLoading = true m.vidErr = nil diff --git a/ui/model_test.go b/ui/model_test.go index 94e93fb..1c3608d 100644 --- a/ui/model_test.go +++ b/ui/model_test.go @@ -380,7 +380,13 @@ func TestIOShapingFooterShowsNewHotkey(t *testing.T) { m.activeView = viewIOShaping footer := m.renderFooter() - if !strings.Contains(footer, "n new") { + if !strings.Contains(footer, "m limits") { + t.Fatalf("expected IO shaping footer to advertise limits hotkey, got: %s", footer) + } + if !strings.Contains(footer, "a/u/g/n/p mode") { + t.Fatalf("expected IO shaping footer to advertise mode hotkeys, got: %s", footer) + } + if !strings.Contains(footer, "N new") { t.Fatalf("expected IO shaping footer to advertise new-policy hotkey, got: %s", footer) } } @@ -2583,16 +2589,19 @@ func TestIOShapingMergedRowsIncludesPolicyOnly(t *testing.T) { rows := m.ioShapingMergedRows() - if len(rows) != 3 { - t.Fatalf("expected 3 merged rows (traffic-only, both, policy-only), got %d", len(rows)) + if len(rows) != 4 { + t.Fatalf("expected 4 merged rows (total, traffic-only, both, policy-only), got %d", len(rows)) + } + if !rows[0].total || rows[0].id != "[total apps]" { + t.Fatalf("expected first merged row to be total apps, got %+v", rows[0]) } - // Rows must be sorted alphabetically by id. + // Non-total rows must be sorted alphabetically by id. ids := make([]string, len(rows)) for i, r := range rows { ids[i] = r.id } - for i := 1; i < len(ids); i++ { + for i := 2; i < len(ids); i++ { if ids[i] < ids[i-1] { t.Errorf("rows not sorted: %v", ids) break @@ -2626,7 +2635,7 @@ func TestIOShapingNavigationIncludesPolicyOnlyRows(t *testing.T) { m.activeView = viewIOShaping m.ioShapingMode = eos.IOShapingApps - // One traffic record, one policy-only record → merged count = 2. + // One traffic record, one policy-only record plus aggregate total row. m.ioShaping = []eos.IOShapingRecord{ {ID: "app-a", Type: "app"}, } @@ -2634,18 +2643,20 @@ func TestIOShapingNavigationIncludesPolicyOnlyRows(t *testing.T) { {ID: "app-b", Type: "app", Enabled: true}, } - if got := len(m.ioShapingMergedRows()); got != 2 { - t.Fatalf("expected 2 merged rows, got %d", got) + if got := len(m.ioShapingMergedRows()); got != 3 { + t.Fatalf("expected 3 merged rows, got %d", got) } - // Simulate pressing "down" from row 0 — should reach row 1 (policy-only). + // Simulate pressing "down" twice from total — should reach row 2 (policy-only). m.ioShapingSelected = 0 msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")} updated, _ := m.Update(msg) m2 := updated.(model) + updated, _ = m2.Update(msg) + m2 = updated.(model) - if m2.ioShapingSelected != 1 { - t.Errorf("after pressing j, expected ioShapingSelected=1, got %d", m2.ioShapingSelected) + if m2.ioShapingSelected != 2 { + t.Errorf("after pressing j twice, expected ioShapingSelected=2, got %d", m2.ioShapingSelected) } } @@ -2665,6 +2676,7 @@ func TestIOShapingEnterOpensPolicyEditor(t *testing.T) { ReservationWriteBytesPerSec: 4000, }, } + m.ioShapingSelected = 1 updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) m = updated.(model) @@ -2683,7 +2695,7 @@ func TestIOShapingEnterOpensPolicyEditor(t *testing.T) { } } -func TestIOShapingEditorPrefillsSelectedValueForEditing(t *testing.T) { +func TestIOShapingEditorStartsNumericInputEmpty(t *testing.T) { m := NewModel(nil, "local", "/").(model) m.activeView = viewIOShaping m.ioShapingMode = eos.IOShapingApps @@ -2703,14 +2715,53 @@ func TestIOShapingEditorPrefillsSelectedValueForEditing(t *testing.T) { if m.ioShapingEdit.stage != ioShapingEditStageInput { t.Fatalf("expected io shaping editor to enter input stage, got %d", m.ioShapingEdit.stage) } - if m.ioShapingEdit.input.Value() != "15000000" { - t.Fatalf("expected io shaping editor input to start from existing limit write, got %q", m.ioShapingEdit.input.Value()) + if m.ioShapingEdit.input.Value() != "" { + t.Fatalf("expected io shaping editor input to start empty, got %q", m.ioShapingEdit.input.Value()) } if cmd == nil { t.Fatalf("expected focus command when entering io shaping input mode") } } +func TestIOShapingEditorEHotkeyTogglesEnabled(t *testing.T) { + m := NewModel(nil, "local", "/").(model) + m.activeView = viewIOShaping + m.ioShapingMode = eos.IOShapingApps + m.ioShapingPolicies = []eos.IOShapingPolicyRecord{ + {ID: "test-app", Type: "app", Enabled: true}, + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(model) + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}) + m = updated.(model) + + if m.ioShapingEdit.enabled { + t.Fatalf("expected e hotkey to toggle enabled off") + } +} + +func TestIOShapingEditorYHotkeyApplies(t *testing.T) { + m := NewModel(&eos.Client{}, "local", "/").(model) + m.activeView = viewIOShaping + m.ioShapingMode = eos.IOShapingApps + m.ioShapingPolicies = []eos.IOShapingPolicyRecord{ + {ID: "test-app", Type: "app", Enabled: true}, + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(model) + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}) + m = updated.(model) + + if m.ioShapingEdit.active { + t.Fatalf("expected y hotkey to close editor while applying changes") + } + if cmd == nil { + t.Fatalf("expected y hotkey to return an io shaping policy update command") + } +} + func TestIOShapingEditorSupportsGAndGNavigation(t *testing.T) { m := NewModel(nil, "local", "/").(model) m.activeView = viewIOShaping @@ -2838,6 +2889,7 @@ func TestIOShapingDeleteHotkeyWithoutPolicyShowsAlert(t *testing.T) { m.activeView = viewIOShaping m.ioShapingMode = eos.IOShapingApps m.ioShaping = []eos.IOShapingRecord{{ID: "traffic-only", Type: "app"}} + m.ioShapingSelected = 1 updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}}) m = updated.(model) @@ -2855,7 +2907,7 @@ func TestIOShapingNewHotkeyOpensTargetEntry(t *testing.T) { m.activeView = viewIOShaping m.ioShapingMode = eos.IOShapingApps - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'N'}}) m = updated.(model) if !m.ioShapingEdit.active { @@ -2874,7 +2926,7 @@ func TestIOShapingNewTargetEntryMovesToPolicyEditor(t *testing.T) { m.activeView = viewIOShaping m.ioShapingMode = eos.IOShapingApps - updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'N'}}) m = updated.(model) for _, r := range "new-app" { updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) @@ -5227,6 +5279,12 @@ func TestIOShapingViewRendersWithData(t *testing.T) { {ID: "app2", Type: "app", ReadBps: 3000, WriteBps: 4000}, } body := m.renderIOShapingView(20) + if !strings.Contains(body, "[total apps]") { + t.Fatalf("expected IO shaping view to contain total apps row, got:\n%s", body) + } + if !strings.Contains(body, "4.00 KB/s") || !strings.Contains(body, "6.00 KB/s") { + t.Fatalf("expected IO shaping view to contain total rates, got:\n%s", body) + } if !strings.Contains(body, "app1") { t.Fatalf("expected IO shaping view to contain 'app1', got:\n%s", body) } @@ -5235,6 +5293,97 @@ func TestIOShapingViewRendersWithData(t *testing.T) { } } +func TestIOShapingViewRendersNodeTotals(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + m := NewModel(nil, "test", "/").(model) + updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) + m = updated.(model) + m.activeView = viewIOShaping + m.ioShapingMode = eos.IOShapingNodes + m.ioShaping = []eos.IOShapingRecord{ + {ID: "node1:1095", Type: "node", ReadBps: 1000, WriteBps: 2000}, + {ID: "node2:1095", Type: "node", ReadBps: 3000, WriteBps: 4000}, + } + body := m.renderIOShapingView(20) + if !strings.Contains(body, "[total nodes]") { + t.Fatalf("expected node IO shaping view to contain total nodes row, got:\n%s", body) + } + if !strings.Contains(body, "node1:1095") || !strings.Contains(body, "node2:1095") { + t.Fatalf("expected node IO shaping view to contain nodes, got:\n%s", body) + } +} + +func TestIOShapingPressureViewRendersData(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + m := NewModel(nil, "test", "/").(model) + updated, _ := m.Update(tea.WindowSizeMsg{Width: 140, Height: 30}) + m = updated.(model) + m.activeView = viewIOShaping + m.ioShapingMode = eos.IOShapingPressure + m.ioShapingPressure = []eos.IOShapingPressureRecord{ + { + App: "bench", + NodeID: "node1:1095", + NodeIOPressure: 0.25, + ReadRateBps: 1000, + WriteRateBps: 2000, + ReservationWriteBytesPerSec: 50000000, + WriteReservationDeficitBps: 50000000, + WriteReservationDeficitActive: true, + }, + } + body := m.renderIOShapingView(20) + for _, want := range []string{"bench", "node1:1095", "0.250", "write-def"} { + if !strings.Contains(body, want) { + t.Fatalf("expected pressure view to contain %q, got:\n%s", want, body) + } + } +} + +func TestIOShapingMergedRowsPrependsTotal(t *testing.T) { + m := NewModel(nil, "test", "/").(model) + m.activeView = viewIOShaping + m.ioShapingMode = eos.IOShapingUsers + m.ioShaping = []eos.IOShapingRecord{ + {ID: "2000", Type: "uid", ReadBps: 1000, WriteBps: 2000, ReadIOPS: 1, WriteIOPS: 2}, + {ID: "1000", Type: "uid", ReadBps: 3000, WriteBps: 4000, ReadIOPS: 3, WriteIOPS: 4}, + } + + rows := m.ioShapingMergedRows() + if len(rows) != 3 { + t.Fatalf("expected total plus two rows, got %d", len(rows)) + } + if !rows[0].total || rows[0].id != "[total users]" { + t.Fatalf("expected first row to be total users, got %+v", rows[0]) + } + if rows[0].traffic == nil || rows[0].traffic.ReadBps != 4000 || rows[0].traffic.WriteBps != 6000 { + t.Fatalf("unexpected total traffic row: %+v", rows[0].traffic) + } + if rows[1].id != "1000" || rows[2].id != "2000" { + t.Fatalf("expected non-total rows to remain sorted, got %q then %q", rows[1].id, rows[2].id) + } +} + +func TestIOShapingTotalRowIsReadOnly(t *testing.T) { + m := NewModel(nil, "test", "/").(model) + m.activeView = viewIOShaping + m.ioShaping = []eos.IOShapingRecord{{ID: "app1", Type: "app", ReadBps: 1000}} + m.ioShapingSelected = 0 + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(model) + + if m.ioShapingEdit.active { + t.Fatalf("did not expect total row to open policy editor") + } + if cmd != nil { + t.Fatalf("did not expect total row edit to return command") + } + if !strings.Contains(m.status, "read-only") { + t.Fatalf("expected read-only status for total row, got %q", m.status) + } +} + func TestIOShapingViewShowsLoadingState(t *testing.T) { t.Setenv("HOME", t.TempDir()) m := NewModel(nil, "test", "/").(model) @@ -5592,6 +5741,89 @@ func TestIOShapingModeSwitchToGroups(t *testing.T) { } } +func TestIOShapingModeSwitchToNodes(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + m := NewModel(nil, "test", "/").(model) + updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) + m = updated.(model) + m.activeView = viewIOShaping + m.ioShapingMode = eos.IOShapingApps + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + m = updated.(model) + if m.ioShapingMode != eos.IOShapingNodes { + t.Fatalf("expected ioShapingMode=IOShapingNodes after 'n', got %d", m.ioShapingMode) + } +} + +func TestIOShapingModeSwitchToPressure(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + m := NewModel(nil, "test", "/").(model) + updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) + m = updated.(model) + m.activeView = viewIOShaping + m.ioShapingMode = eos.IOShapingApps + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}}) + m = updated.(model) + if m.ioShapingMode != eos.IOShapingPressure { + t.Fatalf("expected ioShapingMode=IOShapingPressure after 'p', got %d", m.ioShapingMode) + } +} + +func TestIOShapingLimitsHotkeyLoadsConfigWhenUnknown(t *testing.T) { + m := NewModel(nil, "test", "/").(model) + m.activeView = viewIOShaping + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}) + m = updated.(model) + + if !strings.Contains(m.status, "Loading IO shaping controller limits") { + t.Fatalf("expected loading status for unknown limits state, got %q", m.status) + } + if cmd == nil { + t.Fatalf("expected m hotkey to load IO shaping config when state is unknown") + } +} + +func TestIOShapingLimitsHotkeyTogglesLoadedState(t *testing.T) { + m := NewModel(&eos.Client{}, "test", "/").(model) + m.activeView = viewIOShaping + m.ioShapingConfigLoaded = true + m.ioShapingConfig = eos.IOShapingConfig{LimitsEnabled: true} + + updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}) + m = updated.(model) + + if !strings.Contains(m.status, "Disabling IO shaping controller limits") { + t.Fatalf("expected disabling status, got %q", m.status) + } + if cmd == nil { + t.Fatalf("expected m hotkey to return limits toggle command") + } +} + +func TestIOShapingLimitsToggleResultUpdatesState(t *testing.T) { + m := NewModel(nil, "test", "/").(model) + m.activeView = viewIOShaping + + updated, cmd := m.Update(ioShapingLimitsToggleResultMsg{enabled: false}) + m = updated.(model) + + if !m.ioShapingConfigLoaded { + t.Fatalf("expected config to be marked loaded") + } + if m.ioShapingConfig.LimitsEnabled { + t.Fatalf("expected limits to be disabled after toggle result") + } + if !strings.Contains(m.status, "Disabled IO shaping controller limits") { + t.Fatalf("expected disabled status, got %q", m.status) + } + if cmd == nil { + t.Fatalf("expected toggle result to refresh IO shaping data") + } +} + func TestIOShapingNavigationUpDown(t *testing.T) { t.Setenv("HOME", t.TempDir()) m := NewModel(nil, "test", "/").(model) diff --git a/ui/render.go b/ui/render.go index 9810d42..80fc286 100644 --- a/ui/render.go +++ b/ui/render.go @@ -98,7 +98,7 @@ func (m model) renderFooter() string { keys = "tab/0-9 • ↑↓/jk • enter open • r refresh • L commands • q quit" } case viewIOShaping: - keys = "tab/0-9 • ↑↓/jk • a apps • u users • g groups • n new • enter edit • d del • r • L commands" + keys = "tab/0-9 • ↑↓/jk • a/u/g/n/p mode • m limits • N new • enter edit • d del • r • L commands" case viewGroups: keys = "tab/0-9 • ↑↓/jk • ←→ • S • / • enter status • A all status • r • L commands" case viewVID: diff --git a/ui/types.go b/ui/types.go index ca60d57..60d9514 100644 --- a/ui/types.go +++ b/ui/types.go @@ -206,17 +206,33 @@ type ioShapingLoadedMsg struct { err error } +type ioShapingPressureLoadedMsg struct { + records []eos.IOShapingPressureRecord + mode eos.IOShapingMode + err error +} + type ioShapingPoliciesLoadedMsg struct { records []eos.IOShapingPolicyRecord err error } +type ioShapingConfigLoadedMsg struct { + config eos.IOShapingConfig + err error +} + type ioShapingPolicyResultMsg struct { id string op string err error } +type ioShapingLimitsToggleResultMsg struct { + enabled bool + err error +} + type eosVersionLoadedMsg struct { version string } @@ -841,13 +857,17 @@ type model struct { spaceStatusSelected int spaceStatusTarget string - ioShaping []eos.IOShapingRecord - ioShapingPolicies []eos.IOShapingPolicyRecord - ioShapingMode eos.IOShapingMode - ioShapingLoading bool - ioShapingErr error - ioShapingSelected int - ioShapingEdit ioShapingPolicyEdit + ioShaping []eos.IOShapingRecord + ioShapingPressure []eos.IOShapingPressureRecord + ioShapingPolicies []eos.IOShapingPolicyRecord + ioShapingConfig eos.IOShapingConfig + ioShapingMode eos.IOShapingMode + ioShapingLoading bool + ioShapingErr error + ioShapingConfigLoaded bool + ioShapingConfigErr error + ioShapingSelected int + ioShapingEdit ioShapingPolicyEdit status string @@ -875,6 +895,7 @@ type model struct { type ioShapingMergedRow struct { id string + total bool traffic *eos.IOShapingRecord policy *eos.IOShapingPolicyRecord } diff --git a/ui/view_io_shaping.go b/ui/view_io_shaping.go index 393577c..a3ca695 100644 --- a/ui/view_io_shaping.go +++ b/ui/view_io_shaping.go @@ -9,30 +9,36 @@ import ( "github.com/lobis/eos-tui/eos" ) -// ioShapingMergedRows returns the union of traffic records and policy records -// for the current mode, sorted alphabetically by id. Rows with traffic but no -// policy, policy but no traffic, or both are all included. +// ioShapingMergedRows returns a total traffic row followed by the union of +// traffic records and policy records for the current mode, sorted +// alphabetically by id. Rows with traffic but no policy, policy but no traffic, +// or both are all included. func (m model) ioShapingMergedRows() []ioShapingMergedRow { - policyType := "app" - switch m.ioShapingMode { - case eos.IOShapingUsers: - policyType = "uid" - case eos.IOShapingGroups: - policyType = "gid" - } - policyByID := make(map[string]eos.IOShapingPolicyRecord) - for _, p := range m.ioShapingPolicies { - if strings.ToLower(p.Type) == policyType { - policyByID[p.ID] = p + if policyType, ok := ioShapingPolicyTypeForMode(m.ioShapingMode); ok { + for _, p := range m.ioShapingPolicies { + if strings.ToLower(p.Type) == policyType { + policyByID[p.ID] = p + } } } seen := make(map[string]bool) var rows []ioShapingMergedRow + total := eos.IOShapingRecord{ + ID: ioShapingTotalLabel(m.ioShapingMode), + Type: "total", + } for i := range m.ioShaping { r := &m.ioShaping[i] seen[r.ID] = true + total.ReadBps += r.ReadBps + total.WriteBps += r.WriteBps + total.ReadIOPS += r.ReadIOPS + total.WriteIOPS += r.WriteIOPS + if r.WindowSec > total.WindowSec { + total.WindowSec = r.WindowSec + } row := ioShapingMergedRow{id: r.ID, traffic: r} if p, ok := policyByID[r.ID]; ok { row.policy = &p @@ -46,10 +52,52 @@ func (m model) ioShapingMergedRows() []ioShapingMergedRow { } } sort.Slice(rows, func(i, j int) bool { return rows[i].id < rows[j].id }) + if len(m.ioShaping) > 0 { + rows = append([]ioShapingMergedRow{{ + id: total.ID, + total: true, + traffic: &total, + }}, rows...) + } return rows } +func ioShapingTotalLabel(mode eos.IOShapingMode) string { + switch mode { + case eos.IOShapingUsers: + return "[total users]" + case eos.IOShapingGroups: + return "[total groups]" + case eos.IOShapingNodes: + return "[total nodes]" + default: + return "[total apps]" + } +} + +func ioShapingPolicyTypeForMode(mode eos.IOShapingMode) (string, bool) { + switch mode { + case eos.IOShapingApps: + return "app", true + case eos.IOShapingUsers: + return "uid", true + case eos.IOShapingGroups: + return "gid", true + default: + return "", false + } +} + +func ioShapingModeHasPolicies(mode eos.IOShapingMode) bool { + _, ok := ioShapingPolicyTypeForMode(mode) + return ok +} + func (m model) renderIOShapingView(height int) string { + if m.ioShapingMode == eos.IOShapingPressure { + return m.renderIOShapingPressureView(height) + } + width := m.panelWidth() contentWidth := panelContentWidth(width) @@ -59,6 +107,8 @@ func (m model) renderIOShapingView(height int) string { idLabel = "uid" case eos.IOShapingGroups: idLabel = "gid" + case eos.IOShapingNodes: + idLabel = "node" } indicator := "" @@ -135,11 +185,24 @@ func (m model) renderIOShapingView(height int) string { {title: "res write", min: 10, weight: 0, right: true}, }, dataRows)) + limitsState := "limits ?" + if m.ioShapingConfigLoaded { + limitsState = "limits off" + if m.ioShapingConfig.LimitsEnabled { + limitsState = "limits on" + } + } else if m.ioShapingConfigErr != nil { + limitsState = "limits err" + } + title := m.styles.label.Render("IO Traffic ") + m.styles.label.Render("5s window ") + + m.styles.label.Render("m "+limitsState+" ") + modeTabLabel(m.ioShapingMode, eos.IOShapingApps, "a apps", m.styles) + " " + modeTabLabel(m.ioShapingMode, eos.IOShapingUsers, "u users", m.styles) + " " + - modeTabLabel(m.ioShapingMode, eos.IOShapingGroups, "g groups", m.styles) + + modeTabLabel(m.ioShapingMode, eos.IOShapingGroups, "g groups", m.styles) + " " + + modeTabLabel(m.ioShapingMode, eos.IOShapingNodes, "n nodes", m.styles) + " " + + modeTabLabel(m.ioShapingMode, eos.IOShapingPressure, "p pressure", m.styles) + indicator lines := []string{title, "", m.renderSimpleHeaderRow(columns, headers)} @@ -163,6 +226,138 @@ func (m model) renderIOShapingView(height int) string { return m.styles.panel.Width(width).Render(fitLines(lines, height)) } +func (m model) renderIOShapingPressureView(height int) string { + width := m.panelWidth() + contentWidth := panelContentWidth(width) + + indicator := "" + if m.ioShapingLoading { + indicator = m.styles.status.Render(" ↻") + } + + if m.ioShapingErr != nil { + message := m.ioShapingErr.Error() + if errors.Is(m.ioShapingErr, eos.ErrIOShapingUnsupported) { + message = "IO shaping pressure is not available on this EOS instance.\nThe `io shaping pressure ls` subcommand is missing on the MGM." + } + lines := []string{ + m.styles.label.Render("IO Traffic Pressure") + indicator, + "", + m.styles.error.Render(message), + } + return m.styles.panelDim.Width(width).Render(fitLines(lines, height)) + } + + records := append([]eos.IOShapingPressureRecord(nil), m.ioShapingPressure...) + sort.Slice(records, func(i, j int) bool { + if records[i].App == records[j].App { + return records[i].NodeID < records[j].NodeID + } + return records[i].App < records[j].App + }) + + dataRows := make([][]string, len(records)) + for i, r := range records { + dataRows[i] = []string{ + r.App, + r.NodeID, + fmt.Sprintf("%.3f", r.NodeIOPressure), + humanBytesRate(r.ReadRateBps), + humanBytesRate(r.WriteRateBps), + humanBytesRate(r.ReservationReadBytesPerSec), + humanBytesRate(r.ReservationWriteBytesPerSec), + humanBytesRate(r.ReadReservationDeficitBps), + humanBytesRate(r.WriteReservationDeficitBps), + ioShapingPressureFlags(r), + } + } + + headers := []string{"application", "node", "pressure", "read", "write", "res read", "res write", "def read", "def write", "active"} + columns := allocateTableColumns(contentWidth, contentAwareColumns([]tableColumn{ + {title: "application", min: 12, weight: 3}, + {title: "node", min: 16, weight: 3}, + {title: "pressure", min: 8, weight: 0, right: true}, + {title: "read", min: 10, weight: 0, right: true}, + {title: "write", min: 10, weight: 0, right: true}, + {title: "res read", min: 10, weight: 0, right: true}, + {title: "res write", min: 10, weight: 0, right: true}, + {title: "def read", min: 10, weight: 0, right: true}, + {title: "def write", min: 10, weight: 0, right: true}, + {title: "active", min: 8, weight: 1}, + }, dataRows)) + + limitsState := "limits ?" + if m.ioShapingConfigLoaded { + limitsState = "limits off" + if m.ioShapingConfig.LimitsEnabled { + limitsState = "limits on" + } + } else if m.ioShapingConfigErr != nil { + limitsState = "limits err" + } + + title := m.styles.label.Render("IO Traffic ") + + m.styles.label.Render("pressure ") + + m.styles.label.Render("m "+limitsState+" ") + + modeTabLabel(m.ioShapingMode, eos.IOShapingApps, "a apps", m.styles) + " " + + modeTabLabel(m.ioShapingMode, eos.IOShapingUsers, "u users", m.styles) + " " + + modeTabLabel(m.ioShapingMode, eos.IOShapingGroups, "g groups", m.styles) + " " + + modeTabLabel(m.ioShapingMode, eos.IOShapingNodes, "n nodes", m.styles) + " " + + modeTabLabel(m.ioShapingMode, eos.IOShapingPressure, "p pressure", m.styles) + + indicator + + lines := []string{title, "", m.renderSimpleHeaderRow(columns, headers)} + if m.ioShapingLoading && len(records) == 0 { + lines = append(lines, "Loading...") + } else if len(records) == 0 { + lines = append(lines, "(no pressure data)") + } else { + start, end := visibleWindow(len(records), m.ioShapingSelected, max(1, height-len(lines))) + lines[0] = title + renderScrollSummary(start, end, len(records)) + for i := start; i < end; i++ { + line := formatTableRow(columns, dataRows[i]) + if i == m.ioShapingSelected { + line = m.styles.selected.Width(contentWidth).Render(line) + } + lines = append(lines, line) + } + } + + return m.styles.panel.Width(width).Render(fitLines(lines, height)) +} + +func ioShapingPressureFlags(r eos.IOShapingPressureRecord) string { + var flags []string + if r.ReadPressureActive { + flags = append(flags, "read") + } + if r.WritePressureActive { + flags = append(flags, "write") + } + if r.ReadReservationDeficitActive { + flags = append(flags, "read-def") + } + if r.WriteReservationDeficitActive { + flags = append(flags, "write-def") + } + if r.ReadTriggersCompetitorThrottling { + flags = append(flags, "read-throttle") + } + if r.WriteTriggersCompetitorThrottling { + flags = append(flags, "write-throttle") + } + if r.NodeHasPressuredReadReservation { + flags = append(flags, "node-read") + } + if r.NodeHasPressuredWriteReservation { + flags = append(flags, "node-write") + } + if len(flags) == 0 { + return "-" + } + return strings.Join(flags, ",") +} + func modeTabLabel(current, target eos.IOShapingMode, label string, s styles) string { if current == target { return s.tabActive.Render(label)