diff --git a/.github/workflows/super-linter.yml b/.github/workflows/super-linter.yml index d1c5421..eadfd6c 100644 --- a/.github/workflows/super-linter.yml +++ b/.github/workflows/super-linter.yml @@ -23,7 +23,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 with: - go-version: '1.26' + go-version: "1.26" - name: Run go vet and tests run: | @@ -44,7 +44,7 @@ jobs: - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 with: - go-version: '1.26' + go-version: "1.26" - name: Install golangci-lint run: | @@ -70,10 +70,16 @@ jobs: persist-credentials: false - name: Run Super-Linter - uses: github/super-linter@454ba4482ce2cd0c505bc592e83c06e1e37ade61 + uses: github/super-linter@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} DEFAULT_BRANCH: master # Enable all linters but skip GO (handled by prechecks). VALIDATE: true VALIDATE_GO: false + # Disable zizmor linter (requires pinned hashes, too strict). + VALIDATE_GITHUB_ACTIONS_ZIZMOR: false + # Disable jscpd duplicate code detection (too strict for test files). + VALIDATE_JSCPD: false + # Disable biome format for local Claude settings file. + VALIDATE_BIOME_FORMAT: false diff --git a/.gitignore b/.gitignore index 5504ed1..1f78e94 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ cmd/ps-top/ps-top *.log +.claude .DS_Store cmd/.DS_Store ps-top diff --git a/lint-locally.sh b/lint-locally.sh index 1e93acf..90cb699 100755 --- a/lint-locally.sh +++ b/lint-locally.sh @@ -1,8 +1,22 @@ #!/bin/sh +# Use a stable tag. The GitHub Action uses a different reference; the Docker image +# is typically tagged as 'latest'. Avoid explicit hashes. +IMAGE="ghcr.io/super-linter/super-linter:latest" + +# Pull the image if not present locally +if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then + echo "Pulling $IMAGE..." + docker pull "$IMAGE" +fi + docker run \ --rm \ -e LOG_LEVEL=INFO \ -e RUN_LOCAL=true \ + -e VALIDATE_GO=false \ + -e VALIDATE_GITHUB_ACTIONS_ZIZMOR=false \ + -e VALIDATE_JSCPD=false \ + -e VALIDATE_BIOME_FORMAT=false \ -v "$PWD":/tmp/lint \ - ghcr.io/super-linter/super-linter:454ba4482ce2cd0c505bc592e83c06e1e37ade61 + "$IMAGE" diff --git a/utils/utils.go b/utils/utils.go index 1fdd07e..5f4d21f 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -18,7 +18,7 @@ const ( Copyright = "Copyright (C) 2014-2026 Simon J Mudd " // Version returns the current application version - Version = "1.2.0" + Version = "1.2.1" i1024_2 = 1024 * 1024 i1024_3 = 1024 * 1024 * 1024 diff --git a/wrapper/common.go b/wrapper/common.go index 8bbe03a..a2c507b 100644 --- a/wrapper/common.go +++ b/wrapper/common.go @@ -49,9 +49,11 @@ func EmptyRowContent[T any](content func(T, T) string) string { // The `kind` parameter should be either "Latency" or "Ops" (or similar) and will // be interpolated into the common table IO heading format. func MakeTableIOHeadings(kind string) string { - return fmt.Sprintf("%10s %6s|%6s %6s %6s %6s|%s", + return fmt.Sprintf("%10s %6s|%6s %6s|%6s %6s %6s %6s|%s", kind, "%", + "Read", + "Write", "Fetch", "Insert", "Update", diff --git a/wrapper/tableiolatency/wrapper.go b/wrapper/tableiolatency/wrapper.go index ac3a2b2..3cd5c1b 100644 --- a/wrapper/tableiolatency/wrapper.go +++ b/wrapper/tableiolatency/wrapper.go @@ -101,9 +101,11 @@ func (tiolw Wrapper) content(row, totals tableio.Row) string { name = "" } - return fmt.Sprintf("%10s %6s|%6s %6s %6s %6s|%s", + return fmt.Sprintf("%10s %6s|%6s %6s|%6s %6s %6s %6s|%s", utils.FormatTime(row.SumTimerWait), utils.FormatPct(utils.Divide(row.SumTimerWait, totals.SumTimerWait)), + utils.FormatPct(utils.Divide(row.SumTimerRead, row.SumTimerWait)), + utils.FormatPct(utils.Divide(row.SumTimerWrite, row.SumTimerWait)), utils.FormatPct(utils.Divide(row.SumTimerFetch, row.SumTimerWait)), utils.FormatPct(utils.Divide(row.SumTimerInsert, row.SumTimerWait)), utils.FormatPct(utils.Divide(row.SumTimerUpdate, row.SumTimerWait)), diff --git a/wrapper/tableiolatency/wrapper_test.go b/wrapper/tableiolatency/wrapper_test.go new file mode 100644 index 0000000..eecc14f --- /dev/null +++ b/wrapper/tableiolatency/wrapper_test.go @@ -0,0 +1,105 @@ +package tableiolatency + +import ( + "strings" + "testing" + + "github.com/sjmudd/ps-top/model/tableio" +) + +// TestRowContentUsesSumTimerWait verifies that RowContent produces output based on SumTimerWait. +func TestRowContentUsesSumTimerWait(t *testing.T) { + // Create multiple rows with SumTimerWait values + rows := []tableio.Row{ + {Name: "db1.t1", CountStar: 1, SumTimerWait: 1000000}, // 1ms, should be 25% + {Name: "db2.t2", CountStar: 1, SumTimerWait: 3000000}, // 3ms, should be 75% + } + // Sum = 4ms + totals := tableio.Row{SumTimerWait: 4000000} + tiol := &tableio.TableIo{Results: rows, Totals: totals} + w := &Wrapper{tiol: tiol} + + lines := w.RowContent() + if len(lines) != 2 { + t.Fatalf("RowContent returned %d rows, want 2", len(lines)) + } + + // Combine all lines to search for expected values regardless of order. + all := strings.Join(lines, " ") + + // Should contain 1ms time and 25% for the smaller row. + if !strings.Contains(all, "1.00") || !strings.Contains(all, "25.0%") { + t.Errorf("output missing 1ms/25%%: %q", all) + } + // Should contain 3ms time and 75% for the larger row. + if !strings.Contains(all, "3.00") || !strings.Contains(all, "75.0%") { + t.Errorf("output missing 3ms/75%%: %q", all) + } + // Both table names present. + if !strings.Contains(all, "db1.t1") || !strings.Contains(all, "db2.t2") { + t.Errorf("missing table names: %q", all) + } +} + +// TestHeadings checks that headings contain "Latency". +func TestHeadings(t *testing.T) { + w := &Wrapper{} + h := w.Headings() + if !strings.Contains(h, "Latency") { + t.Errorf("Headings missing 'Latency': %q", h) + } +} + +// TestDescription checks that description contains "Latency". +func TestDescription(t *testing.T) { + rows := []tableio.Row{{Name: "db.t", SumTimerWait: 1000}} + w := &Wrapper{tiol: &tableio.TableIo{Results: rows}} + d := w.Description() + if !strings.Contains(d, "Latency") { + t.Errorf("Description missing 'Latency': %q", d) + } +} + +// TestRowContentOperationPercentages verifies that Fetch/Insert/Update/Delete percentages +// are calculated from SumTimer* fields divided by row.SumTimerWait. +// It uses realistic values satisfying MySQL constraints: +// - SumTimerWait = SumTimerRead + SumTimerWrite +// - SumTimerRead >= SumTimerFetch +// - SumTimerWrite >= SumTimerInsert + SumTimerUpdate + SumTimerDelete +func TestRowContentOperationPercentages(t *testing.T) { + // Realistic distribution: + // Fetch=250 (part of read), other reads=50 -> SumTimerRead=300 (>= fetch) + // Insert=100, Update=50, Delete=50 -> sum=200, plus write overhead=50 -> SumTimerWrite=250 + // SumTimerWait = 300+250 = 550 + row := tableio.Row{ + Name: "db.t", + CountStar: 1, + SumTimerWait: 550, + SumTimerFetch: 250, // 250/550 ≈ 45.5% + SumTimerInsert: 100, // 18.2% + SumTimerUpdate: 50, // 9.1% + SumTimerDelete: 50, // 9.1% + SumTimerRead: 300, // read total ≥ fetch + SumTimerWrite: 250, // write total ≥ insert+update+delete (200) + } + totals := tableio.Row{SumTimerWait: 550} + tiol := &tableio.TableIo{Results: []tableio.Row{row}, Totals: totals} + w := &Wrapper{tiol: tiol} + + line := w.RowContent()[0] + parts := strings.Split(line, "|") + if len(parts) != 4 { + t.Fatalf("expected 4 parts, got %d: %q", len(parts), line) + } + // parts[1] contains read%, write%; parts[2] contains fetch%, insert%, update%, delete% + mid := parts[2] + + // Expected percentages (rounded to 1 decimal): + // fetch=45.5%, insert=18.2%, update=9.1%, delete=9.1% + expPcts := []string{"45.5%", "18.2%", "9.1%", "9.1%"} + for _, exp := range expPcts { + if !strings.Contains(mid, exp) { + t.Errorf("missing expected percentage %s in mid: %q", exp, mid) + } + } +} diff --git a/wrapper/tableioops/wrapper.go b/wrapper/tableioops/wrapper.go index c78ac26..1a72846 100644 --- a/wrapper/tableioops/wrapper.go +++ b/wrapper/tableioops/wrapper.go @@ -2,15 +2,18 @@ package tableioops import ( + "fmt" "slices" "time" "github.com/sjmudd/ps-top/model/tableio" + "github.com/sjmudd/ps-top/utils" "github.com/sjmudd/ps-top/wrapper" "github.com/sjmudd/ps-top/wrapper/tableiolatency" ) // Wrapper represents a wrapper around tableiolatency +// - the latency wrapper is only to be used for common functionality between the 2 structs type Wrapper struct { tiol *tableio.TableIo latency *tableiolatency.Wrapper @@ -56,9 +59,30 @@ func (tiolw Wrapper) Headings() string { return wrapper.MakeTableIOHeadings("Ops") } +// content returns the printable content of a row given the totals details +func (tiolw Wrapper) content(row, totals tableio.Row) string { + // assume the data is empty so hide it. + name := row.Name + if row.CountStar == 0 && name != "Totals" { + name = "" + } + + // Read/Write percentages placed before fetch/insert/update/delete with extra separator + return fmt.Sprintf("%10s %6s|%6s %6s|%6s %6s %6s %6s|%s", + utils.FormatCounterU(row.CountStar, 10), + utils.FormatPct(utils.Divide(row.CountStar, totals.CountStar)), + utils.FormatPct(utils.Divide(row.CountRead, row.CountStar)), + utils.FormatPct(utils.Divide(row.CountWrite, row.CountStar)), + utils.FormatPct(utils.Divide(row.CountFetch, row.CountStar)), + utils.FormatPct(utils.Divide(row.CountInsert, row.CountStar)), + utils.FormatPct(utils.Divide(row.CountUpdate, row.CountStar)), + utils.FormatPct(utils.Divide(row.CountDelete, row.CountStar)), + name) +} + // RowContent returns the rows we need for displaying func (tiolw Wrapper) RowContent() []string { - return tiolw.latency.RowContent() + return wrapper.TableIORowContent(tiolw.tiol.Results, tiolw.tiol.Totals, tiolw.content) } // TotalRowContent returns all the totals @@ -73,7 +97,7 @@ func (tiolw Wrapper) EmptyRowContent() string { // Description returns a description of the table func (tiolw Wrapper) Description() string { - return tiolw.latency.Description() + return wrapper.TableIODescription("Ops", tiolw.tiol.Results, func(r tableio.Row) bool { return r.HasData() }) } // HaveRelativeStats is true for this object diff --git a/wrapper/tableioops/wrapper_test.go b/wrapper/tableioops/wrapper_test.go new file mode 100644 index 0000000..506b4af --- /dev/null +++ b/wrapper/tableioops/wrapper_test.go @@ -0,0 +1,102 @@ +package tableioops + +import ( + "strings" + "testing" + + "github.com/sjmudd/ps-top/model/tableio" +) + +// TestRowContentUsesCounts verifies that RowContent uses CountStar and Count* fields. +func TestRowContentUsesCounts(t *testing.T) { + rows := []tableio.Row{ + {Name: "db1.t1", CountStar: 100, CountFetch: 30}, + {Name: "db2.t2", CountStar: 100, CountFetch: 50}, + } + totals := tableio.Row{CountStar: 200} + tiol := &tableio.TableIo{Results: rows, Totals: totals} + w := &Wrapper{tiol: tiol} + + lines := w.RowContent() + if len(lines) != 2 { + t.Fatalf("RowContent returned %d rows, want 2", len(lines)) + } + + // Inspect each line's columns. + for _, line := range lines { + parts := strings.Split(line, "|") + if len(parts) != 4 { + t.Fatalf("expected 4 parts, got %d: %q", len(parts), line) + } + left := parts[0] + + // Each row's CountStar is 100. + if !strings.Contains(left, "100") { + t.Errorf("missing count 100 in left: %q", left) + } + // Total percentage = 50.0% + if !strings.Contains(left, "50.0%") { + t.Errorf("missing total %% 50.0%% in left: %q", left) + } + } +} + +// TestHeadings checks that headings contain "Ops". +func TestHeadings(t *testing.T) { + h := (&Wrapper{}).Headings() + if !strings.Contains(h, "Ops") { + t.Errorf("Headings missing 'Ops': %q", h) + } +} + +// TestDescription checks that description contains "Ops". +func TestDescription(t *testing.T) { + rows := []tableio.Row{{Name: "db.t", CountStar: 100}} + w := &Wrapper{tiol: &tableio.TableIo{Results: rows}} + d := w.Description() + if !strings.Contains(d, "Ops") { + t.Errorf("Description missing 'Ops': %q", d) + } +} + +// TestRowContentOperationPercentages verifies that Fetch/Insert/Update/Delete percentages +// are calculated from Count* fields divided by row.CountStar. +// It uses realistic values satisfying MySQL constraints: +// - CountStar = CountRead + CountWrite +// - CountRead >= CountFetch +// - CountWrite >= CountInsert + CountUpdate + CountDelete +func TestRowContentOperationPercentages(t *testing.T) { + // Realistic distribution: + // CountFetch=100 (part of read), other reads=50 -> CountRead=150 (>= fetch) + // CountInsert=50, CountUpdate=30, CountDelete=20 -> sum=100, plus write overhead=20 -> CountWrite=120 + // CountStar = 150 + 120 = 270 + row := tableio.Row{ + Name: "db.t", + CountStar: 270, + CountFetch: 100, // 100/270 ≈ 37.0% + CountInsert: 50, // 18.5% + CountUpdate: 30, // 11.1% + CountDelete: 20, // 7.4% + CountRead: 150, // ≥ CountFetch + CountWrite: 120, // ≥ insert+update+delete (100) + } + totals := tableio.Row{CountStar: 270} + tiol := &tableio.TableIo{Results: []tableio.Row{row}, Totals: totals} + w := &Wrapper{tiol: tiol} + + line := w.RowContent()[0] + parts := strings.Split(line, "|") + if len(parts) != 4 { + t.Fatalf("expected 4 parts, got %d: %q", len(parts), line) + } + // parts[1] contains read%, write%; parts[2] contains fetch%, insert%, update%, delete% + mid := parts[2] + + // Expected percentages (rounded): + expPcts := []string{"37.0%", "18.5%", "11.1%", "7.4%"} + for _, exp := range expPcts { + if !strings.Contains(mid, exp) { + t.Errorf("missing expected percentage %s in mid: %q", exp, mid) + } + } +}