feat(logs): log viewer streaming real-time (F-004) — tab Log, follow+scroll, toggle timestamp, focus panel
CI / test (push) Successful in 38s
CI / test (push) Successful in 38s
This commit is contained in:
1 parent
8f7b6c8ab6
commit
de8bbbd354
7 files changed
+518
-26
No files matched your search
+43
-1
@@ -401,7 +401,49 @@ func TestClickSelectsContainer(t *testing.T) {
|
||||
// klik baris item ke-2: xterm y=4 (1-based) → app row 3 → indeks 1
|
||||
u.send("\x1b[<0;10;4M\x1b[<0;10;4m")
|
||||
u.waitHighlighted(short(items[1].Name), 3*time.Second)
|
||||
u.waitContains(items[1].ID, 3*time.Second) // panel detail ikut berpindah
|
||||
|
||||
u.send("q")
|
||||
if code := u.waitExit(5 * time.Second); code != 0 {
|
||||
t.Fatalf("exit code %d, mau 0", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogTab — tab Log: stream nyata (timestamp/level), placeholder tab lain,
|
||||
// fokus & scroll tidak crash. Butuh docker + minimal 1 kontainer.
|
||||
func TestLogTab(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.waitContains("Statistik", 3*time.Second) // baris tab
|
||||
|
||||
u.send("\t") // fokus ke panel konten
|
||||
u.send("k")
|
||||
u.send("j")
|
||||
u.send("G")
|
||||
u.send("l") // tab berikutnya → 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
|
||||
|
||||
u.send("q")
|
||||
if code := u.waitExit(5 * time.Second); code != 0 {
|
||||
|
||||
+278
-21
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -27,6 +28,23 @@ const (
|
||||
viewList
|
||||
)
|
||||
|
||||
type focusKind int
|
||||
|
||||
const (
|
||||
focusSidebar focusKind = iota
|
||||
focusContent
|
||||
)
|
||||
|
||||
// Tab detail di panel konten (PRD §5.2, versi awal: Log berfungsi).
|
||||
const (
|
||||
tabLogs = iota
|
||||
tabStats
|
||||
tabConfig
|
||||
tabEnv
|
||||
tabTop
|
||||
tabCount
|
||||
)
|
||||
|
||||
// section — bagian resource di workspace (PRD F-001: 1-4 jump to section).
|
||||
type section int
|
||||
|
||||
@@ -75,6 +93,18 @@ type Model struct {
|
||||
|
||||
view viewKind
|
||||
section section
|
||||
focus focusKind
|
||||
tab int
|
||||
|
||||
tailLines int
|
||||
showTimestamps bool
|
||||
|
||||
logs []string
|
||||
logCh chan string
|
||||
logCancel context.CancelFunc
|
||||
logID int64
|
||||
logOffset int
|
||||
logEOF bool
|
||||
|
||||
hostname string
|
||||
sys sysinfo.Stats
|
||||
@@ -116,10 +146,15 @@ func New() Model {
|
||||
toast: components.NewToast(),
|
||||
view: viewHome,
|
||||
section: sectionContainers,
|
||||
focus: focusSidebar,
|
||||
tab: tabLogs,
|
||||
hostname: host,
|
||||
sys: sysinfo.Read(),
|
||||
cl: cl,
|
||||
loading: true,
|
||||
|
||||
tailLines: cfg.UI.LogTailLines,
|
||||
showTimestamps: cfg.UI.ShowTimestamps,
|
||||
}
|
||||
if cfgErr != nil {
|
||||
m.toast.Show(components.LevelWarning, i18n.T("toast.configError"), cfgErr.Error())
|
||||
@@ -219,7 +254,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, animation.Next(m.tickID)
|
||||
|
||||
case containersMsg:
|
||||
return m.applyList(msg.err, func(mm *Model) { mm.containers = msg.items })
|
||||
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 mm, cmd
|
||||
|
||||
case imagesMsg:
|
||||
return m.applyList(msg.err, func(mm *Model) { mm.images = msg.items })
|
||||
@@ -239,6 +278,31 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case logBatchMsg:
|
||||
if msg.id != m.logID {
|
||||
return m, nil // stream basi (seleksi sudah pindah)
|
||||
}
|
||||
if len(msg.lines) > 0 {
|
||||
m.logs = append(m.logs, msg.lines...)
|
||||
if over := len(m.logs) - m.tailLines; over > 0 {
|
||||
m.logs = m.logs[over:]
|
||||
}
|
||||
}
|
||||
if msg.eof {
|
||||
m.logEOF = true
|
||||
return m, nil
|
||||
}
|
||||
if m.logCh == nil {
|
||||
return m, nil
|
||||
}
|
||||
return m, waitLogBatch(m.logID, m.logCh)
|
||||
|
||||
case logTickMsg:
|
||||
if msg.id != m.logID || m.logCh == nil {
|
||||
return m, nil
|
||||
}
|
||||
return m, waitLogBatch(m.logID, m.logCh)
|
||||
|
||||
case tea.BackgroundColorMsg:
|
||||
// Mode tema "auto": Mocha vs Latte sesuai background terminal.
|
||||
theme.SetDetectedDark(msg.IsDark())
|
||||
@@ -250,6 +314,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
m.stopLogs()
|
||||
m.sound.Play(audio.ModalClose)
|
||||
return m, tea.Quit
|
||||
case "esc":
|
||||
@@ -257,23 +322,34 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.sound.Play(audio.ModalClose)
|
||||
return m, tea.Quit
|
||||
}
|
||||
if m.focus == focusContent {
|
||||
m.focus = focusSidebar
|
||||
m.sound.Play(audio.Click)
|
||||
break
|
||||
}
|
||||
m.view = viewHome
|
||||
m.stopLogs()
|
||||
m.sound.Play(audio.Click)
|
||||
case "0":
|
||||
m.view = viewHome
|
||||
m.stopLogs()
|
||||
m.sound.Play(audio.Click)
|
||||
case "1", "enter":
|
||||
m.jumpSection(sectionContainers)
|
||||
m.sound.Play(audio.Click)
|
||||
return m, m.restartLogs()
|
||||
case "2":
|
||||
m.jumpSection(sectionImages)
|
||||
m.sound.Play(audio.Click)
|
||||
return m, m.restartLogs()
|
||||
case "3":
|
||||
m.jumpSection(sectionVolumes)
|
||||
m.sound.Play(audio.Click)
|
||||
return m, m.restartLogs()
|
||||
case "4":
|
||||
m.jumpSection(sectionNetworks)
|
||||
m.sound.Play(audio.Click)
|
||||
return m, m.restartLogs()
|
||||
case "a": // reduced motion
|
||||
m.animating = !m.animating
|
||||
if m.animating {
|
||||
@@ -286,7 +362,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.loading = true
|
||||
m.sound.Play(audio.Click)
|
||||
return m, tea.Batch(loadAll(m.cl)...)
|
||||
return m, tea.Batch(append(loadAll(m.cl), m.restartLogs())...)
|
||||
case "T": // tombol cepat tema (PRD §5.4): auto → dark → light → terminal
|
||||
cur := theme.Current()
|
||||
next := theme.CycleMode(cur.Mode)
|
||||
@@ -298,16 +374,63 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case "t": // demo toast (sampai modal asli datang)
|
||||
m.toast.Show(components.LevelSuccess, i18n.T("toast.demoTitle"), i18n.T("toast.demoBody"))
|
||||
m.sound.Play(audio.NotifySuccess)
|
||||
case "tab":
|
||||
if m.view == viewList {
|
||||
if m.focus == focusSidebar {
|
||||
m.focus = focusContent
|
||||
} else {
|
||||
m.focus = focusSidebar
|
||||
}
|
||||
m.sound.Play(audio.Click)
|
||||
}
|
||||
case "h", "left", "[":
|
||||
if m.view == viewList && m.section == sectionContainers {
|
||||
return m, m.cycleTab(-1)
|
||||
}
|
||||
case "l", "right", "]":
|
||||
if m.view == viewList && m.section == sectionContainers {
|
||||
return m, m.cycleTab(1)
|
||||
}
|
||||
case "G": // kembali ke ekor log (live)
|
||||
if m.view == viewList && m.focus == focusContent {
|
||||
m.logOffset = 0
|
||||
m.sound.Play(audio.Click)
|
||||
}
|
||||
case "S": // toggle timestamp log
|
||||
m.showTimestamps = !m.showTimestamps
|
||||
label := i18n.T("state.off")
|
||||
if m.showTimestamps {
|
||||
label = i18n.T("state.on")
|
||||
}
|
||||
m.toast.Show(components.LevelInfo, i18n.Tf("toast.timestamps", label), i18n.T("theme.switchHint"))
|
||||
m.sound.Play(audio.Click)
|
||||
return m, m.restartLogs()
|
||||
case "j", "down":
|
||||
if m.view == viewList && m.focus == focusContent {
|
||||
if m.logOffset > 0 {
|
||||
m.logOffset--
|
||||
}
|
||||
m.sound.Play(audio.Navigate)
|
||||
break
|
||||
}
|
||||
if c := m.cur(); c < m.sectionLen()-1 {
|
||||
m.setCur(c + 1)
|
||||
}
|
||||
m.sound.Play(audio.Navigate)
|
||||
return m, m.restartLogs()
|
||||
case "k", "up":
|
||||
if m.view == viewList && m.focus == focusContent {
|
||||
if m.logOffset < len(m.logs)-1 {
|
||||
m.logOffset++
|
||||
}
|
||||
m.sound.Play(audio.Navigate)
|
||||
break
|
||||
}
|
||||
if c := m.cur(); c > 0 {
|
||||
m.setCur(c - 1)
|
||||
}
|
||||
m.sound.Play(audio.Navigate)
|
||||
return m, m.restartLogs()
|
||||
}
|
||||
|
||||
case tea.MouseClickMsg:
|
||||
@@ -316,16 +439,38 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.toast.Active() {
|
||||
m.toast = components.NewToast() // klik = tutup toast
|
||||
}
|
||||
// hit-test sidebar (hanya di view list):
|
||||
if m.view == viewList {
|
||||
if msg.X >= m.leftWidth() {
|
||||
m.focus = focusContent
|
||||
} else {
|
||||
m.focus = focusSidebar
|
||||
if m.sectionLen() > 0 {
|
||||
// baris item pertama = headerRows + 1 (border atas panel)
|
||||
if m.view == viewList && msg.X < m.leftWidth() && m.sectionLen() > 0 {
|
||||
idx := msg.Y - (m.headerRows() + 1) + m.scrollStart()
|
||||
if idx >= 0 && idx < m.sectionLen() {
|
||||
m.setCur(idx)
|
||||
return m, m.restartLogs()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case tea.MouseWheelMsg:
|
||||
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++
|
||||
}
|
||||
case tea.MouseWheelDown:
|
||||
if m.logOffset > 0 {
|
||||
m.logOffset--
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -353,6 +498,51 @@ func (m Model) applyList(err error, set func(*Model)) (tea.Model, tea.Cmd) {
|
||||
func (m *Model) jumpSection(s section) {
|
||||
m.section = s
|
||||
m.view = viewList
|
||||
m.focus = focusSidebar
|
||||
}
|
||||
|
||||
// stopLogs — matikan stream aktif tanpa memulai yang baru.
|
||||
func (m *Model) stopLogs() {
|
||||
if m.logCancel != nil {
|
||||
m.logCancel()
|
||||
m.logCancel = nil
|
||||
}
|
||||
m.logCh = nil
|
||||
m.logs = nil
|
||||
m.logOffset = 0
|
||||
m.logEOF = false
|
||||
}
|
||||
|
||||
// restartLogs — batalkan stream lama lalu buka stream baru bila konteksnya
|
||||
// cocok (view list · section Kontainer · tab Log). Pola lazydocker
|
||||
// (REA modul 5): satu stream aktif, dibatalkan saat seleksi berpindah.
|
||||
func (m *Model) restartLogs() tea.Cmd {
|
||||
m.stopLogs()
|
||||
if m.view != viewList || m.section != sectionContainers || m.tab != tabLogs ||
|
||||
m.cl == nil || m.sectionLen() == 0 {
|
||||
return nil
|
||||
}
|
||||
c := m.containers[min(m.cur(), m.sectionLen()-1)]
|
||||
m.logID++
|
||||
id := m.logID
|
||||
ch := make(chan string, 256)
|
||||
m.logCh = ch
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m.logCancel = cancel
|
||||
|
||||
cl, tail, ts := m.cl, m.tailLines, m.showTimestamps
|
||||
go streamContainerLogs(ctx, cl, c.ID, tail, ts, ch)
|
||||
return waitLogBatch(id, ch)
|
||||
}
|
||||
|
||||
func (m *Model) cycleTab(delta int) tea.Cmd {
|
||||
if m.view != viewList || m.section != sectionContainers {
|
||||
return nil
|
||||
}
|
||||
m.tab = (m.tab + delta + tabCount) % tabCount
|
||||
m.focus = focusContent
|
||||
m.sound.Play(audio.Click)
|
||||
return m.restartLogs()
|
||||
}
|
||||
|
||||
// --- data helpers ---
|
||||
@@ -612,7 +802,7 @@ func (m Model) sidebarView() string {
|
||||
lines = append(lines, m.sectionLine(i, w-4))
|
||||
}
|
||||
}
|
||||
return panel(title, meta, lines, w, h, false)
|
||||
return panel(title, meta, lines, w, h, m.focus == focusSidebar)
|
||||
}
|
||||
|
||||
func (m Model) sectionLine(i, innerW int) string {
|
||||
@@ -672,23 +862,36 @@ func selPad(s string, w int) string {
|
||||
|
||||
func (m Model) contentView() string {
|
||||
w, h := m.width-m.leftWidth(), m.bodyHeight()
|
||||
focused := m.view == viewList && m.focus == focusContent
|
||||
|
||||
if m.loadErr != nil {
|
||||
return panel(m.sectionName(), "", []string{
|
||||
red(i18n.Tf("error.loadSection", m.sectionName())), "",
|
||||
dim(truncate(m.loadErr.Error(), w-6)),
|
||||
}, w, h, false)
|
||||
}, w, h, focused)
|
||||
}
|
||||
if m.sectionLen() == 0 {
|
||||
return panel(m.sectionName(), "", []string{
|
||||
dim(i18n.Tf("hint.emptyItems", m.sectionName())),
|
||||
}, w, h, false)
|
||||
}, w, h, focused)
|
||||
}
|
||||
|
||||
idx := min(m.cur(), m.sectionLen()-1)
|
||||
|
||||
// Kontainer: tab detail (Log berfungsi; sisanya menyusul Phase 2).
|
||||
if m.section == sectionContainers {
|
||||
c := m.containers[idx]
|
||||
lines := []string{m.tabBarLine(w - 4)}
|
||||
if m.tab == tabLogs {
|
||||
lines = append(lines, m.logLines(h-3)...)
|
||||
} else {
|
||||
lines = append(lines, "", dim(i18n.T("hint.tabComing")))
|
||||
}
|
||||
return panel(c.Name, c.State, lines, w, h, focused)
|
||||
}
|
||||
|
||||
title, meta := m.sectionName(), ""
|
||||
var lines []string
|
||||
|
||||
switch m.section {
|
||||
case sectionImages:
|
||||
im := m.images[idx]
|
||||
@@ -724,18 +927,72 @@ func (m Model) contentView() string {
|
||||
field(i18n.T("field.scope"))+dim(n.Scope),
|
||||
field(i18n.T("field.subnet"))+dim(n.Subnet),
|
||||
)
|
||||
default:
|
||||
c := m.containers[idx]
|
||||
glyph, color := stateStyle(c.State)
|
||||
title, meta = c.Name, c.State
|
||||
lines = append(lines,
|
||||
field(i18n.T("field.id"))+dim(c.ID),
|
||||
field(i18n.T("field.image"))+dim(truncate(c.Image, w-20)),
|
||||
field(i18n.T("field.state"))+styled(color, glyph+" "+i18n.TOr("state."+c.State, c.State)),
|
||||
field(i18n.T("field.status"))+dim(truncate(c.Status, w-20)),
|
||||
"",
|
||||
dim(i18n.T("hint.tabsComing")),
|
||||
)
|
||||
}
|
||||
return panel(title, meta, lines, w, h, false)
|
||||
return panel(title, meta, lines, w, h, focused)
|
||||
}
|
||||
|
||||
// tabBarLine — baris tab (aktif bergaris aksen, sisanya redup).
|
||||
func (m Model) tabBarLine(w int) string {
|
||||
tabs := []string{
|
||||
i18n.T("tab.logs"), i18n.T("tab.stats"), i18n.T("tab.config"),
|
||||
i18n.T("tab.env"), i18n.T("tab.top"),
|
||||
}
|
||||
parts := make([]string, 0, len(tabs))
|
||||
for i, t := range tabs {
|
||||
if i == m.tab {
|
||||
parts = append(parts, theme.Style(theme.Current().Accent).Bold(true).Render("["+t+"]"))
|
||||
} else {
|
||||
parts = append(parts, dim(t))
|
||||
}
|
||||
}
|
||||
return ansiTruncate(strings.Join(parts, " "), w)
|
||||
}
|
||||
|
||||
// logLines — potongan log yang tampil: h-3 baris, offset dari ekor (live).
|
||||
func (m Model) logLines(rows int) []string {
|
||||
if len(m.logs) == 0 {
|
||||
msg := i18n.T("hint.logsWaiting")
|
||||
if m.logEOF {
|
||||
msg = i18n.T("hint.logsEnd")
|
||||
}
|
||||
return []string{dim(msg)}
|
||||
}
|
||||
end := len(m.logs) - m.logOffset
|
||||
if end > len(m.logs) {
|
||||
end = len(m.logs)
|
||||
}
|
||||
if end < 0 {
|
||||
end = 0
|
||||
}
|
||||
start := end - rows
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
out := make([]string, 0, end-start)
|
||||
for _, l := range m.logs[start:end] {
|
||||
out = append(out, colorizeLog(l))
|
||||
}
|
||||
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`)
|
||||
|
||||
// colorizeLog — warnai token level (PRD §5.4: INFO=info, WARN=warning, ERRO=danger).
|
||||
func colorizeLog(line string) string {
|
||||
th := theme.Current()
|
||||
return logLevelRE.ReplaceAllStringFunc(line, func(tok string) string {
|
||||
var c theme.Color
|
||||
switch {
|
||||
case strings.HasPrefix(tok, "INFO"):
|
||||
c = th.Info
|
||||
case strings.HasPrefix(tok, "WARN"):
|
||||
c = th.Warning
|
||||
case strings.HasPrefix(tok, "TRAC"), strings.HasPrefix(tok, "DEBU"):
|
||||
c = th.Subtext
|
||||
default:
|
||||
c = th.Danger
|
||||
}
|
||||
return theme.Style(c).Bold(true).Render(tok)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
|
||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker"
|
||||
)
|
||||
|
||||
// --- pesan ---
|
||||
|
||||
// logBatchMsg — sekumpulan baris log (dikirim per batch agar hemat render).
|
||||
type logBatchMsg struct {
|
||||
id int64
|
||||
lines []string
|
||||
eof bool
|
||||
}
|
||||
|
||||
// logTickMsg — tidak ada data dalam window idle; re-arm tanpa state change.
|
||||
type logTickMsg struct{ id int64 }
|
||||
|
||||
const (
|
||||
logBatchMax = 200 // maksimum baris per batch
|
||||
logIdle = 200 * time.Millisecond // tunggu baris pertama
|
||||
logDrainWait = 50 * time.Millisecond // jendela serap baris siap berikutnya
|
||||
)
|
||||
|
||||
// lineWriter — pecah byte stream jadi baris dan dorong ke channel.
|
||||
// Bila konsumen lambat (buffer penuh), baris di-drop supaya UI tetap responsif
|
||||
// (shortcut: log yang lewat di detik ekstrem bisa hilang — listener batch
|
||||
// akan mengejar; upgrade bila perlu arsip log lengkap di file).
|
||||
type lineWriter struct {
|
||||
ch chan<- string
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func newLineWriter(ch chan<- string) *lineWriter { return &lineWriter{ch: ch} }
|
||||
|
||||
func (w *lineWriter) Write(p []byte) (int, error) {
|
||||
w.buf = append(w.buf, p...)
|
||||
for {
|
||||
i := bytes.IndexByte(w.buf, '\n')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
line := strings.TrimRight(string(w.buf[:i]), "\r")
|
||||
w.buf = w.buf[i+1:]
|
||||
select {
|
||||
case w.ch <- line:
|
||||
default:
|
||||
}
|
||||
}
|
||||
if len(w.buf) > 1<<20 { // baris raksasa tanpa newline: jaga memori
|
||||
w.buf = nil
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (w *lineWriter) flush() {
|
||||
if len(w.buf) == 0 {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case w.ch <- string(w.buf):
|
||||
default:
|
||||
}
|
||||
w.buf = nil
|
||||
}
|
||||
|
||||
// streamContainerLogs — goroutine per stream: inspect (TTY) → docker logs →
|
||||
// demux → lineWriter → channel. Menutup channel saat selesai/ctx dibatalkan.
|
||||
func streamContainerLogs(ctx context.Context, cl *docker.Client, id string, tail int, timestamps bool, ch chan<- string) {
|
||||
defer close(ch)
|
||||
|
||||
tty, err := cl.ContainerTty(ctx, id)
|
||||
if err != nil {
|
||||
ch <- "! " + err.Error()
|
||||
return
|
||||
}
|
||||
rc, err := cl.StreamLogs(ctx, id, tail, timestamps)
|
||||
if err != nil {
|
||||
ch <- "! " + err.Error()
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
lw := newLineWriter(ch)
|
||||
if tty {
|
||||
_, _ = io.Copy(lw, rc)
|
||||
} else {
|
||||
_, _ = stdcopy.StdCopy(lw, lw, rc) // stdout & stderr dicampur
|
||||
}
|
||||
lw.flush()
|
||||
}
|
||||
|
||||
// waitLogBatch — tea.Cmd: tunggu baris pertama (maks logIdle), lalu serap
|
||||
// sebanyak mungkin dalam jendela pendek supaya 1 render menampung banyak baris.
|
||||
func waitLogBatch(id int64, ch <-chan string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
var lines []string
|
||||
select {
|
||||
case l, ok := <-ch:
|
||||
if !ok {
|
||||
return logBatchMsg{id: id, eof: true}
|
||||
}
|
||||
lines = append(lines, l)
|
||||
case <-time.After(logIdle):
|
||||
return logTickMsg{id: id}
|
||||
}
|
||||
|
||||
timer := time.NewTimer(logDrainWait)
|
||||
defer timer.Stop()
|
||||
for len(lines) < logBatchMax {
|
||||
select {
|
||||
case l, ok := <-ch:
|
||||
if !ok {
|
||||
return logBatchMsg{id: id, lines: lines, eof: true}
|
||||
}
|
||||
lines = append(lines, l)
|
||||
case <-timer.C:
|
||||
return logBatchMsg{id: id, lines: lines}
|
||||
}
|
||||
}
|
||||
return logBatchMsg{id: id, lines: lines}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ type UI struct {
|
||||
SoundVolume int `yaml:"sound_volume"` // 0-100
|
||||
SoundMuteOnSSH bool `yaml:"sound_mute_on_ssh"`
|
||||
AnimationEnabled bool `yaml:"animation_enabled"`
|
||||
LogTailLines int `yaml:"log_tail_lines"` // jumlah baris log yang disimpan
|
||||
ShowTimestamps bool `yaml:"show_timestamps"` // timestamp di log kontainer
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
@@ -43,6 +45,8 @@ func Default() Config {
|
||||
SoundVolume: 70,
|
||||
SoundMuteOnSSH: true,
|
||||
AnimationEnabled: true,
|
||||
LogTailLines: 300,
|
||||
ShowTimestamps: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -120,6 +124,12 @@ func (c *Config) normalize() {
|
||||
if c.UI.SoundVolume > 100 {
|
||||
c.UI.SoundVolume = 100
|
||||
}
|
||||
if c.UI.LogTailLines < 10 {
|
||||
c.UI.LogTailLines = 10
|
||||
}
|
||||
if c.UI.LogTailLines > 10000 {
|
||||
c.UI.LogTailLines = 10000
|
||||
}
|
||||
if c.StacksDir == "" {
|
||||
c.StacksDir = Default().StacksDir
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ package docker
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
@@ -194,3 +196,28 @@ func trimID(s string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// StreamLogs membuka stream log kontainer (follow). Pemanggil wajib Close.
|
||||
// Stream mentah: kontainer TTY tidak dibungkus header multiplex.
|
||||
func (c *Client) StreamLogs(ctx context.Context, id string, tail int, timestamps bool) (io.ReadCloser, error) {
|
||||
rc, err := c.cli.ContainerLogs(ctx, id, container.LogsOptions{
|
||||
ShowStdout: true,
|
||||
ShowStderr: true,
|
||||
Follow: true,
|
||||
Tail: strconv.Itoa(tail),
|
||||
Timestamps: timestamps,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker: stream log: %w", err)
|
||||
}
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
// ContainerTty — apakah kontainer punya TTY (menentukan perlu demux atau tidak).
|
||||
func (c *Client) ContainerTty(ctx context.Context, id string) (bool, error) {
|
||||
in, err := c.cli.ContainerInspect(ctx, id)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("docker: inspect: %w", err)
|
||||
}
|
||||
return in.Config != nil && in.Config.Tty, nil
|
||||
}
|
||||
@@ -16,6 +16,13 @@ section:
|
||||
activity: "Activity"
|
||||
detail: "Detail"
|
||||
|
||||
tab:
|
||||
logs: "Logs"
|
||||
stats: "Stats"
|
||||
config: "Config"
|
||||
env: "Env"
|
||||
top: "Top"
|
||||
|
||||
host:
|
||||
local: "local"
|
||||
|
||||
@@ -59,6 +66,8 @@ state:
|
||||
untagged: "<untagged>"
|
||||
yes: "yes"
|
||||
no: "no"
|
||||
on: "on"
|
||||
off: "off"
|
||||
|
||||
error:
|
||||
fail: "FAILED"
|
||||
@@ -73,9 +82,11 @@ hint:
|
||||
noContainers: "No containers yet — run something with Docker, then press r."
|
||||
miniNoContainers: "no containers yet — press 1"
|
||||
emptyItems: "no %s yet"
|
||||
tabsComing: "Detail tabs (Logs · Stats · Config) coming in Phase 2."
|
||||
tabComing: "This tab is coming (Phase 2)."
|
||||
logsWaiting: "waiting for logs…"
|
||||
logsEnd: "log stream ended"
|
||||
footerHome: "[1/enter] containers · [t] toast · [T] theme · [a] reduced-motion · [q] quit"
|
||||
footerContainers: "[1-4] sections · [j/k] select · [r] reload · [0/esc] home · [T] theme · [q] quit"
|
||||
footerContainers: "[1-4] sections · [j/k] select · [Tab] focus · [h/l] tab · [r] reload · [0] home · [T] theme · [q] quit"
|
||||
|
||||
footer:
|
||||
status: "%s animation %s fps · audio: %s"
|
||||
@@ -86,6 +97,7 @@ toast:
|
||||
dockerError: "✖ [ FAILED ] Docker"
|
||||
configError: "⚠ [ WARNING ] Config problem — using defaults"
|
||||
themeMode: "Theme mode: %s"
|
||||
timestamps: "Log timestamps: %s"
|
||||
|
||||
activity:
|
||||
coming: "Docker events listener coming (Phase 2)."
|
||||
|
||||
@@ -16,6 +16,13 @@ section:
|
||||
activity: "Aktivitas"
|
||||
detail: "Detail"
|
||||
|
||||
tab:
|
||||
logs: "Log"
|
||||
stats: "Statistik"
|
||||
config: "Konfigurasi"
|
||||
env: "Env"
|
||||
top: "Proses"
|
||||
|
||||
host:
|
||||
local: "lokal"
|
||||
|
||||
@@ -59,6 +66,8 @@ state:
|
||||
untagged: "<tanpa tag>"
|
||||
yes: "ya"
|
||||
no: "tidak"
|
||||
on: "nyala"
|
||||
off: "mati"
|
||||
|
||||
error:
|
||||
fail: "GAGAL"
|
||||
@@ -73,9 +82,11 @@ hint:
|
||||
noContainers: "Belum ada kontainer — jalankan sesuatu dengan Docker, lalu tekan r."
|
||||
miniNoContainers: "belum ada kontainer — tekan 1"
|
||||
emptyItems: "belum ada %s"
|
||||
tabsComing: "Tab detail (Log · Statistik · Konfigurasi) menyusul di Phase 2."
|
||||
tabComing: "Tab ini menyusul (Phase 2)."
|
||||
logsWaiting: "menunggu log…"
|
||||
logsEnd: "stream log berakhir"
|
||||
footerHome: "[1/enter] kontainer · [t] toast · [T] tema · [a] reduced-motion · [q] keluar"
|
||||
footerContainers: "[1-4] bagian · [j/k] pilih · [r] muat ulang · [0/esc] beranda · [T] tema · [q] keluar"
|
||||
footerContainers: "[1-4] bagian · [j/k] pilih · [Tab] fokus · [h/l] tab · [r] muat ulang · [0] beranda · [T] tema · [q] keluar"
|
||||
|
||||
footer:
|
||||
status: "%s animasi %s fps · audio: %s"
|
||||
@@ -86,6 +97,7 @@ toast:
|
||||
dockerError: "✖ [ GAGAL ] Docker"
|
||||
configError: "⚠ [ PERINGATAN ] Config bermasalah — memakai default"
|
||||
themeMode: "Mode tema: %s"
|
||||
timestamps: "Timestamp log: %s"
|
||||
|
||||
activity:
|
||||
coming: "Listener Docker events menyusul (Phase 2)."
|
||||
|
||||
Reference in new issue
Block a user