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 := startTUI(t)
u.waitContains("beranda", 5*time.Second) u.waitContains("beranda", 5*time.Second)
for _, want := range [][2]string{ 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.send(want[0])
u.waitContains(want[1], 3*time.Second) 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, // TestStatsTab — tab Statistik: bar CPU/RAM, riwayat sparkline, NET/Disk,
// PIDs muncul dari data nyata. Butuh docker + minimal 1 kontainer. // PIDs muncul dari data nyata. Butuh docker + minimal 1 kontainer.
func TestStatsTab(t *testing.T) { func TestStatsTab(t *testing.T) {
@@ -337,7 +368,7 @@ func TestStatsTab(t *testing.T) {
u := startTUI(t) u := startTUI(t)
u.waitContains("beranda", 5*time.Second) u.waitContains("beranda", 5*time.Second)
u.send("1") u.send("2")
u.waitContains(name, 5*time.Second) u.waitContains(name, 5*time.Second)
u.send("l") // tab Statistik (fokus pindah ke konten) u.send("l") // tab Statistik (fokus pindah ke konten)
u.waitContains("Riwayat CPU", 6*time.Second) u.waitContains("Riwayat CPU", 6*time.Second)
@@ -372,7 +403,7 @@ func TestProcessTab(t *testing.T) {
u := startTUI(t) u := startTUI(t)
u.waitContains("beranda", 5*time.Second) u.waitContains("beranda", 5*time.Second)
u.send("1") u.send("2")
u.waitContains(name, 5*time.Second) u.waitContains(name, 5*time.Second)
u.send("h") // dari Log membungkus ke tab Proses (paling akhir) u.send("h") // dari Log membungkus ke tab Proses (paling akhir)
u.waitContains("PID", 6*time.Second) u.waitContains("PID", 6*time.Second)
@@ -410,7 +441,7 @@ func TestConfigEnvTabs(t *testing.T) {
u := startTUI(t) u := startTUI(t)
u.waitContains("beranda", 5*time.Second) u.waitContains("beranda", 5*time.Second)
u.send("1") u.send("2")
u.waitContains(name, 5*time.Second) u.waitContains(name, 5*time.Second)
u.send("l") // Stats u.send("l") // Stats
u.send("l") // Konfigurasi u.send("l") // Konfigurasi
@@ -504,8 +535,8 @@ func TestMouseTabBar(t *testing.T) {
u := startTUI(t) u := startTUI(t)
u.waitContains("beranda", 5*time.Second) u.waitContains("beranda", 5*time.Second)
u.send("1") u.send("2")
u.waitContains("[1-4]", 3*time.Second) // footer workspace = sudah pindah view u.waitContains("[1-5]", 3*time.Second) // footer workspace = sudah pindah view
u.waitContains(name, 5*time.Second) u.waitContains(name, 5*time.Second)
if !u.clickText("Statistik") { if !u.clickText("Statistik") {
t.Fatal("tab Statistik tidak ditemukan") t.Fatal("tab Statistik tidak ditemukan")
@@ -549,7 +580,7 @@ func TestMouseHomeContainer(t *testing.T) {
if !u.clickText(name) { if !u.clickText(name) {
t.Fatal("baris kontainer tidak ditemukan") 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") u.send("q")
if code := u.waitExit(5 * time.Second); code != 0 { if code := u.waitExit(5 * time.Second); code != 0 {
t.Fatalf("exit code %d, mau 0", code) 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("· home", 5*time.Second)
u.waitContains("Clock", 5*time.Second) u.waitContains("Clock", 5*time.Second)
u.send("1") u.send("1")
u.waitContains("· stacks", 3*time.Second)
u.send("2")
u.waitContains("· containers", 3*time.Second) u.waitContains("· containers", 3*time.Second)
u.send("q") u.send("q")
if code := u.waitExit(5 * time.Second); code != 0 { 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("Jam", 5*time.Second) // panel jam blok
u.waitContains("Docker", 5*time.Second) u.waitContains("Docker", 5*time.Second)
u.send("1") u.send("1")
u.waitContains("Stack", 3*time.Second)
u.send("2")
u.waitContains("Kontainer", 3*time.Second) u.waitContains("Kontainer", 3*time.Second)
u.send("0") u.send("0")
u.waitContains("beranda", 3*time.Second) u.waitContains("beranda", 3*time.Second)
@@ -618,7 +653,7 @@ func TestDashboardNavigation(t *testing.T) {
func TestMouseAndNavigateKeys(t *testing.T) { func TestMouseAndNavigateKeys(t *testing.T) {
u := startTUI(t) u := startTUI(t)
u.waitContains("beranda", 5*time.Second) 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.waitContains("Kontainer", 3*time.Second)
u.send("j") u.send("j")
u.send("k") u.send("k")
@@ -657,7 +692,7 @@ func TestClickSelectsContainer(t *testing.T) {
u := startTUI(t) u := startTUI(t)
u.waitContains("beranda", 5*time.Second) 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.waitContains(short(items[0].Name), 5*time.Second)
u.waitHighlighted(short(items[0].Name), 3*time.Second) // seleksi awal (kursor 0) 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 := startTUI(t)
u.waitContains("beranda", 5*time.Second) u.waitContains("beranda", 5*time.Second)
u.send("1") u.send("2")
u.waitContains(name, 5*time.Second) u.waitContains(name, 5*time.Second)
u.waitContains("Statistik", 3*time.Second) // baris tab 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/config"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker" "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/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/sysinfo"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/animation" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/animation"
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components"
@@ -46,15 +47,17 @@ const (
tabCount 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 type section int
const ( const (
sectionContainers section = iota sectionStacks section = iota
sectionContainers
sectionImages sectionImages
sectionVolumes sectionVolumes
sectionNetworks sectionNetworks
sectionCount = 4 sectionCount = 5
) )
// statsHistory — jumlah sampel riwayat (60 ≈ 1 menit @1 dtk, PRD F-005). // statsHistory — jumlah sampel riwayat (60 ≈ 1 menit @1 dtk, PRD F-005).
@@ -140,8 +143,14 @@ type Model struct {
hostname string hostname string
sys sysinfo.Stats sys sysinfo.Stats
cl *docker.Client cl *docker.Client
engine docker.Info compose *docker.ComposeClient
engine docker.Info
stacksDir string
stacks []stack.Stack
stacksID int64
stacksErr error
containers []docker.Container containers []docker.Container
images []docker.Image images []docker.Image
@@ -176,12 +185,14 @@ func New() Model {
animating: cfg.UI.AnimationEnabled, animating: cfg.UI.AnimationEnabled,
toast: components.NewToast(), toast: components.NewToast(),
view: viewHome, view: viewHome,
section: sectionContainers, section: sectionStacks,
focus: focusSidebar, focus: focusSidebar,
tab: tabLogs, tab: tabLogs,
hostname: host, hostname: host,
sys: sysinfo.Read(), sys: sysinfo.Read(),
cl: cl, cl: cl,
compose: docker.NewCompose(cfg.Docker.ComposeCommand),
stacksDir: config.ExpandPath(cfg.StacksDir),
loading: true, loading: true,
tailLines: cfg.UI.LogTailLines, tailLines: cfg.UI.LogTailLines,
@@ -208,6 +219,10 @@ func New() Model {
m.initCmds = append(m.initCmds, cmd) 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 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 m.procsTitle, m.procsRows = msg.titles, msg.rows
return m, nil 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: case eventMsg:
if msg.id != m.eventsID { if msg.id != m.eventsID {
return m, nil 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 { if msg.gen != m.refreshGen || m.cl == nil {
return m, nil // event lebih baru menunggu tick berikutnya 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: case tea.BackgroundColorMsg:
// Mode tema "auto": Mocha vs Latte sesuai background terminal. // 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 { func (m Model) lenOf(s section) int {
switch s { switch s {
case sectionStacks:
return len(m.stacks)
case sectionImages: case sectionImages:
return len(m.images) return len(m.images)
case sectionVolumes: case sectionVolumes:
@@ -627,6 +662,8 @@ func (m *Model) setCur(v int) { m.cursors[m.section] = v }
func (m Model) sectionName() string { func (m Model) sectionName() string {
switch m.section { switch m.section {
case sectionStacks:
return i18n.T("section.stacks")
case sectionImages: case sectionImages:
return i18n.T("section.images") return i18n.T("section.images")
case sectionVolumes: case sectionVolumes:
@@ -812,7 +849,7 @@ func (m Model) View() tea.View {
header += dim(" · " + i18n.T("nav.home")) header += dim(" · " + i18n.T("nav.home"))
body = m.homeBody() body = m.homeBody()
default: default:
header += dim(" · " + i18n.T("nav.containers")) header += dim(" · " + strings.ToLower(m.sectionName()))
body = lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView()) body = lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView())
} }
footer = m.footerLine() footer = m.footerLine()
@@ -998,6 +1035,10 @@ func (m Model) sectionLine(i, innerW int) string {
var line string var line string
switch m.section { 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: case sectionImages:
im := m.images[i] im := m.images[i]
glyph, color := "○", th.Overlay glyph, color := "○", th.Overlay
@@ -1065,6 +1106,22 @@ func (m Model) contentView() string {
idx := min(m.cur(), m.sectionLen()-1) 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). // Kontainer: tab detail (Log berfungsi; sisanya menyusul Phase 2).
if m.section == sectionContainers { if m.section == sectionContainers {
c := m.containers[idx] c := m.containers[idx]
@@ -1386,18 +1443,22 @@ func (m *Model) handleKey(key string) tea.Cmd {
m.stopLogs() m.stopLogs()
m.sound.Play(audio.Click) m.sound.Play(audio.Click)
case "1", "enter": case "1", "enter":
m.jumpSection(sectionContainers) m.jumpSection(sectionStacks)
m.sound.Play(audio.Click) m.sound.Play(audio.Click)
return m.restartData() return m.restartData()
case "2": case "2":
m.jumpSection(sectionImages) m.jumpSection(sectionContainers)
m.sound.Play(audio.Click) m.sound.Play(audio.Click)
return m.restartData() return m.restartData()
case "3": case "3":
m.jumpSection(sectionVolumes) m.jumpSection(sectionImages)
m.sound.Play(audio.Click) m.sound.Play(audio.Click)
return m.restartData() return m.restartData()
case "4": case "4":
m.jumpSection(sectionVolumes)
m.sound.Play(audio.Click)
return m.restartData()
case "5":
m.jumpSection(sectionNetworks) m.jumpSection(sectionNetworks)
m.sound.Play(audio.Click) m.sound.Play(audio.Click)
return m.restartData() return m.restartData()
@@ -1415,6 +1476,8 @@ func (m *Model) handleKey(key string) tea.Cmd {
m.sound.Play(audio.Click) m.sound.Play(audio.Click)
cmds := append(loadAll(m.cl), m.restartData()) cmds := append(loadAll(m.cl), m.restartData())
cmds = append(cmds, m.startEvents()) cmds = append(cmds, m.startEvents())
m.stacksID++
cmds = append(cmds, fetchStacks(m.stacksID, m.compose, m.stacksDir))
return tea.Batch(cmds...) return tea.Batch(cmds...)
case "T": // tombol cepat tema (PRD §5.4): auto → dark → light → terminal case "T": // tombol cepat tema (PRD §5.4): auto → dark → light → terminal
cur := theme.Current() 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: nav:
home: "home" home: "home"
containers: "containers"
section: section:
clock: "Clock" clock: "Clock"
host: "Host" host: "Host"
stacks: "Stacks"
containers: "Containers" containers: "Containers"
images: "Images" images: "Images"
volumes: "Volumes" volumes: "Volumes"
@@ -57,6 +57,8 @@ field:
scope: "Scope" scope: "Scope"
subnet: "Subnet" subnet: "Subnet"
inUse: "In use" inUse: "In use"
dir: "Dir"
composeFile: "Compose"
state: state:
loading: "loading…" loading: "loading…"
@@ -73,6 +75,7 @@ state:
no: "no" no: "no"
on: "on" on: "on"
off: "off" off: "off"
detected: "detected"
error: error:
fail: "FAILED" fail: "FAILED"
@@ -93,8 +96,9 @@ hint:
statsWaiting: "fetching stats…" statsWaiting: "fetching stats…"
inspectWaiting: "fetching inspect…" inspectWaiting: "fetching inspect…"
procsWaiting: "fetching processes…" procsWaiting: "fetching processes…"
footerHome: "[1/enter] containers · [t] toast · [T] theme · [a] reduced-motion · [q] quit" stackLater: "Stack actions (start · stop · pull) coming next."
footerContainers: "[1-4] sections · [j/k] select · [Tab] focus · [h/l] tab · [r] reload · [0] home · [T] theme · [q] quit" 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: footer:
status: "%s animation %s fps · audio: %s" status: "%s animation %s fps · audio: %s"
+7 -3
View File
@@ -4,11 +4,11 @@
nav: nav:
home: "beranda" home: "beranda"
containers: "kontainer"
section: section:
clock: "Jam" clock: "Jam"
host: "Host" host: "Host"
stacks: "Stack"
containers: "Kontainer" containers: "Kontainer"
images: "Image" images: "Image"
volumes: "Volume" volumes: "Volume"
@@ -57,6 +57,8 @@ field:
scope: "Scope" scope: "Scope"
subnet: "Subnet" subnet: "Subnet"
inUse: "Dipakai" inUse: "Dipakai"
dir: "Dir"
composeFile: "Compose"
state: state:
loading: "memuat…" loading: "memuat…"
@@ -73,6 +75,7 @@ state:
no: "tidak" no: "tidak"
on: "nyala" on: "nyala"
off: "mati" off: "mati"
detected: "terdeteksi"
error: error:
fail: "GAGAL" fail: "GAGAL"
@@ -93,8 +96,9 @@ hint:
statsWaiting: "mengambil statistik…" statsWaiting: "mengambil statistik…"
inspectWaiting: "mengambil inspect…" inspectWaiting: "mengambil inspect…"
procsWaiting: "mengambil daftar proses…" procsWaiting: "mengambil daftar proses…"
footerHome: "[1/enter] kontainer · [t] toast · [T] tema · [a] reduced-motion · [q] keluar" stackLater: "Aksi stack (jalankan · henti · pull) menyusul."
footerContainers: "[1-4] bagian · [j/k] pilih · [Tab] fokus · [h/l] tab · [r] muat ulang · [0] beranda · [T] tema · [q] keluar" 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: footer:
status: "%s animasi %s fps · audio: %s" status: "%s animasi %s fps · audio: %s"