feat(stats): tab Statistik (F-005) — bar CPU/RAM, riwayat sparkline 60 dtk, laju NET/Disk, PIDs
CI / test (push) Successful in 22s

This commit is contained in:
oxpoe committed 2026-10-16 01:02:46 +07:00
1 parent d3abc15a95
commit 86db90ea9a
10 files changed
+409 -18

No files matched your search

+38 -1
View File
@@ -312,6 +312,42 @@ func TestResourceSections(t *testing.T) {
}
}
// TestStatsTab — tab Statistik: bar CPU/RAM, riwayat sparkline, NET/Disk,
// PIDs muncul dari data nyata. Butuh docker + minimal 1 kontainer.
func TestStatsTab(t *testing.T) {
if _, err := os.Stat("/var/run/docker.sock"); err != nil {
t.Skip("docker socket tidak ada")
}
cl, err := docker.New()
if err != nil {
t.Skipf("docker: %v", err)
}
defer cl.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
items, err := cl.ListContainers(ctx)
if err != nil || len(items) == 0 {
t.Skipf("tidak ada kontainer (err=%v)", err)
}
name := items[0].Name
if len(name) > 12 {
name = name[:12]
}
u := startTUI(t)
u.waitContains("beranda", 5*time.Second)
u.send("1")
u.waitContains(name, 5*time.Second)
u.send("l") // tab Statistik (fokus pindah ke konten)
u.waitContains("Riwayat CPU", 6*time.Second)
u.waitContains("PIDs", 3*time.Second)
u.send("h") // kembali ke Log
u.send("q")
if code := u.waitExit(5 * time.Second); code != 0 {
t.Fatalf("exit code %d, mau 0", code)
}
}
func TestThemeCycle(t *testing.T) {
u := startTUI(t)
u.waitContains("beranda", 5*time.Second)
@@ -440,7 +476,8 @@ func TestLogTab(t *testing.T) {
u.send("k")
u.send("j")
u.send("G")
u.send("l") // tab berikutnya → placeholder
u.send("l") // tab berikutnya (Statistik)
u.send("l") // lalu Konfigurasi → placeholder
u.waitContains("menyusul", 3*time.Second)
u.send("h") // kembali ke tab Log
u.send("\x1b[<0;60;10M\x1b[<0;60;10m") // klik area konten = fokus
+107 -13
View File
@@ -56,6 +56,9 @@ const (
sectionCount = 4
)
// statsHistory — jumlah sampel riwayat (60 ≈ 1 menit @1 dtk, PRD F-005).
const statsHistory = 60
// --- messages (async via tea.Cmd) ---
type containersMsg struct {
@@ -106,6 +109,9 @@ type Model struct {
logOffset int
logEOF bool
stats []docker.Stats
statsID int64
hostname string
sys sysinfo.Stats
@@ -256,7 +262,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case containersMsg:
mm, cmd := m.applyList(msg.err, func(mm *Model) { mm.containers = msg.items })
if mm2, ok := mm.(Model); ok {
return mm2, tea.Batch(cmd, mm2.restartLogs())
return mm2, tea.Batch(cmd, mm2.restartData())
}
return mm, cmd
@@ -303,6 +309,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, waitLogBatch(m.logID, m.logCh)
case statsMsg:
if msg.id != m.statsID {
return m, nil // stream basi
}
if msg.err == nil {
m.stats = append(m.stats, msg.st)
if over := len(m.stats) - statsHistory; over > 0 {
m.stats = m.stats[over:]
}
}
return m, statsNext(m.statsID)
case statsTickMsg:
if msg.id != m.statsID || m.cl == nil || m.sectionLen() == 0 {
return m, nil
}
c := m.containers[min(m.cur(), m.sectionLen()-1)]
return m, fetchStats(m.statsID, m.cl, c.ID)
case tea.BackgroundColorMsg:
// Mode tema "auto": Mocha vs Latte sesuai background terminal.
theme.SetDetectedDark(msg.IsDark())
@@ -337,19 +362,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "1", "enter":
m.jumpSection(sectionContainers)
m.sound.Play(audio.Click)
return m, m.restartLogs()
return m, m.restartData()
case "2":
m.jumpSection(sectionImages)
m.sound.Play(audio.Click)
return m, m.restartLogs()
return m, m.restartData()
case "3":
m.jumpSection(sectionVolumes)
m.sound.Play(audio.Click)
return m, m.restartLogs()
return m, m.restartData()
case "4":
m.jumpSection(sectionNetworks)
m.sound.Play(audio.Click)
return m, m.restartLogs()
return m, m.restartData()
case "a": // reduced motion
m.animating = !m.animating
if m.animating {
@@ -362,7 +387,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.loading = true
m.sound.Play(audio.Click)
return m, tea.Batch(append(loadAll(m.cl), m.restartLogs())...)
return m, tea.Batch(append(loadAll(m.cl), m.restartData())...)
case "T": // tombol cepat tema (PRD §5.4): auto → dark → light → terminal
cur := theme.Current()
next := theme.CycleMode(cur.Mode)
@@ -404,7 +429,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.toast.Show(components.LevelInfo, i18n.Tf("toast.timestamps", label), i18n.T("theme.switchHint"))
m.sound.Play(audio.Click)
return m, m.restartLogs()
return m, m.restartData()
case "j", "down":
if m.view == viewList && m.focus == focusContent {
if m.logOffset > 0 {
@@ -417,7 +442,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.setCur(c + 1)
}
m.sound.Play(audio.Navigate)
return m, m.restartLogs()
return m, m.restartData()
case "k", "up":
if m.view == viewList && m.focus == focusContent {
if m.logOffset < len(m.logs)-1 {
@@ -430,7 +455,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.setCur(c - 1)
}
m.sound.Play(audio.Navigate)
return m, m.restartLogs()
return m, m.restartData()
}
case tea.MouseClickMsg:
@@ -449,7 +474,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
idx := msg.Y - (m.headerRows() + 1) + m.scrollStart()
if idx >= 0 && idx < m.sectionLen() {
m.setCur(idx)
return m, m.restartLogs()
return m, m.restartData()
}
}
}
@@ -542,7 +567,25 @@ func (m *Model) cycleTab(delta int) tea.Cmd {
m.tab = (m.tab + delta + tabCount) % tabCount
m.focus = focusContent
m.sound.Play(audio.Click)
return m.restartLogs()
return m.restartData()
}
// restartStats — bersihkan riwayat & mulai snapshot periodik (one-shot @1 dtk)
// bila konteksnya cocok (tab Statistik). Tidak ada stream persisten.
func (m *Model) restartStats() tea.Cmd {
m.statsID++
m.stats = nil
if m.view != viewList || m.section != sectionContainers || m.tab != tabStats ||
m.cl == nil || m.sectionLen() == 0 {
return nil
}
c := m.containers[min(m.cur(), m.sectionLen()-1)]
return fetchStats(m.statsID, m.cl, c.ID)
}
// restartData — restart semua sumber data detail (log + stats) untuk seleksi kini.
func (m *Model) restartData() tea.Cmd {
return tea.Batch(m.restartLogs(), m.restartStats())
}
// --- data helpers ---
@@ -882,9 +925,12 @@ func (m Model) contentView() string {
if m.section == sectionContainers {
c := m.containers[idx]
lines := []string{m.tabBarLine(w - 4)}
if m.tab == tabLogs {
switch m.tab {
case tabLogs:
lines = append(lines, m.logLines(h-3)...)
} else {
case tabStats:
lines = append(lines, m.statsLines(h-3, w-4)...)
default:
lines = append(lines, "", dim(i18n.T("hint.tabComing")))
}
return panel(c.Name, c.State, lines, w, h, focused)
@@ -978,6 +1024,54 @@ func (m Model) logLines(rows int) []string {
// logLevelRE — token level umum di log (INFO/WARN/ERRO/…).
var logLevelRE = regexp.MustCompile(`\b(TRACE|TRAC|DEBUG|DEBU|INFO|WARN|WARNING|ERRO|ERROR|FATAL|FATA)\b`)
// statsLines — isi tab Statistik (PRD §5.1.1): bar CPU/RAM, riwayat CPU
// 60 detik sebagai sparkline, laju NET/Disk, dan PIDs.
func (m Model) statsLines(rows, w int) []string {
if len(m.stats) == 0 {
return []string{dim(i18n.T("hint.statsWaiting"))}
}
cur := m.stats[len(m.stats)-1]
out := []string{
fieldW("CPU", 6) + pctBar(cur.CPUPercent, 20) + dim(fmt.Sprintf(" %5.1f%%", cur.CPUPercent)),
fieldW("RAM", 6) + pctBar(cur.MemPercent, 20) +
dim(fmt.Sprintf(" %s / %s", humanKBs(cur.MemUsed/1024), humanKBs(cur.MemLimit/1024))),
"",
dim(i18n.T("stats.history")),
}
vals := make([]float64, 0, len(m.stats))
for _, s := range m.stats {
vals = append(vals, s.CPUPercent)
}
out = append(out, theme.Style(theme.Current().Accent).Render(components.Sparkline(vals, min(w, statsHistory))), "")
if len(m.stats) >= 2 {
prev := m.stats[len(m.stats)-2]
dt := cur.At.Sub(prev.At).Seconds()
if dt <= 0 {
dt = 1
}
rate := func(curV, prevV uint64) uint64 {
if curV < prevV { // counter reset (kontainer restart)
return 0
}
return uint64(float64(curV-prevV) / dt)
}
out = append(out,
text("NET ")+dim("↑ "+humanBytes(int64(rate(cur.NetRx, prev.NetRx)))+"/s ↓ "+humanBytes(int64(rate(cur.NetTx, prev.NetTx)))+"/s"),
text("Disk ")+dim(i18n.T("stats.read")+" "+humanBytes(int64(rate(cur.BlkRead, prev.BlkRead)))+"/s · "+
i18n.T("stats.write")+" "+humanBytes(int64(rate(cur.BlkWrite, prev.BlkWrite)))+"/s"),
)
}
out = append(out, text("PIDs ")+dim(fmt.Sprintf("%d", cur.PIDs)))
if len(out) > rows {
out = out[:rows]
}
return out
}
// colorizeLog — warnai token level (PRD §5.4: INFO=info, WARN=warning, ERRO=danger).
func colorizeLog(line string) string {
th := theme.Current()
+4 -4
View File
@@ -95,7 +95,7 @@ func (m Model) hostPanel(w, h int) string {
lines := []string{
fieldW(i18n.T("field.host"), 9) + dim(m.hostname),
fieldW(i18n.T("field.cpu"), 9) + dim(i18n.Tf("fmt.coreLoad", cpus, s.Load1)),
fieldW(i18n.T("field.ram"), 9) + dim(i18n.Tf("fmt.ramUsage", pct)) + memBar(pct, 10) +
fieldW(i18n.T("field.ram"), 9) + dim(i18n.Tf("fmt.ramUsage", pct)) + pctBar(float64(pct), 10) +
dim(fmt.Sprintf(" %s/%s", humanKBs(s.MemTotal-s.MemAvail), humanKBs(s.MemTotal))),
fieldW(i18n.T("field.uptime"), 9) + dim(humanDuration(s.Uptime)),
}
@@ -184,9 +184,9 @@ func humanDuration(sec float64) string {
}
}
// memBar — bar RAM; warna ikut beban (hijau <60% · kuning 60–85% · merah >85%).
func memBar(pct, w int) string {
fill := pct * w / 100
// pctBar — bar persentase; warna ikut beban (hijau <60% · kuning 60–85% · merah >85%).
func pctBar(pct float64, w int) string {
fill := int(pct * float64(w) / 100)
if fill > w {
fill = w
}
+23
View File
@@ -25,6 +25,29 @@ type logBatchMsg struct {
// logTickMsg — tidak ada data dalam window idle; re-arm tanpa state change.
type logTickMsg struct{ id int64 }
// statsMsg / statsTickMsg — snapshot metrik kontainer (tab Statistik).
type statsMsg struct {
id int64
st docker.Stats
err error
}
type statsTickMsg struct{ id int64 }
const statsInterval = time.Second
func fetchStats(id int64, cl *docker.Client, cid string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
st, err := cl.StatsOnce(ctx, cid)
return statsMsg{id: id, st: st, err: err}
}
}
func statsNext(id int64) tea.Cmd {
return tea.Tick(statsInterval, func(time.Time) tea.Msg { return statsTickMsg{id: id} })
}
const (
logBatchMax = 200 // maksimum baris per batch
logIdle = 200 * time.Millisecond // tunggu baris pertama
+96
View File
@@ -0,0 +1,96 @@
package docker
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/docker/docker/api/types/container"
)
// Stats — snapshot metrik kontainer (kumulatif untuk net/disk; app menghitung
// laju dari selisih antar snapshot).
type Stats struct {
CPUPercent float64
MemUsed uint64 // usage − cache (seperti `docker stats`)
MemLimit uint64
MemPercent float64
NetRx uint64 // kumulatif
NetTx uint64 // kumulatif
BlkRead uint64 // kumulatif
BlkWrite uint64 // kumulatif
PIDs int
At time.Time
}
// StatsOnce mengambil satu snapshot statistik (one-shot, tanpa stream).
func (c *Client) StatsOnce(ctx context.Context, id string) (Stats, error) {
resp, err := c.cli.ContainerStatsOneShot(ctx, id)
if err != nil {
return Stats{}, fmt.Errorf("docker: stats: %w", err)
}
defer resp.Body.Close()
var raw container.StatsResponse
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return Stats{}, fmt.Errorf("docker: decode stats: %w", err)
}
return computeStats(&raw), nil
}
// computeStats — rumus yang sama dengan `docker stats` (CPU dari delta
// pre/cpu-stats, memori usage − cache), dipisah agar bisa diuji tanpa daemon.
func computeStats(s *container.StatsResponse) Stats {
// CPU% = (Δcpu / Δsystem) × onlineCPU × 100
cpuDelta := float64(s.CPUStats.CPUUsage.TotalUsage) - float64(s.PreCPUStats.CPUUsage.TotalUsage)
sysDelta := float64(s.CPUStats.SystemUsage) - float64(s.PreCPUStats.SystemUsage)
online := s.CPUStats.OnlineCPUs
if online == 0 {
online = uint32(len(s.CPUStats.CPUUsage.PercpuUsage))
}
cpuPct := 0.0
if sysDelta > 0 && cpuDelta > 0 {
cpuPct = cpuDelta / sysDelta * float64(online) * 100
}
memUsed := s.MemoryStats.Usage
if cache, ok := s.MemoryStats.Stats["cache"]; ok && cache < memUsed {
memUsed -= cache
}
memPct := 0.0
if s.MemoryStats.Limit > 0 {
memPct = float64(memUsed) / float64(s.MemoryStats.Limit) * 100
}
// Net: jumlahkan semua interface (kumulatif).
var rx, tx uint64
for _, n := range s.Networks {
rx += n.RxBytes
tx += n.TxBytes
}
// Blok I/O: jumlahkan per operasi (kumulatif).
var rd, wr uint64
for _, b := range s.BlkioStats.IoServiceBytesRecursive {
switch b.Op {
case "Read", "read":
rd += b.Value
case "Write", "write":
wr += b.Value
}
}
return Stats{
CPUPercent: cpuPct,
MemUsed: memUsed,
MemLimit: s.MemoryStats.Limit,
MemPercent: memPct,
NetRx: rx,
NetTx: tx,
BlkRead: rd,
BlkWrite: wr,
PIDs: int(s.PidsStats.Current),
At: time.Now(),
}
}
+67
View File
@@ -0,0 +1,67 @@
package docker
import (
"testing"
"github.com/docker/docker/api/types/container"
)
// TestComputeStats — verifikasi rumus tanpa daemon, angka dibuat persis.
func TestComputeStats(t *testing.T) {
raw := &container.StatsResponse{
CPUStats: container.CPUStats{
CPUUsage: container.CPUUsage{TotalUsage: 300_000_000},
SystemUsage: 1_000_000_000,
OnlineCPUs: 4,
},
PreCPUStats: container.CPUStats{
CPUUsage: container.CPUUsage{TotalUsage: 100_000_000},
SystemUsage: 500_000_000,
},
MemoryStats: container.MemoryStats{
Usage: 200 * 1024 * 1024,
Limit: 500 * 1024 * 1024,
Stats: map[string]uint64{"cache": 50 * 1024 * 1024},
},
Networks: map[string]container.NetworkStats{
"eth0": {RxBytes: 1000, TxBytes: 2000},
"eth1": {RxBytes: 500, TxBytes: 300},
},
BlkioStats: container.BlkioStats{
IoServiceBytesRecursive: []container.BlkioStatEntry{
{Op: "Read", Value: 4096},
{Op: "Write", Value: 8192},
{Op: "Sync", Value: 999},
},
},
PidsStats: container.PidsStats{Current: 17},
}
s := computeStats(raw)
// CPU: (300−100)/(1000−500) × 4 × 100 = 160%
if s.CPUPercent < 159.9 || s.CPUPercent > 160.1 {
t.Fatalf("CPU%% = %v, mau ~160", s.CPUPercent)
}
// Mem: (200−50) MB dari limit 500 MB = 30%
if s.MemUsed != 150*1024*1024 || s.MemPercent < 29.9 || s.MemPercent > 30.1 {
t.Fatalf("mem salah: used=%d pct=%v", s.MemUsed, s.MemPercent)
}
if s.NetRx != 1500 || s.NetTx != 2300 {
t.Fatalf("net salah: rx=%d tx=%d", s.NetRx, s.NetTx)
}
if s.BlkRead != 4096 || s.BlkWrite != 8192 {
t.Fatalf("blk salah: r=%d w=%d", s.BlkRead, s.BlkWrite)
}
if s.PIDs != 17 {
t.Fatalf("pids=%d", s.PIDs)
}
}
// TestComputeStatsZeroGuard — delta nol / data kosong tidak boleh NaN/panic.
func TestComputeStatsZeroGuard(t *testing.T) {
s := computeStats(&container.StatsResponse{})
if s.CPUPercent != 0 || s.MemPercent != 0 || s.MemUsed != 0 {
t.Fatalf("guard gagal: %+v", s)
}
}
+6
View File
@@ -23,6 +23,11 @@ tab:
env: "Env"
top: "Top"
stats:
history: "CPU history (60s)"
read: "Read"
write: "Write"
host:
local: "local"
@@ -85,6 +90,7 @@ hint:
tabComing: "This tab is coming (Phase 2)."
logsWaiting: "waiting for logs…"
logsEnd: "log stream ended"
statsWaiting: "fetching stats…"
footerHome: "[1/enter] containers · [t] toast · [T] theme · [a] reduced-motion · [q] quit"
footerContainers: "[1-4] sections · [j/k] select · [Tab] focus · [h/l] tab · [r] reload · [0] home · [T] theme · [q] quit"
+6
View File
@@ -23,6 +23,11 @@ tab:
env: "Env"
top: "Proses"
stats:
history: "Riwayat CPU (60 dtk)"
read: "Baca"
write: "Tulis"
host:
local: "lokal"
@@ -85,6 +90,7 @@ hint:
tabComing: "Tab ini menyusul (Phase 2)."
logsWaiting: "menunggu log…"
logsEnd: "stream log berakhir"
statsWaiting: "mengambil statistik…"
footerHome: "[1/enter] kontainer · [t] toast · [T] tema · [a] reduced-motion · [q] keluar"
footerContainers: "[1-4] bagian · [j/k] pilih · [Tab] fokus · [h/l] tab · [r] muat ulang · [0] beranda · [T] tema · [q] keluar"
+38
View File
@@ -0,0 +1,38 @@
package components
import "math"
// sparkChars — delapan level blok (PRD F-005).
var sparkChars = []rune("▁▂▃▄▅▆▇█")
// Sparkline — grafik mini dari nilai (normalisasi ke maksimum).
// w = lebar maksimum; nilai terbaru ada di kanan. Kosong → "".
func Sparkline(vals []float64, w int) string {
if len(vals) == 0 || w <= 0 {
return ""
}
if len(vals) > w {
vals = vals[len(vals)-w:]
}
maxV := 0.0
for _, v := range vals {
if v > maxV {
maxV = v
}
}
if maxV <= 0 {
maxV = 1
}
out := make([]rune, len(vals))
for i, v := range vals {
idx := int(math.Round(v / maxV * float64(len(sparkChars)-1)))
if idx < 0 {
idx = 0
}
if idx >= len(sparkChars) {
idx = len(sparkChars) - 1
}
out[i] = sparkChars[idx]
}
return string(out)
}
+24
View File
@@ -0,0 +1,24 @@
package components
import "testing"
func TestSparkline(t *testing.T) {
// nol semua → level terendah
if got := Sparkline([]float64{0, 0, 0}, 10); got != "▁▁▁" {
t.Fatalf("nol: %q", got)
}
// maksimum di tengah → blok penuh di sana (3.5 dibulatkan ke atas → ▅)
got := Sparkline([]float64{0, 50, 100, 50, 0}, 10)
want := "▁▅█▅▁"
if got != want {
t.Fatalf("grafik: %q, mau %q", got, want)
}
// pemotongan ke w terakhir (data terbaru di kanan)
got = Sparkline([]float64{100, 0, 0, 0}, 3)
if got != "▁▁▁" {
t.Fatalf("potong: %q", got)
}
if Sparkline(nil, 5) != "" || Sparkline([]float64{1}, 0) != "" {
t.Fatal("kasus kosong harus \"\"")
}
}