feat(ui): sidebar kontainer + panel detail (ListContainers, navigasi j/k, klik pilih, refresh r)
CI / test (push) Successful in 21s
CI / test (push) Successful in 21s
This commit is contained in:
1 parent
18055979da
commit
fe781591e3
4 files changed
+336
-42
No files matched your search
+34
-4
@@ -8,6 +8,7 @@
|
|||||||
package e2e
|
package e2e
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -18,6 +19,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/creack/pty"
|
"github.com/creack/pty"
|
||||||
|
|
||||||
|
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -210,12 +213,39 @@ func TestDoctorCommand(t *testing.T) {
|
|||||||
|
|
||||||
func TestMouseAndNavigateKeys(t *testing.T) {
|
func TestMouseAndNavigateKeys(t *testing.T) {
|
||||||
u := startTUI(t)
|
u := startTUI(t)
|
||||||
u.waitContains("belum ada klik", 5*time.Second)
|
u.waitContains("Kontainer", 5*time.Second)
|
||||||
// klik kiri di tengah layar (SGR 1006: ESC [ < 0 ; x ; y M)
|
|
||||||
u.send("\x1b[<0;60;10M\x1b[<0;60;10m")
|
|
||||||
u.waitContains("klik #1", 3*time.Second)
|
|
||||||
u.send("j")
|
u.send("j")
|
||||||
u.send("k")
|
u.send("k")
|
||||||
|
u.send("\x1b[<0;10;3M\x1b[<0;10;3m") // klik kiri di area sidebar
|
||||||
|
u.send("r") // muat ulang
|
||||||
|
u.send("q")
|
||||||
|
if code := u.waitExit(5 * time.Second); code != 0 {
|
||||||
|
t.Fatalf("exit code %d, mau 0", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClickSelectsContainer — lokal/CI-with-docker saja: klik baris sidebar
|
||||||
|
// harus memindahkan penanda pilihan (▶) ke kontainer.
|
||||||
|
func TestClickSelectsContainer(t *testing.T) {
|
||||||
|
if _, err := os.Stat("/var/run/docker.sock"); err != nil {
|
||||||
|
t.Skip("docker socket tidak ada")
|
||||||
|
}
|
||||||
|
cl, err := docker.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("docker: %v", err)
|
||||||
|
}
|
||||||
|
defer cl.Close()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
items, err := cl.ListContainers(ctx)
|
||||||
|
if err != nil || len(items) == 0 {
|
||||||
|
t.Skipf("tidak ada kontainer (err=%v, n=%d)", err, len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
u := startTUI(t)
|
||||||
|
u.waitContains("Kontainer (", 5*time.Second)
|
||||||
|
u.send("\x1b[<0;10;4M\x1b[<0;10;4m") // klik baris item pertama sidebar
|
||||||
|
u.waitContains("▶", 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 {
|
||||||
t.Fatalf("exit code %d, mau 0", code)
|
t.Fatalf("exit code %d, mau 0", code)
|
||||||
|
|||||||
+252
-38
@@ -1,6 +1,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -9,45 +10,74 @@ import (
|
|||||||
"charm.land/lipgloss/v2"
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio"
|
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio"
|
||||||
|
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Model root sementara (T-0.2–T-0.4, Phase 0).
|
// containersMsg — hasil muat daftar kontainer (async via tea.Cmd).
|
||||||
// Membuktikan Bubble Tea v2 (View struct, mouse, resize, ticker 30 FPS),
|
type containersMsg struct {
|
||||||
// audio engine, dan toast animasi sebelum layout penuh Phase 1.
|
items []docker.Container
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
width, height int
|
width, height int
|
||||||
clicks int
|
|
||||||
lastClick string
|
|
||||||
sound *audio.Engine
|
sound *audio.Engine
|
||||||
|
toast components.Toast
|
||||||
|
|
||||||
tickID int64
|
tickID int64
|
||||||
animating bool
|
animating bool
|
||||||
phase float64
|
phase float64
|
||||||
toast components.Toast
|
|
||||||
frames int
|
frames int
|
||||||
fps int
|
fps int
|
||||||
lastFPSAt time.Time
|
lastFPSAt time.Time
|
||||||
|
|
||||||
|
cl *docker.Client
|
||||||
|
containers []docker.Container
|
||||||
|
cursor int
|
||||||
|
loadErr error
|
||||||
|
loading bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() Model {
|
func New() Model {
|
||||||
return Model{
|
cl, err := docker.New()
|
||||||
lastClick: "belum ada klik",
|
m := Model{
|
||||||
sound: audio.New(audio.Options{Enabled: true, Volume: 70, MuteOnSSH: true}),
|
sound: audio.New(audio.Options{Enabled: true, Volume: 70, MuteOnSSH: true}),
|
||||||
tickID: animation.NewID(),
|
tickID: animation.NewID(),
|
||||||
animating: true,
|
animating: true,
|
||||||
toast: components.NewToast(),
|
toast: components.NewToast(),
|
||||||
|
cl: cl,
|
||||||
|
loading: true,
|
||||||
}
|
}
|
||||||
|
if err != nil {
|
||||||
|
m.loadErr = err
|
||||||
|
m.loading = false
|
||||||
|
}
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd { return animation.Next(m.tickID) }
|
func (m Model) Init() tea.Cmd {
|
||||||
|
if m.cl == nil {
|
||||||
|
return animation.Next(m.tickID)
|
||||||
|
}
|
||||||
|
return tea.Batch(animation.Next(m.tickID), loadContainers(m.cl))
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadContainers(cl *docker.Client) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
items, err := cl.ListContainers(ctx)
|
||||||
|
return containersMsg{items: items, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case animation.TickMsg:
|
case animation.TickMsg:
|
||||||
if msg.ID != m.tickID || !m.animating {
|
if msg.ID != m.tickID || !m.animating {
|
||||||
return m, nil // tick basi / animasi dimatikan oleh reduced-motion
|
return m, nil // tick basi / reduced-motion
|
||||||
}
|
}
|
||||||
m.phase += 0.9
|
m.phase += 0.9
|
||||||
m.toast.Update(animation.FrameInterval)
|
m.toast.Update(animation.FrameInterval)
|
||||||
@@ -62,6 +92,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, animation.Next(m.tickID)
|
return m, animation.Next(m.tickID)
|
||||||
|
|
||||||
|
case containersMsg:
|
||||||
|
m.loading = false
|
||||||
|
if msg.err != nil {
|
||||||
|
m.loadErr = msg.err
|
||||||
|
m.toast.Show("✖ [ GAGAL ] Docker", msg.err.Error())
|
||||||
|
m.sound.Play(audio.NotifyError)
|
||||||
|
} else {
|
||||||
|
m.loadErr = nil
|
||||||
|
m.containers = msg.items
|
||||||
|
if m.cursor >= len(m.containers) {
|
||||||
|
m.cursor = max(0, len(m.containers)-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width, m.height = msg.Width, msg.Height
|
m.width, m.height = msg.Width, msg.Height
|
||||||
|
|
||||||
@@ -70,65 +115,234 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case "q", "ctrl+c", "esc":
|
case "q", "ctrl+c", "esc":
|
||||||
m.sound.Play(audio.ModalClose)
|
m.sound.Play(audio.ModalClose)
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case "a": // reduced motion: hentikan/nyalakan ticker
|
case "a": // reduced motion
|
||||||
m.animating = !m.animating
|
m.animating = !m.animating
|
||||||
if m.animating {
|
if m.animating {
|
||||||
m.tickID = animation.NewID()
|
m.tickID = animation.NewID()
|
||||||
return m, animation.Next(m.tickID)
|
return m, animation.Next(m.tickID)
|
||||||
}
|
}
|
||||||
return m, nil
|
case "r": // muat ulang daftar kontainer
|
||||||
case "t":
|
if m.cl == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
m.loading = true
|
||||||
|
m.sound.Play(audio.Click)
|
||||||
|
return m, loadContainers(m.cl)
|
||||||
|
case "t": // demo toast (sampai modal asli datang)
|
||||||
m.toast.Show("✔ [ SUKSES ] Konversi Berhasil Disimpan",
|
m.toast.Show("✔ [ SUKSES ] Konversi Berhasil Disimpan",
|
||||||
"Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/")
|
"Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/")
|
||||||
m.sound.Play(audio.NotifySuccess)
|
m.sound.Play(audio.NotifySuccess)
|
||||||
case "j", "down", "k", "up":
|
case "j", "down":
|
||||||
m.phase += 6
|
if len(m.containers) > 0 && m.cursor < len(m.containers)-1 {
|
||||||
|
m.cursor++
|
||||||
|
}
|
||||||
|
m.sound.Play(audio.Navigate)
|
||||||
|
case "k", "up":
|
||||||
|
if m.cursor > 0 {
|
||||||
|
m.cursor--
|
||||||
|
}
|
||||||
m.sound.Play(audio.Navigate)
|
m.sound.Play(audio.Navigate)
|
||||||
}
|
}
|
||||||
|
|
||||||
case tea.MouseClickMsg:
|
case tea.MouseClickMsg:
|
||||||
if msg.Button == tea.MouseLeft {
|
if msg.Button == tea.MouseLeft {
|
||||||
m.clicks++
|
m.sound.Play(audio.Click)
|
||||||
m.lastClick = fmt.Sprintf("klik #%d di (%d, %d)", m.clicks, msg.X, msg.Y)
|
|
||||||
if m.toast.Active() {
|
if m.toast.Active() {
|
||||||
m.toast = components.NewToast() // klik = tutup toast
|
m.toast = components.NewToast() // klik = tutup toast
|
||||||
}
|
}
|
||||||
m.sound.Play(audio.Click)
|
// hit-test sisi sidebar: baris item pertama = bodyTop + 2
|
||||||
|
// (border atas + baris judul panel)
|
||||||
|
if msg.X < m.leftWidth() && len(m.containers) > 0 {
|
||||||
|
idx := msg.Y - (m.headerRows() + 2) + m.scrollStart()
|
||||||
|
if idx >= 0 && idx < len(m.containers) {
|
||||||
|
m.cursor = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) View() tea.View {
|
// --- layout helpers ---
|
||||||
title := lipgloss.NewStyle().
|
|
||||||
Bold(true).
|
|
||||||
Foreground(lipgloss.Color("#cba6f7")).
|
|
||||||
Render("tia — Terminal Is A My Way")
|
|
||||||
|
|
||||||
fps := "—"
|
func (m Model) toastRows() int {
|
||||||
if m.animating {
|
if m.toast.Active() {
|
||||||
fps = fmt.Sprintf("%d", m.fps)
|
return 5 // 4 baris toast + 1 baris jarak
|
||||||
}
|
}
|
||||||
statusline := lipgloss.NewStyle().
|
return 0
|
||||||
Foreground(lipgloss.Color("#a6adc8")).
|
}
|
||||||
Render(fmt.Sprintf("Just Work · Phase 0 · animasi %s fps · audio: %s", fps, m.sound.Status()))
|
|
||||||
hint := lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("#6c7086")).
|
|
||||||
Render("[t] toast · [a] reduced-motion · klik / j / k = bunyi · q = keluar")
|
|
||||||
|
|
||||||
box := lipgloss.NewStyle().
|
func (m Model) headerRows() int { return 1 + m.toastRows() }
|
||||||
|
|
||||||
|
func (m Model) bodyHeight() int {
|
||||||
|
h := m.height - 3 - m.toastRows() // header(1) + footer(1) + margin(1)
|
||||||
|
if h < 5 {
|
||||||
|
h = 5
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) leftWidth() int {
|
||||||
|
w := int(float64(m.width) * 0.33)
|
||||||
|
if w < 24 {
|
||||||
|
w = 24
|
||||||
|
}
|
||||||
|
if w > 46 {
|
||||||
|
w = 46
|
||||||
|
}
|
||||||
|
if w > m.width-24 {
|
||||||
|
w = max(16, m.width-24)
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrollStart — offset window daftar agar kursor selalu terlihat.
|
||||||
|
func (m Model) scrollStart() int {
|
||||||
|
rows := m.bodyHeight() - 3 // tinggi dalam panel - border - baris judul
|
||||||
|
if rows <= 0 || m.cursor < rows {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return m.cursor - rows + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func stateStyle(state string) (glyph, color string) {
|
||||||
|
switch state {
|
||||||
|
case "running":
|
||||||
|
return "●", "#a6e3a1"
|
||||||
|
case "paused":
|
||||||
|
return "⏸", "#f9e2af"
|
||||||
|
case "restarting", "created":
|
||||||
|
return "◌", "#89b4fa"
|
||||||
|
case "exited", "dead":
|
||||||
|
return "○", "#f38ba8"
|
||||||
|
default:
|
||||||
|
return "○", "#6c7086"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, w int) string {
|
||||||
|
r := []rune(s)
|
||||||
|
if w <= 1 || len(r) <= w {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(r[:w-1]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
|
// box — panel ber-border; w/h = ukuran total panel (termasuk border+padding).
|
||||||
|
func box(lines []string, w, h int) string {
|
||||||
|
return lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(lipgloss.Color("#6c7086")).
|
BorderForeground(lipgloss.Color("#6c7086")).
|
||||||
Padding(0, 2).
|
Width(w-4).
|
||||||
Render(fmt.Sprintf("%s\nukuran jendela: %dx%d", m.lastClick, m.width, m.height))
|
Height(h-2).
|
||||||
|
Padding(0, 1).
|
||||||
|
Render(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
parts := []string{title, "", animation.RenderLogo(m.phase), "", statusline, hint, "", box}
|
func dim(s string) string {
|
||||||
if t := m.toast.View(max(m.width, 20)); t != "" {
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#6c7086")).Render(s)
|
||||||
parts = append([]string{t, ""}, parts...)
|
}
|
||||||
|
|
||||||
|
func text(s string) string {
|
||||||
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("#cdd6f4")).Render(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- views ---
|
||||||
|
|
||||||
|
func (m Model) View() tea.View {
|
||||||
|
header := lipgloss.NewStyle().Bold(true).
|
||||||
|
Foreground(lipgloss.Color("#cba6f7")).
|
||||||
|
Render("tia — Terminal Is A My Way") +
|
||||||
|
dim(" · kontainer")
|
||||||
|
|
||||||
|
body := lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView())
|
||||||
|
|
||||||
|
anim := "—"
|
||||||
|
if m.animating {
|
||||||
|
anim = fmt.Sprintf("%d", m.fps)
|
||||||
}
|
}
|
||||||
|
footerPlain := fmt.Sprintf(
|
||||||
|
"[j/k] pilih · [r] muat ulang · [t] toast · [a] reduced-motion · [q] keluar animasi %s fps · audio: %s",
|
||||||
|
anim, m.sound.Status())
|
||||||
|
footer := dim(truncate(footerPlain, m.width))
|
||||||
|
|
||||||
|
parts := []string{}
|
||||||
|
if t := m.toast.View(max(m.width, 20)); t != "" {
|
||||||
|
parts = append(parts, t)
|
||||||
|
}
|
||||||
|
parts = append(parts, header, body, footer)
|
||||||
|
|
||||||
v := tea.NewView(strings.Join(parts, "\n"))
|
v := tea.NewView(strings.Join(parts, "\n"))
|
||||||
v.AltScreen = true
|
v.AltScreen = true
|
||||||
v.MouseMode = tea.MouseModeCellMotion
|
v.MouseMode = tea.MouseModeCellMotion
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m Model) sidebarView() string {
|
||||||
|
w, h := m.leftWidth(), m.bodyHeight()
|
||||||
|
innerW := w - 4
|
||||||
|
|
||||||
|
head := fmt.Sprintf("Kontainer (%d)", len(m.containers))
|
||||||
|
if m.loading {
|
||||||
|
head = "Kontainer · memuat…"
|
||||||
|
}
|
||||||
|
lines := []string{lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#cdd6f4")).Render(head)}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case m.loadErr != nil:
|
||||||
|
lines = append(lines,
|
||||||
|
lipgloss.NewStyle().Foreground(lipgloss.Color("#f38ba8")).Render("GAGAL memuat"),
|
||||||
|
dim("tekan r untuk coba lagi"),
|
||||||
|
)
|
||||||
|
case m.loading:
|
||||||
|
lines = append(lines, dim("memuat…"))
|
||||||
|
case len(m.containers) == 0:
|
||||||
|
lines = append(lines, dim("belum ada kontainer"))
|
||||||
|
default:
|
||||||
|
rows := h - 3
|
||||||
|
start := m.scrollStart()
|
||||||
|
for i := start; i < len(m.containers) && i-start < rows; i++ {
|
||||||
|
c := m.containers[i]
|
||||||
|
glyph, color := stateStyle(c.State)
|
||||||
|
mark := " "
|
||||||
|
if i == m.cursor {
|
||||||
|
mark = lipgloss.NewStyle().Foreground(lipgloss.Color("#cba6f7")).Render("▶ ")
|
||||||
|
}
|
||||||
|
line := mark +
|
||||||
|
lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(glyph) + " " +
|
||||||
|
lipgloss.NewStyle().Foreground(lipgloss.Color("#cdd6f4")).Render(truncate(c.Name, innerW-4))
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return box(lines, w, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) contentView() string {
|
||||||
|
w, h := m.width-m.leftWidth(), m.bodyHeight()
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
switch {
|
||||||
|
case m.loadErr != nil:
|
||||||
|
lines = append(lines,
|
||||||
|
lipgloss.NewStyle().Foreground(lipgloss.Color("#f38ba8")).Render("Gagal memuat daftar kontainer"),
|
||||||
|
"",
|
||||||
|
dim(truncate(m.loadErr.Error(), w-6)),
|
||||||
|
)
|
||||||
|
case len(m.containers) == 0:
|
||||||
|
lines = append(lines, dim("Belum ada kontainer — jalankan sesuatu dengan Docker, lalu tekan r."))
|
||||||
|
default:
|
||||||
|
c := m.containers[min(m.cursor, len(m.containers)-1)]
|
||||||
|
glyph, color := stateStyle(c.State)
|
||||||
|
lines = append(lines,
|
||||||
|
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#cdd6f4")).Render(c.Name),
|
||||||
|
"",
|
||||||
|
text("ID ")+dim(c.ID),
|
||||||
|
text("Image ")+dim(truncate(c.Image, w-20)),
|
||||||
|
text("State ")+lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(glyph+" "+c.State),
|
||||||
|
text("Status ")+dim(truncate(c.Status, w-20)),
|
||||||
|
"",
|
||||||
|
dim("Tab detail (Log · Statistik · Konfigurasi) menyusul di Phase 2."),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return box(lines, w, h)
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ package docker
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,3 +62,39 @@ func (c *Client) Ping(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Container — ringkasan kontainer untuk sidebar/dashboard.
|
||||||
|
type Container struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Image string
|
||||||
|
State string // running, exited, paused, restarting, created, dead
|
||||||
|
Status string // "Up 2 hours (healthy)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListContainers mengembalikan semua kontainer (termasuk yang berhenti).
|
||||||
|
func (c *Client) ListContainers(ctx context.Context) ([]Container, error) {
|
||||||
|
list, err := c.cli.ContainerList(ctx, container.ListOptions{All: true})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("docker: daftar kontainer: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]Container, 0, len(list))
|
||||||
|
for _, ct := range list {
|
||||||
|
name := ""
|
||||||
|
if len(ct.Names) > 0 {
|
||||||
|
name = strings.TrimPrefix(ct.Names[0], "/")
|
||||||
|
}
|
||||||
|
id := ct.ID
|
||||||
|
if len(id) > 12 {
|
||||||
|
id = id[:12]
|
||||||
|
}
|
||||||
|
out = append(out, Container{
|
||||||
|
ID: id,
|
||||||
|
Name: name,
|
||||||
|
Image: ct.Image,
|
||||||
|
State: ct.State,
|
||||||
|
Status: ct.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -35,4 +35,16 @@ func TestIntegrationInfo(t *testing.T) {
|
|||||||
t.Logf("engine %s (API %s) · %d/%d kontainer berjalan · %d image · %d CPU · %d MB RAM",
|
t.Logf("engine %s (API %s) · %d/%d kontainer berjalan · %d image · %d CPU · %d MB RAM",
|
||||||
info.ServerVersion, info.APIVersion, info.Running, info.Containers,
|
info.ServerVersion, info.APIVersion, info.Running, info.Containers,
|
||||||
info.Images, info.CPUs, info.MemoryTotal/1024/1024)
|
info.Images, info.CPUs, info.MemoryTotal/1024/1024)
|
||||||
|
|
||||||
|
items, err := c.ListContainers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListContainers: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("ListContainers: %d kontainer", len(items))
|
||||||
|
for i, ct := range items {
|
||||||
|
if i >= 5 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
t.Logf(" - %-20s %-10s %s", ct.Name, ct.State, ct.Image)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user