feat(stacks): seksi Stacks di workspace (urutan PRD 1-5) — scan disk + rekonsiliasi compose ls
CI / test (push) Successful in 24s

This commit is contained in:
oxpoe committed 2026-10-16 01:50:16 +07:00
1 parent 8a3b92b155
commit f57d1cc29c
5 files changed
+199 -27

No files matched your search

+45 -10
View File
@@ -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
+74 -11
View File
@@ -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()
+66
View File
@@ -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")
}
}
+7 -3
View File
@@ -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"
+7 -3
View File
@@ -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"