feat(events): listener Docker events (F-002) — auto-refresh daftar + panel Aktivitas beranda hidup
CI / test (push) Successful in 25s

This commit is contained in:
oxpoe committed 2026-10-16 01:23:28 +07:00
1 parent 177e27910b
commit f555f27467
8 files changed
+316 -32

No files matched your search

+27
View File
@@ -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)
+75 -14
View File
@@ -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(),
+94
View File
@@ -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
}
+2 -10
View File
@@ -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 {
+45 -2
View File
@@ -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")
}
}
}
+71
View File
@@ -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
}
+1 -3
View File
@@ -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"
+1 -3
View File
@@ -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"