feat(tabs): Proses (docker top) + Konfigurasi (JSON inspect, scroll) + Env (KEY=value) — Phase 2 tuntas
CI / test (push) Successful in 23s
CI / test (push) Successful in 23s
This commit is contained in:
1 parent
86db90ea9a
commit
177e27910b
7 files changed
+419
-17
No files matched your search
+231
-14
@@ -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`)
|
||||
|
||||
|
||||
Reference in new issue
Block a user