feat(logs): log viewer streaming real-time (F-004) — tab Log, follow+scroll, toggle timestamp, focus panel
CI / test (push) Successful in 38s

This commit is contained in:
oxpoe committed 2026-10-15 14:32:14 +07:00
1 parent 8f7b6c8ab6
commit de8bbbd354
7 files changed
+522 -30

No files matched your search

+43 -1
View File
@@ -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 {
+282 -25
View File
@@ -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)
})
}
+132
View File
@@ -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}
}
}
+10
View File
@@ -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
}
+27
View File
@@ -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
}
+14 -2
View File
@@ -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)."
+14 -2
View File
@@ -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)."