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 := 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
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in new issue
Block a user