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
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["rps"]), int(data["total"]), int(data["2xx"]), int(data["4xx"]), int(data["499"]), int(data["5xx"]))

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