Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
6 changes: 4 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ endif
LDFLAGS=-ldflags "-w -s -extldflags=-static"

default:
make clean
make build
@make clean
@make build
@make toolchain

.PHONY: install
install:
Expand All @@ -31,6 +32,7 @@ build:
.PHONY: toolchain
toolchain:
@env CGO_ENABLED=0 go build ${LDFLAGS} -o bin/tq cmd/tq/main.go
@env CGO_ENABLED=0 go build ${LDFLAGS} -o bin/ttop cmd/top/main.go

.PHONY: run
run:
Expand Down
2 changes: 2 additions & 0 deletions api/defined/v1/storage/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ type Bucket interface {
StoreType() string
// Path returns the Bucket path.
Path() string
// TopK returns the top k most frequently used keys
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Bucket.TopK doc says it returns "the top k most frequently used keys", but current implementations return formatted strings like path@@time@@refs for UI consumption. This mismatch makes the interface confusing and brittle. Consider either (1) changing the method name/docs to reflect returning display/metadata strings, or (2) returning a structured type (e.g. []HotKey) and/or separate TopKKeys/TopKStats APIs.

Suggested change
// TopK returns the top k most frequently used keys
// TopK returns implementation-defined metadata strings for the top k most frequently used keys.
// The returned strings are intended for UI/display consumption (for example, "path@@time@@refs"),
// and do not necessarily correspond to raw key values.

Copilot uses AI. Check for mistakes.
TopK(k int) []string
}

type PurgeControl struct {
Expand Down
308 changes: 308 additions & 0 deletions cmd/top/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
package main

import (
"bufio"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"

"github.com/dustin/go-humanize"
terminal "github.com/gizak/termui/v3"
"github.com/gizak/termui/v3/widgets"
"github.com/samber/lo"
)

var (
endpoint = ""
tickInterval = time.Second * 1
)

func init() {
flag.StringVar(&endpoint, "endpoint", "http://localhost:8080/plugin/qs/graph", "The metrics endpoint to fetch data from tavern server.")
flag.DurationVar(&tickInterval, "interval", time.Second*1, "The interval to fetch metrics.")
}

func main() {
flag.Parse()

newDashboard()
}

func newDashboard() {
if err := terminal.Init(); err != nil {
log.Fatalf("failed to initialize termui: %v", err)
}
defer terminal.Close()

termWidth, _ := terminal.TerminalDimensions()

collected := atomic.Bool{}
cpuPercent := atomic.Uint32{}
memUsage := atomic.Uint64{}
memTotal := atomic.Uint64{}
diskPercent := atomic.Uint64{} // mock
diskUsage := atomic.Uint64{}
diskTotal := atomic.Uint64{}
startedAt := atomic.Int64{}

// 高级监控指标 { 热点url 热点域名 热点磁盘 }
list := widgets.NewList()
list.Title = "Hot URLs"
list.SetRect(0, 12, termWidth, 30)
list.BorderStyle.Fg = terminal.ColorWhite
list.TitleStyle.Fg = terminal.ColorCyan
list.TextStyle.Fg = terminal.ColorYellow

client := &http.Client{
Transport: &http.Transport{},
}

var (
dataMu sync.RWMutex
latestData = make(map[string]float64)
latestHotUrls []string
)

// Background SSE consumer
go func() {
for {
func() {
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return
}

resp, err := client.Do(req)
if err != nil {
collected.Store(false)
return
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
collected.Store(false)
return
}

collected.Store(true)
reader := bufio.NewReader(resp.Body)
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "data:") {
jsonStr := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if jsonStr == "" {
continue
}

var rsp Graph
if err := json.Unmarshal([]byte(jsonStr), &rsp); err == nil {
dataMu.Lock()
latestData = rsp.Data
latestHotUrls = rsp.HotUrls
dataMu.Unlock()

startedAt.Store(rsp.StartedAt)
cpuPercent.Store(uint32(rsp.Data["cpu_percent"]))
memUsage.Store(uint64(rsp.Data["mem_usage"]))
memTotal.Store(uint64(rsp.Data["mem_total"]))
diskUsage.Store(uint64(rsp.Data["disk_usage"]))
diskTotal.Store(uint64(rsp.Data["disk_total"]))
}
}
}
}()
time.Sleep(time.Second) // Reconnect delay
}
}()

// 基础监控指标 { qps, cpu, memory }
metricGrid := terminal.NewGrid()
metricGrid.SetRect(0, 3, termWidth, 20)

banner, bannerDraw := func() (*widgets.Paragraph, func()) {
banner := widgets.NewParagraph()
banner.SetRect(0, 0, termWidth, 3)
banner.Title = " Tavern (PRESS q TO QUIT) "
banner.Border = true

textDraw := func() {
color := "fg:red"
status := "Disconnected"
if collected.Load() {
color = "fg:green"
status = "Connected"
}

startAt := time.UnixMilli(startedAt.Load())

banner.Text = fmt.Sprintf("%s | Sampling @ [%s](fg:blue) | [%s](%s) (%s) | Uptime %s",
endpoint, tickInterval.String(), status, color, startAt.Format(time.RFC1123), humanize.Time(startAt))
}
textDraw()

return banner, textDraw
}()

rater, raterDraw := func() (*widgets.Paragraph, func()) {
rater := widgets.NewParagraph()
rater.Title = "Requests"
rater.SetRect(0, 3, 50, 6)
rater.BorderStyle.Fg = terminal.ColorWhite
rater.TitleStyle.Fg = terminal.ColorCyan

draw := func() {
dataMu.RLock()
data := make(map[string]float64, len(latestData))
for k, v := range latestData {
data[k] = v
}
hotUrls := make([]string, len(latestHotUrls))
copy(hotUrls, latestHotUrls)
dataMu.RUnlock()

rater.Text = fmt.Sprintf("\nRequests/sec: %d \nTotal: %d \n2xx : %d\n4xx : %d\n499 : %d\n5xx : %d",
int(data["total"]), int(data["total"]), int(data["2xx"]), int(data["4xx"]), int(data["499"]), int(data["5xx"]))
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UI displays both "Requests/sec" and "Total" using data["total"]. As implemented, these two values will always be identical, which is misleading. Consider exposing separate fields (e.g. rps vs total_requests) and using them accordingly in the dashboard.

Suggested change
int(data["total"]), int(data["total"]), int(data["2xx"]), int(data["4xx"]), int(data["499"]), int(data["5xx"]))
int(data["rps"]), int(data["total"]), int(data["2xx"]), int(data["4xx"]), int(data["499"]), int(data["5xx"]))

Copilot uses AI. Check for mistakes.

list.Rows = lo.Filter(lo.Map(hotUrls, toMap), filter)
}

draw()
return rater, draw
}()

load, loadDraw := func() (*widgets.Gauge, func()) {
load := widgets.NewGauge()
load.Title = "CPU Usage"
load.Percent = int(cpuPercent.Load())
load.BarColor = terminal.ColorMagenta
load.BorderStyle.Fg = terminal.ColorWhite
load.TitleStyle.Fg = terminal.ColorCyan

return load, func() {
load.Percent = int(cpuPercent.Load())
}
}()

mem, memDraw := func() (*widgets.Gauge, func()) {
mem := widgets.NewGauge()
mem.Title = "Memory Usage"
usagePercent := 0
if memTotal.Load() > 0 {
usagePercent = int(float64(memUsage.Load()) / float64(memTotal.Load()) * 100)
}
mem.Percent = usagePercent
mem.BarColor = terminal.ColorGreen
mem.BorderStyle.Fg = terminal.ColorWhite
mem.TitleStyle.Fg = terminal.ColorCyan

return mem, func() {
usagePercent := 0
if memTotal.Load() > 0 {
usagePercent = int(float64(memUsage.Load()) / float64(memTotal.Load()) * 100)
}
mem.Percent = usagePercent
mem.Label = fmt.Sprintf("%d%% | Mem: %s / %s",
usagePercent,
humanize.Bytes(memUsage.Load()),
humanize.Bytes(memTotal.Load()),
)
}
}()

disk, diskDraw := func() (*widgets.Gauge, func()) {
disk := widgets.NewGauge()
disk.Title = "Disk Usage"
disk.Percent = int(diskPercent.Load())
disk.BarColor = terminal.ColorYellow
disk.BorderStyle.Fg = terminal.ColorWhite
disk.TitleStyle.Fg = terminal.ColorCyan

return disk, func() {
disk.Percent = int(diskPercent.Load())
disk.Label = fmt.Sprintf("%d%% | Disk: %s / %s",
0,
humanize.Bytes(diskUsage.Load()),
humanize.Bytes(diskTotal.Load()),
)
}
Comment on lines +222 to +237
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

diskPercent is never set (and the label hardcodes 0%%), so the Disk gauge percent will always be 0 regardless of actual usage. Either compute and store the percentage from disk_usage/disk_total, or remove diskPercent and derive percent in diskDraw.

Copilot uses AI. Check for mistakes.
}()

metricGrid.Set(
terminal.NewRow(1.0/2,
terminal.NewCol(1.0/2, rater),
terminal.NewCol(1.0/2,
terminal.NewRow(1.0/3, load),
terminal.NewRow(1.0/3, mem),
terminal.NewRow(1.0/3, disk),
),
),
)

terminal.Render(banner, metricGrid, list)

uiEvents := terminal.PollEvents()
ticker := time.NewTicker(time.Second).C
for {
Comment on lines +253 to +255
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The render loop uses time.NewTicker(time.Second) and ignores the -interval flag, so UI refresh rate can't be configured. Use tickInterval when creating the ticker (and stop it on exit).

Copilot uses AI. Check for mistakes.
select {
case e := <-uiEvents:
switch e.ID {
case "q", "<C-c>":
return
}

switch e.Type {
case terminal.ResizeEvent:
payload := e.Payload.(terminal.Resize)
termWidth = payload.Width
// termHeight = payload.Height

banner.SetRect(0, 0, termWidth, 3)
metricGrid.SetRect(0, 3, termWidth, 20)
list.SetRect(0, 12, termWidth, 30)

terminal.Clear()
terminal.Render(banner, metricGrid, list)
}

case <-ticker:
bannerDraw()
raterDraw()
memDraw()
diskDraw()
loadDraw()

terminal.Render(banner, metricGrid, list)
}
}
}

func filter(s string, _ int) bool {
if s == "" {
return false
}
return true
}

func toMap(s string, i int) string {
parts := strings.Split(s, "@@")
if len(parts) != 3 {
return ""
}
return fmt.Sprintf("[%02d] LastAccess=%s %s ReqCount=%s", i, parts[1], parts[0], parts[2])
}

type Graph struct {
Data map[string]float64 `json:"data"`
HotUrls []string `json:"hot_urls"`
StartedAt int64 `json:"started_at"`
}
16 changes: 15 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ require (
github.com/cespare/xxhash/v2 v2.3.0
github.com/cloudflare/tableflip v1.2.3
github.com/cockroachdb/pebble/v2 v2.1.2
github.com/dustin/go-humanize v1.0.1
github.com/fsnotify/fsnotify v1.9.0
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gizak/termui/v3 v3.1.0
github.com/go-viper/mapstructure/v2 v2.4.0
github.com/goccy/go-json v0.10.5
github.com/google/uuid v1.6.0
Expand All @@ -19,6 +21,9 @@ require (
github.com/omalloc/proxy v0.0.0-20251201151440-9054f8002a97
github.com/paulbellamy/ratecounter v0.2.0
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/samber/lo v1.52.0
github.com/shirou/gopsutil/v4 v4.26.1
github.com/stretchr/testify v1.11.1
go.uber.org/zap v1.27.1
golang.org/x/sync v0.18.0
Expand All @@ -42,8 +47,10 @@ require (
github.com/cockroachdb/swiss v0.0.0-20250624142022-d6e517c1d961 // indirect
github.com/cockroachdb/tokenbucket v0.0.0-20250429170803-42689b6311bb // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/edsrzf/mmap-go v1.2.0 // indirect
github.com/getsentry/sentry-go v0.40.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/snappy v1.0.0 // indirect
Expand All @@ -52,18 +59,25 @@ require (
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mattn/go-runewidth v0.0.9 // indirect
github.com/minio/minlz v1.0.1 // indirect
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/tidwall/btree v1.8.1 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xujiajun/utils v0.0.0-20220904132955-5f7c5b914235 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect
Expand Down
Loading
Loading