Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/super-linter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -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: |
Expand All @@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
cmd/ps-top/ps-top
*.log
.claude
.DS_Store
cmd/.DS_Store
ps-top
16 changes: 15 additions & 1 deletion lint-locally.sh
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 1 addition & 1 deletion utils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const (
Copyright = "Copyright (C) 2014-2026 Simon J Mudd <sjmudd@pobox.com>"

// Version returns the current application version
Version = "1.2.0"
Version = "1.2.1"

i1024_2 = 1024 * 1024
i1024_3 = 1024 * 1024 * 1024
Expand Down
4 changes: 3 additions & 1 deletion wrapper/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion wrapper/tableiolatency/wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
105 changes: 105 additions & 0 deletions wrapper/tableiolatency/wrapper_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
28 changes: 26 additions & 2 deletions wrapper/tableioops/wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
102 changes: 102 additions & 0 deletions wrapper/tableioops/wrapper_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading