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
+419 -17

No files matched your search

+231 -14
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 {
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`)