feat(stacks): seksi Stacks di workspace (urutan PRD 1-5) — scan disk + rekonsiliasi compose ls
CI / test (push) Successful in 24s
CI / test (push) Successful in 24s
This commit is contained in:
1 parent
8a3b92b155
commit
f57d1cc29c
5 files changed
+199
-27
No files matched your search
+45
-10
@@ -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
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user