feat(stats): tab Statistik (F-005) — bar CPU/RAM, riwayat sparkline 60 dtk, laju NET/Disk, PIDs
CI / test (push) Successful in 22s
CI / test (push) Successful in 22s
This commit is contained in:
1 parent
d3abc15a95
commit
86db90ea9a
10 files changed
+409
-18
No files matched your search
+38
-1
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 \"\"")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user