From de8bbbd354cb9b03f095e300f2469f11eb7cb3b6 Mon Sep 17 00:00:00 2001 From: oxpoe <324556608+oxpoe@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:32:14 +0700 Subject: [PATCH] =?UTF-8?q?feat(logs):=20log=20viewer=20streaming=20real-t?= =?UTF-8?q?ime=20(F-004)=20=E2=80=94=20tab=20Log,=20follow+scroll,=20toggl?= =?UTF-8?q?e=20timestamp,=20focus=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/e2e_test.go | 44 ++++- internal/app/app.go | 307 +++++++++++++++++++++++++++++++--- internal/app/logstream.go | 132 +++++++++++++++ internal/config/config.go | 10 ++ internal/docker/client.go | 27 +++ internal/i18n/locales/en.yaml | 16 +- internal/i18n/locales/id.yaml | 16 +- 7 files changed, 522 insertions(+), 30 deletions(-) create mode 100644 internal/app/logstream.go diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index b507413..bbc40ce 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -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 { diff --git a/internal/app/app.go b/internal/app/app.go index 796eda0..6061050 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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,15 +439,37 @@ 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): - // 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) + 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) + 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) + }) } diff --git a/internal/app/logstream.go b/internal/app/logstream.go new file mode 100644 index 0000000..156c2b0 --- /dev/null +++ b/internal/app/logstream.go @@ -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} + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 47416ac..c7d20b3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/docker/client.go b/internal/docker/client.go index 93e8150..a70bcaa 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -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 +} diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index bf4f4f7..182bdde 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -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: "" 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)." diff --git a/internal/i18n/locales/id.yaml b/internal/i18n/locales/id.yaml index 7cb8d61..cd9ac47 100644 --- a/internal/i18n/locales/id.yaml +++ b/internal/i18n/locales/id.yaml @@ -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: "" 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)."