feat(tabs): Proses (docker top) + Konfigurasi (JSON inspect, scroll) + Env (KEY=value) — Phase 2 tuntas
CI / test (push) Successful in 23s

This commit is contained in:
oxpoe committed 2026-10-16 01:14:02 +07:00
1 parent 86db90ea9a
commit 177e27910b
7 files changed
+414 -12

No files matched your search

+78 -3
View File
@@ -348,6 +348,82 @@ func TestStatsTab(t *testing.T) {
}
}
// TestProcessTab — tab Proses: tabel `docker top` tampil.
func TestProcessTab(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("h") // dari Log membungkus ke tab Proses (paling akhir)
u.waitContains("PID", 6*time.Second)
u.send("q")
if code := u.waitExit(5 * time.Second); code != 0 {
t.Fatalf("exit code %d, mau 0", code)
}
}
// TestConfigEnvTabs — tab Konfigurasi (JSON inspect) & Env (KEY=value).
func TestConfigEnvTabs(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]
}
env, err := cl.InspectEnv(ctx, items[0].ID)
if err != nil || len(env) == 0 {
t.Skipf("env kosong (err=%v)", err)
}
firstKey, _, _ := strings.Cut(env[0], "=")
u := startTUI(t)
u.waitContains("beranda", 5*time.Second)
u.send("1")
u.waitContains(name, 5*time.Second)
u.send("l") // Stats
u.send("l") // Konfigurasi
u.waitContains(`"Id"`, 6*time.Second)
u.send("l") // Env
u.waitContains(firstKey+"=", 6*time.Second)
u.send("k") // scroll dokumen tidak crash
u.send("G")
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)
@@ -477,9 +553,8 @@ func TestLogTab(t *testing.T) {
u.send("j")
u.send("G")
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("l") // Konfigurasi — kini data nyata: JSON inspect
u.waitContains(`"Id"`, 6*time.Second)
u.send("\x1b[<0;60;10M\x1b[<0;60;10m") // klik area konten = fokus
u.send("q")
+226 -9
View File
@@ -1,6 +1,7 @@
package app
import (
"bytes"
"context"
"fmt"
"os"
@@ -112,6 +113,21 @@ type Model struct {
stats []docker.Stats
statsID int64
inspectID int64
inspectJSON []byte
inspectErr error
envID int64
env []string
envErr error
procsID int64
procsTitle []string
procsRows [][]string
procsErr error
detailScroll int
hostname string
sys sysinfo.Stats
@@ -328,6 +344,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
c := m.containers[min(m.cur(), m.sectionLen()-1)]
return m, fetchStats(m.statsID, m.cl, c.ID)
case inspectMsg:
if msg.id != m.inspectID {
return m, nil
}
m.inspectErr = msg.err
m.inspectJSON = msg.data
m.detailScroll = 0
return m, nil
case envMsg:
if msg.id != m.envID {
return m, nil
}
m.envErr = msg.err
m.env = msg.env
m.detailScroll = 0
return m, nil
case procsMsg:
if msg.id != m.procsID {
return m, nil
}
m.procsErr = msg.err
m.procsTitle, m.procsRows = msg.titles, msg.rows
return m, nil
case tea.BackgroundColorMsg:
// Mode tema "auto": Mocha vs Latte sesuai background terminal.
theme.SetDetectedDark(msg.IsDark())
@@ -416,9 +458,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.view == viewList && m.section == sectionContainers {
return m, m.cycleTab(1)
}
case "G": // kembali ke ekor log (live)
case "G": // ekor log (live) / dasar dokumen (config & env)
if m.view == viewList && m.focus == focusContent {
switch m.tab {
case tabConfig, tabEnv:
m.detailScroll = m.detailScrollMax()
default:
m.logOffset = 0
}
m.sound.Play(audio.Click)
}
case "S": // toggle timestamp log
@@ -432,9 +479,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.restartData()
case "j", "down":
if m.view == viewList && m.focus == focusContent {
switch m.tab {
case tabConfig, tabEnv:
if m.detailScroll < m.detailScrollMax() {
m.detailScroll++
}
default: // tab Log
if m.logOffset > 0 {
m.logOffset--
}
}
m.sound.Play(audio.Navigate)
break
}
@@ -445,9 +499,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.restartData()
case "k", "up":
if m.view == viewList && m.focus == focusContent {
switch m.tab {
case tabConfig, tabEnv:
if m.detailScroll > 0 {
m.detailScroll--
}
default: // tab Log
if m.logOffset < len(m.logs)-1 {
m.logOffset++
}
}
m.sound.Play(audio.Navigate)
break
}
@@ -485,13 +546,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.view != viewList || m.focus != focusContent {
return m, nil
}
switch msg.Button {
case tea.MouseWheelUp:
if m.logOffset < len(m.logs)-1 {
m.logOffset++
up := msg.Button == tea.MouseWheelUp
switch m.tab {
case tabConfig, tabEnv:
if up && m.detailScroll < m.detailScrollMax() {
m.detailScroll++
} else if !up && m.detailScroll > 0 {
m.detailScroll--
}
case tea.MouseWheelDown:
if m.logOffset > 0 {
default: // tab Log
if up && m.logOffset < len(m.logs)-1 {
m.logOffset++
} else if !up && m.logOffset > 0 {
m.logOffset--
}
}
@@ -583,9 +649,38 @@ func (m *Model) restartStats() tea.Cmd {
return fetchStats(m.statsID, m.cl, c.ID)
}
// restartData — restart semua sumber data detail (log + stats) untuk seleksi kini.
// restartData — restart semua sumber data detail (log + stats + inspect/env/
// proses) untuk seleksi saat ini; masing-masing menyaring sendiri konteksnya.
func (m *Model) restartData() tea.Cmd {
return tea.Batch(m.restartLogs(), m.restartStats())
return tea.Batch(
m.restartLogs(), m.restartStats(),
m.restartInspect(), m.restartEnv(), m.restartProcs(),
)
}
func (m *Model) detailCmdFor(tab int, fetch func(id int64, cl *docker.Client, cid string) tea.Cmd, id *int64) tea.Cmd {
*id++
if m.view != viewList || m.section != sectionContainers || m.tab != tab ||
m.cl == nil || m.sectionLen() == 0 {
return nil
}
c := m.containers[min(m.cur(), m.sectionLen()-1)]
return fetch(*id, m.cl, c.ID)
}
func (m *Model) restartInspect() tea.Cmd {
m.inspectJSON, m.inspectErr = nil, nil
return m.detailCmdFor(tabConfig, fetchInspect, &m.inspectID)
}
func (m *Model) restartEnv() tea.Cmd {
m.env, m.envErr = nil, nil
return m.detailCmdFor(tabEnv, fetchEnv, &m.envID)
}
func (m *Model) restartProcs() tea.Cmd {
m.procsTitle, m.procsRows, m.procsErr = nil, nil, nil
return m.detailCmdFor(tabTop, fetchProcs, &m.procsID)
}
// --- data helpers ---
@@ -930,6 +1025,12 @@ func (m Model) contentView() string {
lines = append(lines, m.logLines(h-3)...)
case tabStats:
lines = append(lines, m.statsLines(h-3, w-4)...)
case tabConfig:
lines = append(lines, m.inspectLines(h-3, w-4)...)
case tabEnv:
lines = append(lines, m.envLines(h-3, w-4)...)
case tabTop:
lines = append(lines, m.procLines(h-3, w-4)...)
default:
lines = append(lines, "", dim(i18n.T("hint.tabComing")))
}
@@ -1021,6 +1122,122 @@ func (m Model) logLines(rows int) []string {
return out
}
// detailScrollMax — batas scroll dokumen (tab Config/Env) dalam baris.
func (m Model) detailScrollMax() int {
rows := m.bodyHeight() - 3
var total int
switch m.tab {
case tabConfig:
if len(m.inspectJSON) > 0 {
total = bytes.Count(m.inspectJSON, []byte("\n")) + 1
}
case tabEnv:
total = len(m.env)
}
if total <= rows {
return 0
}
return total - rows
}
// inspectLines — JSON inspect mentah (scroll via detailScroll).
func (m Model) inspectLines(rows, w int) []string {
if m.inspectErr != nil {
return []string{red(truncate(m.inspectErr.Error(), w))}
}
if len(m.inspectJSON) == 0 {
return []string{dim(i18n.T("hint.inspectWaiting"))}
}
all := strings.Split(string(m.inspectJSON), "\n")
out := make([]string, 0, rows)
for i := m.detailScroll; i < len(all) && len(out) < rows; i++ {
out = append(out, all[i])
}
return out
}
// envLines — KEY=value; kunci terang, nilai redup.
func (m Model) envLines(rows, w int) []string {
if m.envErr != nil {
return []string{red(truncate(m.envErr.Error(), w))}
}
if m.env == nil {
return []string{dim(i18n.T("hint.inspectWaiting"))}
}
out := make([]string, 0, rows)
for i := m.detailScroll; i < len(m.env) && len(out) < rows; i++ {
kv := m.env[i]
if k, v, ok := strings.Cut(kv, "="); ok {
out = append(out, text(k+"=")+dim(truncate(v, max(1, w-len([]rune(k))-1))))
} else {
out = append(out, text(kv))
}
}
return out
}
// procLines — tabel proses (`docker top`): header redup, kolom dipadatkan,
// kolom terakhir (perintah) mengambil sisa lebar.
func (m Model) procLines(rows, w int) []string {
if m.procsErr != nil {
return []string{red(truncate(m.procsErr.Error(), w))}
}
cols := len(m.procsTitle)
if cols == 0 {
return []string{dim(i18n.T("hint.procsWaiting"))}
}
widths := make([]int, cols)
for i, t := range m.procsTitle {
widths[i] = len([]rune(t))
}
for _, r := range m.procsRows {
for i, c := range r {
if i < cols && len([]rune(c)) > widths[i] {
widths[i] = len([]rune(c))
}
}
}
for i := 0; i < cols-1; i++ {
if widths[i] > 14 {
widths[i] = 14
}
}
used := 0
for i := 0; i < cols-1; i++ {
used += widths[i] + 2
}
widths[cols-1] = max(10, w-used)
row := func(cells []string, style func(string) string) string {
var b strings.Builder
for i := 0; i < cols; i++ {
c := ""
if i < len(cells) {
c = cells[i]
}
if r := []rune(c); len(r) > widths[i] {
c = string(r[:max(1, widths[i]-1)]) + "…"
}
b.WriteString(style(fmt.Sprintf("%-*s", widths[i], c)))
if i < cols-1 {
b.WriteString(" ")
}
}
return b.String()
}
out := []string{row(m.procsTitle, dim)}
maxRows := rows - 1
for i, r := range m.procsRows {
if i >= maxRows {
out = append(out, dim(fmt.Sprintf("… +%d", len(m.procsRows)-i)))
break
}
out = append(out, row(r, text))
}
return out
}
// 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`)
+46
View File
@@ -48,6 +48,52 @@ func statsNext(id int64) tea.Cmd {
return tea.Tick(statsInterval, func(time.Time) tea.Msg { return statsTickMsg{id: id} })
}
// inspectMsg / envMsg / procsMsg — data tab Konfigurasi / Env / Proses
// (one-shot saat tab dibuka atau `r`).
type inspectMsg struct {
id int64
data []byte
err error
}
type envMsg struct {
id int64
env []string
err error
}
type procsMsg struct {
id int64
titles []string
rows [][]string
err error
}
func fetchInspect(id int64, cl *docker.Client, cid string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
b, err := cl.InspectJSON(ctx, cid)
return inspectMsg{id: id, data: b, err: err}
}
}
func fetchEnv(id int64, cl *docker.Client, cid string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
env, err := cl.InspectEnv(ctx, cid)
return envMsg{id: id, env: env, err: err}
}
}
func fetchProcs(id int64, cl *docker.Client, cid string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
titles, rows, err := cl.TopProcesses(ctx, cid)
return procsMsg{id: id, titles: titles, rows: rows, err: err}
}
}
const (
logBatchMax = 200 // maksimum baris per batch
logIdle = 200 * time.Millisecond // tunggu baris pertama
+19
View File
@@ -61,4 +61,23 @@ func TestIntegrationInfo(t *testing.T) {
t.Fatalf("ListNetworks: %v", err)
}
t.Logf("images=%d volumes=%d networks=%d", len(images), len(volumes), len(networks))
// Inspect & top: pakai kontainer yang benar-benar berjalan.
for _, ct := range items {
if ct.State != "running" {
continue
}
if b, err := c.InspectJSON(ctx, ct.ID); err != nil || len(b) == 0 {
t.Fatalf("InspectJSON(%s): err=%v len=%d", ct.Name, err, len(b))
}
if env, err := c.InspectEnv(ctx, ct.ID); err != nil || len(env) == 0 {
t.Fatalf("InspectEnv(%s): err=%v n=%d", ct.Name, err, len(env))
}
titles, rows, err := c.TopProcesses(ctx, ct.ID)
if err != nil || len(titles) == 0 || len(rows) == 0 {
t.Fatalf("TopProcesses(%s): err=%v titles=%d rows=%d", ct.Name, err, len(titles), len(rows))
}
t.Logf("inspect+env+top OK untuk %s (%d proses)", ct.Name, len(rows))
break
}
}
+41
View File
@@ -0,0 +1,41 @@
package docker
import (
"context"
"encoding/json"
"fmt"
)
// InspectJSON — inspect kontainer sebagai JSON rapi (tab Konfigurasi).
func (c *Client) InspectJSON(ctx context.Context, id string) ([]byte, error) {
in, err := c.cli.ContainerInspect(ctx, id)
if err != nil {
return nil, fmt.Errorf("docker: inspect: %w", err)
}
b, err := json.MarshalIndent(in, "", " ")
if err != nil {
return nil, fmt.Errorf("docker: marshal inspect: %w", err)
}
return b, nil
}
// InspectEnv — variabel environment kontainer (tab Env).
func (c *Client) InspectEnv(ctx context.Context, id string) ([]string, error) {
in, err := c.cli.ContainerInspect(ctx, id)
if err != nil {
return nil, fmt.Errorf("docker: inspect env: %w", err)
}
if in.Config == nil {
return nil, nil
}
return in.Config.Env, nil
}
// TopProcesses — daftar proses di dalam kontainer (`docker top`).
func (c *Client) TopProcesses(ctx context.Context, id string) ([]string, [][]string, error) {
resp, err := c.cli.ContainerTop(ctx, id, nil)
if err != nil {
return nil, nil, fmt.Errorf("docker: top: %w", err)
}
return resp.Titles, resp.Processes, nil
}
+2
View File
@@ -91,6 +91,8 @@ hint:
logsWaiting: "waiting for logs…"
logsEnd: "log stream ended"
statsWaiting: "fetching stats…"
inspectWaiting: "fetching inspect…"
procsWaiting: "fetching processes…"
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"
+2
View File
@@ -91,6 +91,8 @@ hint:
logsWaiting: "menunggu log…"
logsEnd: "stream log berakhir"
statsWaiting: "mengambil statistik…"
inspectWaiting: "mengambil inspect…"
procsWaiting: "mengambil daftar proses…"
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"