diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index cd3e2eb..05e23e3 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -300,7 +300,7 @@ func TestResourceSections(t *testing.T) { u := startTUI(t) u.waitContains("beranda", 5*time.Second) for _, want := range [][2]string{ - {"2", "Image"}, {"3", "Volume"}, {"4", "Jaringan"}, {"1", "Kontainer"}, + {"1", "Stack"}, {"3", "Image"}, {"4", "Volume"}, {"5", "Jaringan"}, {"2", "Kontainer"}, } { u.send(want[0]) u.waitContains(want[1], 3*time.Second) @@ -313,6 +313,37 @@ func TestResourceSections(t *testing.T) { } } +// TestStacksSection — stack dari disk tampil di seksi Stacks, tanpa docker: +// stacks_dir diarahkan ke folder sementara berisi satu stack. +func TestStacksSection(t *testing.T) { + xdg := t.TempDir() + stacksDir := filepath.Join(xdg, "stacks") + if err := os.MkdirAll(filepath.Join(stacksDir, "demo"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stacksDir, "demo", "compose.yaml"), + []byte("services:\n web:\n image: nginx\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(xdg, "tia"), 0o755); err != nil { + t.Fatal(err) + } + cfg := "version: 1\nstacks_dir: " + stacksDir + "\nui:\n language: id\n" + if err := os.WriteFile(filepath.Join(xdg, "tia", "config.yaml"), []byte(cfg), 0o644); err != nil { + t.Fatal(err) + } + + u := startTUIEnv(t, "XDG_CONFIG_HOME="+xdg) + u.waitContains("beranda", 5*time.Second) + u.send("1") + u.waitContains("demo", 5*time.Second) // item stack dari disk + u.waitContains("terdeteksi", 3*time.Second) // status disk (compose tidak match) + u.send("q") + if code := u.waitExit(5 * time.Second); code != 0 { + t.Fatalf("exit code %d, mau 0", code) + } +} + // TestStatsTab — tab Statistik: bar CPU/RAM, riwayat sparkline, NET/Disk, // PIDs muncul dari data nyata. Butuh docker + minimal 1 kontainer. func TestStatsTab(t *testing.T) { @@ -337,7 +368,7 @@ func TestStatsTab(t *testing.T) { u := startTUI(t) u.waitContains("beranda", 5*time.Second) - u.send("1") + u.send("2") u.waitContains(name, 5*time.Second) u.send("l") // tab Statistik (fokus pindah ke konten) u.waitContains("Riwayat CPU", 6*time.Second) @@ -372,7 +403,7 @@ func TestProcessTab(t *testing.T) { u := startTUI(t) u.waitContains("beranda", 5*time.Second) - u.send("1") + u.send("2") u.waitContains(name, 5*time.Second) u.send("h") // dari Log membungkus ke tab Proses (paling akhir) u.waitContains("PID", 6*time.Second) @@ -410,7 +441,7 @@ func TestConfigEnvTabs(t *testing.T) { u := startTUI(t) u.waitContains("beranda", 5*time.Second) - u.send("1") + u.send("2") u.waitContains(name, 5*time.Second) u.send("l") // Stats u.send("l") // Konfigurasi @@ -504,8 +535,8 @@ func TestMouseTabBar(t *testing.T) { u := startTUI(t) u.waitContains("beranda", 5*time.Second) - u.send("1") - u.waitContains("[1-4]", 3*time.Second) // footer workspace = sudah pindah view + u.send("2") + u.waitContains("[1-5]", 3*time.Second) // footer workspace = sudah pindah view u.waitContains(name, 5*time.Second) if !u.clickText("Statistik") { t.Fatal("tab Statistik tidak ditemukan") @@ -549,7 +580,7 @@ func TestMouseHomeContainer(t *testing.T) { if !u.clickText(name) { t.Fatal("baris kontainer tidak ditemukan") } - u.waitContains("[1-4]", 3*time.Second) // footer workspace = bukti pindah view + u.waitContains("[1-5]", 3*time.Second) // footer workspace = bukti pindah view u.send("q") if code := u.waitExit(5 * time.Second); code != 0 { t.Fatalf("exit code %d, mau 0", code) @@ -593,6 +624,8 @@ func TestEnglishLanguage(t *testing.T) { u.waitContains("· home", 5*time.Second) u.waitContains("Clock", 5*time.Second) u.send("1") + u.waitContains("· stacks", 3*time.Second) + u.send("2") u.waitContains("· containers", 3*time.Second) u.send("q") if code := u.waitExit(5 * time.Second); code != 0 { @@ -606,6 +639,8 @@ func TestDashboardNavigation(t *testing.T) { u.waitContains("Jam", 5*time.Second) // panel jam blok u.waitContains("Docker", 5*time.Second) u.send("1") + u.waitContains("Stack", 3*time.Second) + u.send("2") u.waitContains("Kontainer", 3*time.Second) u.send("0") u.waitContains("beranda", 3*time.Second) @@ -618,7 +653,7 @@ func TestDashboardNavigation(t *testing.T) { func TestMouseAndNavigateKeys(t *testing.T) { u := startTUI(t) u.waitContains("beranda", 5*time.Second) - u.send("1") // masuk view kontainer + u.send("2") // masuk view kontainer u.waitContains("Kontainer", 3*time.Second) u.send("j") u.send("k") @@ -657,7 +692,7 @@ func TestClickSelectsContainer(t *testing.T) { u := startTUI(t) u.waitContains("beranda", 5*time.Second) - u.send("1") // masuk view kontainer + u.send("2") // masuk view kontainer u.waitContains(short(items[0].Name), 5*time.Second) u.waitHighlighted(short(items[0].Name), 3*time.Second) // seleksi awal (kursor 0) @@ -695,7 +730,7 @@ func TestLogTab(t *testing.T) { u := startTUI(t) u.waitContains("beranda", 5*time.Second) - u.send("1") + u.send("2") u.waitContains(name, 5*time.Second) u.waitContains("Statistik", 3*time.Second) // baris tab diff --git a/internal/app/app.go b/internal/app/app.go index bf7dfab..4d7f04a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -16,6 +16,7 @@ import ( "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/config" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n" + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/stack" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/sysinfo" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/animation" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components" @@ -46,15 +47,17 @@ const ( tabCount ) -// section — bagian resource di workspace (PRD F-001: 1-4 jump to section). +// section — bagian resource di workspace (urutan PRD F-001: +// Stacks · Containers · Images · Volumes · Networks). type section int const ( - sectionContainers section = iota + sectionStacks section = iota + sectionContainers sectionImages sectionVolumes sectionNetworks - sectionCount = 4 + sectionCount = 5 ) // statsHistory — jumlah sampel riwayat (60 ≈ 1 menit @1 dtk, PRD F-005). @@ -140,8 +143,14 @@ type Model struct { hostname string sys sysinfo.Stats - cl *docker.Client - engine docker.Info + cl *docker.Client + compose *docker.ComposeClient + engine docker.Info + + stacksDir string + stacks []stack.Stack + stacksID int64 + stacksErr error containers []docker.Container images []docker.Image @@ -176,12 +185,14 @@ func New() Model { animating: cfg.UI.AnimationEnabled, toast: components.NewToast(), view: viewHome, - section: sectionContainers, + section: sectionStacks, focus: focusSidebar, tab: tabLogs, hostname: host, sys: sysinfo.Read(), cl: cl, + compose: docker.NewCompose(cfg.Docker.ComposeCommand), + stacksDir: config.ExpandPath(cfg.StacksDir), loading: true, tailLines: cfg.UI.LogTailLines, @@ -208,6 +219,10 @@ func New() Model { m.initCmds = append(m.initCmds, cmd) } } + if m.compose != nil { + m.stacksID++ + m.initCmds = append(m.initCmds, fetchStacks(m.stacksID, m.compose, m.stacksDir)) + } return m } @@ -393,6 +408,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.procsTitle, m.procsRows = msg.titles, msg.rows return m, nil + case stacksMsg: + if msg.id != m.stacksID { + return m, nil + } + m.stacksErr = msg.err + if msg.err == nil { + m.stacks = msg.items + } + if n := len(m.stacks); m.cursors[sectionStacks] >= n { + m.cursors[sectionStacks] = max(0, n-1) + } + return m, nil + case eventMsg: if msg.id != m.eventsID { return m, nil @@ -421,7 +449,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.gen != m.refreshGen || m.cl == nil { return m, nil // event lebih baru menunggu tick berikutnya } - return m, tea.Batch(loadAll(m.cl)...) + cmds := loadAll(m.cl) + if m.compose != nil { + m.stacksID++ + cmds = append(cmds, fetchStacks(m.stacksID, m.compose, m.stacksDir)) + } + return m, tea.Batch(cmds...) case tea.BackgroundColorMsg: // Mode tema "auto": Mocha vs Latte sesuai background terminal. @@ -608,6 +641,8 @@ func (m *Model) restartProcs() tea.Cmd { func (m Model) lenOf(s section) int { switch s { + case sectionStacks: + return len(m.stacks) case sectionImages: return len(m.images) case sectionVolumes: @@ -627,6 +662,8 @@ func (m *Model) setCur(v int) { m.cursors[m.section] = v } func (m Model) sectionName() string { switch m.section { + case sectionStacks: + return i18n.T("section.stacks") case sectionImages: return i18n.T("section.images") case sectionVolumes: @@ -812,7 +849,7 @@ func (m Model) View() tea.View { header += dim(" · " + i18n.T("nav.home")) body = m.homeBody() default: - header += dim(" · " + i18n.T("nav.containers")) + header += dim(" · " + strings.ToLower(m.sectionName())) body = lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView()) } footer = m.footerLine() @@ -998,6 +1035,10 @@ func (m Model) sectionLine(i, innerW int) string { var line string switch m.section { + case sectionStacks: + s := m.stacks[i] + glyph, color := stackStyle(s.Status) + line = paintRow(sel, color, glyph) + paintRow(sel, th.Text, " "+truncate(s.Name, innerW-4)) case sectionImages: im := m.images[i] glyph, color := "○", th.Overlay @@ -1065,6 +1106,22 @@ func (m Model) contentView() string { idx := min(m.cur(), m.sectionLen()-1) + // Stack: ringkasan + berkas (aksi menyusul). + if m.section == sectionStacks { + if m.stacksErr != nil { + return panel(m.sectionName(), "", []string{red(truncate(m.stacksErr.Error(), w-6))}, w, h, focused) + } + s := m.stacks[idx] + glyph, color := stackStyle(s.Status) + return panel(s.Name, "", []string{ + field(i18n.T("field.dir")) + dim(truncate(s.Dir, w-12)), + field(i18n.T("field.composeFile")) + dim(truncate(s.ComposeFile, w-18)), + field(i18n.T("field.status")) + styled(color, glyph+" "+stackStatusText(s.Status)), + "", + dim(i18n.T("hint.stackLater")), + }, w, h, focused) + } + // Kontainer: tab detail (Log berfungsi; sisanya menyusul Phase 2). if m.section == sectionContainers { c := m.containers[idx] @@ -1386,18 +1443,22 @@ func (m *Model) handleKey(key string) tea.Cmd { m.stopLogs() m.sound.Play(audio.Click) case "1", "enter": - m.jumpSection(sectionContainers) + m.jumpSection(sectionStacks) m.sound.Play(audio.Click) return m.restartData() case "2": - m.jumpSection(sectionImages) + m.jumpSection(sectionContainers) m.sound.Play(audio.Click) return m.restartData() case "3": - m.jumpSection(sectionVolumes) + m.jumpSection(sectionImages) m.sound.Play(audio.Click) return m.restartData() case "4": + m.jumpSection(sectionVolumes) + m.sound.Play(audio.Click) + return m.restartData() + case "5": m.jumpSection(sectionNetworks) m.sound.Play(audio.Click) return m.restartData() @@ -1415,6 +1476,8 @@ func (m *Model) handleKey(key string) tea.Cmd { m.sound.Play(audio.Click) cmds := append(loadAll(m.cl), m.restartData()) cmds = append(cmds, m.startEvents()) + m.stacksID++ + cmds = append(cmds, fetchStacks(m.stacksID, m.compose, m.stacksDir)) return tea.Batch(cmds...) case "T": // tombol cepat tema (PRD §5.4): auto → dark → light → terminal cur := theme.Current() diff --git a/internal/app/stacks.go b/internal/app/stacks.go new file mode 100644 index 0000000..cb5e8e9 --- /dev/null +++ b/internal/app/stacks.go @@ -0,0 +1,66 @@ +package app + +import ( + "context" + "time" + + tea "charm.land/bubbletea/v2" + + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker" + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n" + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/stack" + "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/theme" +) + +// stacksMsg — hasil scan stack dari disk + rekonsiliasi compose ls. +type stacksMsg struct { + id int64 + items []stack.Stack + err error +} + +// fetchStacks — scan folder stack (synch cepat) lalu overlay status dari +// `compose ls`. Compose gagal → status disk tetap dipakai (degrade anggun). +func fetchStacks(id int64, cm *docker.ComposeClient, dir string) tea.Cmd { + return func() tea.Msg { + items, err := stack.ScanFS(dir) + if err != nil { + return stacksMsg{id: id, err: err} + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if projects, err := cm.List(ctx); err == nil { + items = stack.Reconcile(items, projects) + } + return stacksMsg{id: id, items: items} + } +} + +// stackStyle — glyph + warna per status stack. +func stackStyle(st stack.Status) (string, theme.Color) { + th := theme.Current() + switch st { + case stack.StatusRunning: + return "●", th.Success + case stack.StatusExited: + return "○", th.Danger + case stack.StatusCreatedFile, stack.StatusCreatedStack: + return "◌", th.Info + default: + return "○", th.Overlay + } +} + +// stackStatusText — label terjemahan status stack. +func stackStatusText(st stack.Status) string { + switch st { + case stack.StatusRunning: + return i18n.T("state.running") + case stack.StatusExited: + return i18n.T("state.exited") + case stack.StatusCreatedStack: + return i18n.T("state.created") + default: + return i18n.T("state.detected") + } +} diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index ff8748f..96f3c19 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -4,11 +4,11 @@ nav: home: "home" - containers: "containers" section: clock: "Clock" host: "Host" + stacks: "Stacks" containers: "Containers" images: "Images" volumes: "Volumes" @@ -57,6 +57,8 @@ field: scope: "Scope" subnet: "Subnet" inUse: "In use" + dir: "Dir" + composeFile: "Compose" state: loading: "loading…" @@ -73,6 +75,7 @@ state: no: "no" on: "on" off: "off" + detected: "detected" error: fail: "FAILED" @@ -93,8 +96,9 @@ hint: statsWaiting: "fetching stats…" inspectWaiting: "fetching inspect…" procsWaiting: "fetching processes…" - footerHome: "[1/enter] containers · [t] toast · [T] theme · [a] reduced-motion · [q] quit" - footerContainers: "[1-4] sections · [j/k] select · [Tab] focus · [h/l] tab · [r] reload · [0] home · [T] theme · [q] quit" + stackLater: "Stack actions (start · stop · pull) coming next." + footerHome: "[1/enter] stacks · [t] toast · [T] theme · [a] reduced-motion · [q] quit" + footerContainers: "[1-5] 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" diff --git a/internal/i18n/locales/id.yaml b/internal/i18n/locales/id.yaml index e16ef3a..2ccf631 100644 --- a/internal/i18n/locales/id.yaml +++ b/internal/i18n/locales/id.yaml @@ -4,11 +4,11 @@ nav: home: "beranda" - containers: "kontainer" section: clock: "Jam" host: "Host" + stacks: "Stack" containers: "Kontainer" images: "Image" volumes: "Volume" @@ -57,6 +57,8 @@ field: scope: "Scope" subnet: "Subnet" inUse: "Dipakai" + dir: "Dir" + composeFile: "Compose" state: loading: "memuat…" @@ -73,6 +75,7 @@ state: no: "tidak" on: "nyala" off: "mati" + detected: "terdeteksi" error: fail: "GAGAL" @@ -93,8 +96,9 @@ hint: statsWaiting: "mengambil statistik…" inspectWaiting: "mengambil inspect…" procsWaiting: "mengambil daftar proses…" - footerHome: "[1/enter] kontainer · [t] toast · [T] tema · [a] reduced-motion · [q] keluar" - footerContainers: "[1-4] bagian · [j/k] pilih · [Tab] fokus · [h/l] tab · [r] muat ulang · [0] beranda · [T] tema · [q] keluar" + stackLater: "Aksi stack (jalankan · henti · pull) menyusul." + footerHome: "[1/enter] stack · [t] toast · [T] tema · [a] reduced-motion · [q] keluar" + footerContainers: "[1-5] 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"