feat(events): listener Docker events (F-002) — auto-refresh daftar + panel Aktivitas beranda hidup
CI / test (push) Successful in 25s
CI / test (push) Successful in 25s
This commit is contained in:
1 parent
177e27910b
commit
f555f27467
8 files changed
+316
-32
No files matched your search
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user