diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index f443b9b..5e9f13a 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -9,6 +9,7 @@ package e2e import ( "context" + "fmt" "os" "os/exec" "path/filepath" @@ -424,6 +425,32 @@ func TestConfigEnvTabs(t *testing.T) { } } +// TestEventsActivity — event Docker nyata (volume create) muncul di panel +// Aktivitas beranda dalam beberapa detik. Butuh docker + CLI. +func TestEventsActivity(t *testing.T) { + if _, err := os.Stat("/var/run/docker.sock"); err != nil { + t.Skip("docker socket tidak ada") + } + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI tidak ada") + } + name := fmt.Sprintf("tiae2e%d", os.Getpid()) + + u := startTUI(t) + u.waitContains("beranda", 5*time.Second) + + if out, err := exec.Command("docker", "volume", "create", name).CombinedOutput(); err != nil { + t.Skipf("volume create gagal: %v (%s)", err, out) + } + defer func() { _ = exec.Command("docker", "volume", "rm", name).Run() }() + + u.waitContains(name, 10*time.Second) // baris aktivitas memuat nama volume + u.send("q") + if code := u.waitExit(5 * time.Second); code != 0 { + t.Fatalf("exit code %d, mau 0", code) + } +} + func TestThemeCycle(t *testing.T) { u := startTUI(t) u.waitContains("beranda", 5*time.Second) diff --git a/internal/app/app.go b/internal/app/app.go index b2e5837..aec0bdb 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -128,6 +128,15 @@ type Model struct { detailScroll int + eventsCancel context.CancelFunc + eventsCh <-chan docker.Event + eventsID int64 + activity []docker.Event + refreshGen int64 + selID string + + initCmds []tea.Cmd + hostname string sys sysinfo.Stats @@ -186,19 +195,23 @@ func New() Model { m.engineErr = err m.loading = false } + + // Perintah awal dikumpulkan di sini: Init() adalah value receiver sehingga + // tidak bisa memutasi model yang dipegang program (id stream, ctx cancel). + m.initCmds = []tea.Cmd{tea.RequestBackgroundColor} // untuk mode tema "auto" + if m.animating { + m.initCmds = append(m.initCmds, animation.Next(m.tickID)) + } + if m.cl != nil { + m.initCmds = append(m.initCmds, loadAll(m.cl)...) + if cmd := m.startEvents(); cmd != nil { + m.initCmds = append(m.initCmds, cmd) + } + } return m } -func (m Model) Init() tea.Cmd { - cmds := []tea.Cmd{tea.RequestBackgroundColor} // untuk mode tema "auto" - if m.animating { - cmds = append(cmds, animation.Next(m.tickID)) - } - if m.cl != nil { - cmds = append(cmds, loadAll(m.cl)...) - } - return tea.Batch(cmds...) -} +func (m Model) Init() tea.Cmd { return tea.Batch(m.initCmds...) } func loadAll(cl *docker.Client) []tea.Cmd { return []tea.Cmd{ @@ -277,10 +290,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case containersMsg: mm, cmd := m.applyList(msg.err, func(mm *Model) { mm.containers = msg.items }) - if mm2, ok := mm.(Model); ok { + mm2, ok := mm.(Model) + if !ok { + return mm, cmd + } + // Auto-refresh dari events: restart stream detail HANYA bila kontainer + // terpilih berubah — kalau tidak, log yang mengalir jangan diputus. + newSel := "" + if mm2.section == sectionContainers && mm2.sectionLen() > 0 { + newSel = mm2.containers[min(mm2.cur(), mm2.sectionLen()-1)].ID + } + if newSel != m.selID { return mm2, tea.Batch(cmd, mm2.restartData()) } - return mm, cmd + return mm2, cmd case imagesMsg: return m.applyList(msg.err, func(mm *Model) { mm.images = msg.items }) @@ -370,6 +393,36 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.procsTitle, m.procsRows = msg.titles, msg.rows return m, nil + case eventMsg: + if msg.id != m.eventsID { + return m, nil + } + m.activity = append(m.activity, msg.ev) + if over := len(m.activity) - activityMax; over > 0 { + m.activity = m.activity[over:] + } + m.refreshGen++ // debounce: segarkan daftar setelah burst event reda + return m, tea.Batch(waitEvent(msg.id, m.eventsCh), refreshAfter(m.refreshGen)) + + case eventsClosedMsg: + if msg.id != m.eventsID { + return m, nil + } + m.eventsCh = nil + return m, eventsRetry(m.eventsID) // daemon putus → sambung lagi nanti + + case eventsRetryMsg: + if msg.id != m.eventsID { + return m, nil + } + return m, m.startEvents() + + case refreshTickMsg: + if msg.gen != m.refreshGen || m.cl == nil { + return m, nil // event lebih baru menunggu tick berikutnya + } + return m, tea.Batch(loadAll(m.cl)...) + case tea.BackgroundColorMsg: // Mode tema "auto": Mocha vs Latte sesuai background terminal. theme.SetDetectedDark(msg.IsDark()) @@ -382,6 +435,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "ctrl+c": m.stopLogs() + if m.eventsCancel != nil { + m.eventsCancel() + } m.sound.Play(audio.ModalClose) return m, tea.Quit case "esc": @@ -423,13 +479,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.tickID = animation.NewID() return m, animation.Next(m.tickID) } - case "r": // muat ulang semua data docker + case "r": // muat ulang semua data docker + stream event if m.cl == nil { break } m.loading = true m.sound.Play(audio.Click) - return m, tea.Batch(append(loadAll(m.cl), m.restartData())...) + cmds := append(loadAll(m.cl), m.restartData()) + cmds = append(cmds, m.startEvents()) + return m, tea.Batch(cmds...) case "T": // tombol cepat tema (PRD §5.4): auto → dark → light → terminal cur := theme.Current() next := theme.CycleMode(cur.Mode) @@ -652,6 +710,9 @@ func (m *Model) restartStats() tea.Cmd { // 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 { + if m.section == sectionContainers && m.sectionLen() > 0 { + m.selID = m.containers[min(m.cur(), m.sectionLen()-1)].ID + } return tea.Batch( m.restartLogs(), m.restartStats(), m.restartInspect(), m.restartEnv(), m.restartProcs(), diff --git a/internal/app/events.go b/internal/app/events.go new file mode 100644 index 0000000..492eb8f --- /dev/null +++ b/internal/app/events.go @@ -0,0 +1,94 @@ +package app + +import ( + "context" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker" + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n" +) + +// eventMsg — satu event Docker dari stream (volume rendah; 1 event = 1 render). +type eventMsg struct { + id int64 + ev docker.Event +} +type eventsClosedMsg struct{ id int64 } +type eventsRetryMsg struct{ id int64 } +type refreshTickMsg struct{ gen int64 } + +const ( + activityMax = 20 + refreshDebounce = 500 * time.Millisecond + eventsRetryDelay = 5 * time.Second +) + +// startEvents — (re)buka stream event daemon. Dipanggil dari New (init), `r`, +// dan retry otomatis saat koneksi putus. +func (m *Model) startEvents() tea.Cmd { + if m.eventsCancel != nil { + m.eventsCancel() + m.eventsCancel = nil + } + m.eventsCh = nil + if m.cl == nil { + return nil + } + m.eventsID++ + ctx, cancel := context.WithCancel(context.Background()) + m.eventsCancel = cancel + m.eventsCh = m.cl.StreamEvents(ctx) + return waitEvent(m.eventsID, m.eventsCh) +} + +func waitEvent(id int64, ch <-chan docker.Event) tea.Cmd { + return func() tea.Msg { + ev, ok := <-ch + if !ok { + return eventsClosedMsg{id: id} + } + return eventMsg{id: id, ev: ev} + } +} + +func refreshAfter(gen int64) tea.Cmd { + return tea.Tick(refreshDebounce, func(time.Time) tea.Msg { return refreshTickMsg{gen: gen} }) +} + +func eventsRetry(id int64) tea.Cmd { + return tea.Tick(eventsRetryDelay, func(time.Time) tea.Msg { return eventsRetryMsg{id: id} }) +} + +// evTypeKey — jenis event → kunci katalog bagian (untuk label terjemahan). +func evTypeKey(t string) string { + switch t { + case "image": + return "images" + case "volume": + return "volumes" + case "network": + return "networks" + default: + return "containers" + } +} + +// activityLines — event terbaru di atas (untuk panel Aktivitas di beranda). +func (m Model) activityLines(w, h int) []string { + if len(m.activity) == 0 { + return []string{dim(i18n.T("activity.waiting"))} + } + out := make([]string, 0, h) + for i := len(m.activity) - 1; i >= 0 && len(out) < h; i-- { + ev := m.activity[i] + label := strings.ToLower(i18n.T("section." + evTypeKey(ev.Type))) + out = append(out, dim(ev.At.Format("15:04:05"))+" "+ + dim("["+label+"]")+" "+ + text("'"+truncate(ev.Actor, 20)+"'")+" "+ + dim(truncate(ev.Action, 24))) + } + return out +} diff --git a/internal/app/home.go b/internal/app/home.go index 3798b7e..f02de77 100644 --- a/internal/app/home.go +++ b/internal/app/home.go @@ -42,7 +42,8 @@ func (m Model) homeBody() string { row2 := lipgloss.JoinHorizontal(lipgloss.Top, panel(i18n.T("section.containers"), i18n.Tf("fmt.countTotal", len(m.containers)), m.miniContainerLines(row2H-2), k1, row2H, false), - panel(i18n.T("section.activity"), i18n.Tf("fmt.phase", "2"), activityLines(), k2, row2H, false), + panel(i18n.T("section.activity"), fmt.Sprintf("(%d)", len(m.activity)), + m.activityLines(k2-4, row2H-2), k2, row2H, false), ) return row1 + "\n" + row2 @@ -125,15 +126,6 @@ func (m Model) miniContainerLines(maxLines int) []string { return lines } -func activityLines() []string { - return []string{ - dim(i18n.T("activity.coming")), - "", - dim(i18n.T("activity.willShow")), - dim(i18n.T("activity.examples")), - } -} - // --- small formatting helpers --- func humanKB(kb uint64) string { diff --git a/internal/docker/client_test.go b/internal/docker/client_test.go index 6bbc27d..145e6bb 100644 --- a/internal/docker/client_test.go +++ b/internal/docker/client_test.go @@ -2,7 +2,9 @@ package docker import ( "context" + "fmt" "os" + "os/exec" "testing" "time" ) @@ -61,8 +63,6 @@ func TestIntegrationInfo(t *testing.T) { t.Fatalf("ListNetworks: %v", err) } t.Logf("images=%d volumes=%d networks=%d", len(images), len(volumes), len(networks)) - - // Inspect & top: pakai kontainer yang benar-benar berjalan. for _, ct := range items { if ct.State != "running" { continue @@ -81,3 +81,46 @@ func TestIntegrationInfo(t *testing.T) { break } } + +// TestIntegrationEvents — stream event daemon: buat volume unik via CLI, +// tunggu event `create`-nya muncul. Butuh docker CLI + socket. +func TestIntegrationEvents(t *testing.T) { + if _, err := os.Stat("/var/run/docker.sock"); err != nil { + t.Skip("docker socket tidak ada") + } + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI tidak ada") + } + c, err := New() + if err != nil { + t.Fatalf("New: %v", err) + } + defer c.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + ch := c.StreamEvents(ctx) + time.Sleep(400 * time.Millisecond) // beri waktu stream tersambung + + name := fmt.Sprintf("tiae2e%d", os.Getpid()) + if out, err := exec.Command("docker", "volume", "create", name).CombinedOutput(); err != nil { + t.Skipf("docker volume create gagal: %v (%s)", err, out) + } + defer exec.Command("docker", "volume", "rm", name).Run() //nolint:errcheck + + deadline := time.After(8 * time.Second) + for { + select { + case ev, ok := <-ch: + if !ok { + t.Fatal("stream event tertutup lebih awal") + } + if ev.Type == "volume" && ev.Actor == name && ev.Action == "create" { + t.Logf("event diterima: %s %s '%s'", ev.At.Format("15:04:05"), ev.Action, ev.Actor) + return + } + case <-deadline: + t.Fatal("event volume create tidak diterima dalam 8 dtk") + } + } +} diff --git a/internal/docker/events.go b/internal/docker/events.go new file mode 100644 index 0000000..aa71fdf --- /dev/null +++ b/internal/docker/events.go @@ -0,0 +1,71 @@ +package docker + +import ( + "context" + "time" + + "github.com/docker/docker/api/types/events" + "github.com/docker/docker/api/types/filters" +) + +// Event — ringkasan event Docker untuk daftar auto-refresh & feed aktivitas. +type Event struct { + At time.Time + Type string // container | image | volume | network + Action string // start | stop | die | destroy | pull | … + Actor string // nama (atau ID pendek bila nama tidak ada) +} + +// activityTypes — jenis event yang relevan untuk tia (buang noise daemon/plugin). +var activityTypes = []string{"container", "image", "volume", "network"} + +// StreamEvents membuka stream event (follow) dari daemon; channel ditutup +// saat ctx dibatalkan atau koneksi putus. Buffer 64: saat konsumen lambat, +// event di-drop (UI tetap responsif; daftar disinkronkan ulang oleh refresh). +func (c *Client) StreamEvents(ctx context.Context) <-chan Event { + args := filters.NewArgs() + for _, t := range activityTypes { + args.Add("type", t) + } + msgCh, errCh := c.cli.Events(ctx, events.ListOptions{Filters: args}) + + out := make(chan Event, 64) + go func() { + defer close(out) + for { + select { + case <-ctx.Done(): + return + case m, ok := <-msgCh: + if !ok { + return + } + actor := m.Actor.Attributes["name"] + if actor == "" { + // Volume: Actor.ID = nama volume penuh — jangan dipotong. + // Image: Actor.ID = sha256 (64 hex) → tampilkan 12 char. + actor = m.Actor.ID + if len(actor) == 64 { + actor = trimID(actor) + } else if len(actor) > 40 { + actor = actor[:40] + } + } + at := time.Unix(0, m.TimeNano) + if m.TimeNano == 0 { + at = time.Unix(m.Time, 0) + } + select { + case out <- Event{At: at, Type: string(m.Type), Action: string(m.Action), Actor: actor}: + default: + } + case <-errCh: + if ctx.Err() != nil { + return + } + return // koneksi putus → channel ditutup; app akan mencoba lagi + } + } + }() + return out +} diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index f7c3a78..1f5a00d 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -108,9 +108,7 @@ toast: timestamps: "Log timestamps: %s" activity: - coming: "Docker events listener coming (Phase 2)." - willShow: "This panel will show the 5 most recent events:" - examples: "container start/stop, image pulls, latest error." + waiting: "waiting for Docker events…" fmt: countTotal: "%d total" diff --git a/internal/i18n/locales/id.yaml b/internal/i18n/locales/id.yaml index d1567c8..52f9557 100644 --- a/internal/i18n/locales/id.yaml +++ b/internal/i18n/locales/id.yaml @@ -108,9 +108,7 @@ toast: timestamps: "Timestamp log: %s" activity: - coming: "Listener Docker events menyusul (Phase 2)." - willShow: "Panel ini akan menampilkan 5 aktivitas terbaru:" - examples: "kontainer start/stop, image di-pull, error terakhir." + waiting: "menunggu event Docker…" fmt: countTotal: "%d total"