diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index dc550e7..f443b9b 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -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") diff --git a/internal/app/app.go b/internal/app/app.go index 1aff66e..b2e5837 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 { - m.logOffset = 0 + 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,8 +479,15 @@ 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 { - if m.logOffset > 0 { - m.logOffset-- + 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,8 +499,15 @@ 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 { - if m.logOffset < len(m.logs)-1 { - m.logOffset++ + 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`) diff --git a/internal/app/logstream.go b/internal/app/logstream.go index 3781f1b..beda9d6 100644 --- a/internal/app/logstream.go +++ b/internal/app/logstream.go @@ -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 diff --git a/internal/docker/client_test.go b/internal/docker/client_test.go index 66b0d94..6bbc27d 100644 --- a/internal/docker/client_test.go +++ b/internal/docker/client_test.go @@ -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 + } } diff --git a/internal/docker/inspect.go b/internal/docker/inspect.go new file mode 100644 index 0000000..51ac1cf --- /dev/null +++ b/internal/docker/inspect.go @@ -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 +} diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index e89049b..f7c3a78 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -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" diff --git a/internal/i18n/locales/id.yaml b/internal/i18n/locales/id.yaml index 93d441c..d1567c8 100644 --- a/internal/i18n/locales/id.yaml +++ b/internal/i18n/locales/id.yaml @@ -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"