feat(ui): bagian Image/Volume/Jaringan di workspace (F-003) + fix closure applyList + emulator ESC[J penuh
CI / test (push) Successful in 12m9s
CI / test (push) Successful in 12m9s
This commit is contained in:
1 parent
8b2a86877e
commit
a28b83513d
8 files changed
+487
-73
No files matched your search
@@ -275,6 +275,23 @@ func TestDoctorUsesConfigStacksDir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"},
|
||||
} {
|
||||
u.send(want[0])
|
||||
u.waitContains(want[1], 3*time.Second)
|
||||
}
|
||||
u.send("0")
|
||||
u.waitContains("beranda", 3*time.Second)
|
||||
u.send("q")
|
||||
if code := u.waitExit(5 * time.Second); code != 0 {
|
||||
t.Fatalf("exit code %d, mau 0", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemeCycle(t *testing.T) {
|
||||
u := startTUI(t)
|
||||
u.waitContains("beranda", 5*time.Second)
|
||||
|
||||
+20
-1
@@ -178,7 +178,26 @@ func (s *screen) applyCSI(final rune, params string) {
|
||||
s.curR = at(nums, 0, 1) - 1
|
||||
s.clampCursor()
|
||||
case 'J':
|
||||
if at(nums, 0, 0) == 2 {
|
||||
switch at(nums, 0, 0) {
|
||||
case 0: // dari kursor sampai akhir layar
|
||||
for c := s.curC; c < s.cols; c++ {
|
||||
s.grid[s.curR][c] = ' '
|
||||
}
|
||||
for r := s.curR + 1; r < s.rows; r++ {
|
||||
for c := range s.grid[r] {
|
||||
s.grid[r][c] = ' '
|
||||
}
|
||||
}
|
||||
case 1: // dari awal layar sampai kursor
|
||||
for r := 0; r < s.curR; r++ {
|
||||
for c := range s.grid[r] {
|
||||
s.grid[r][c] = ' '
|
||||
}
|
||||
}
|
||||
for c := 0; c <= s.curC && c < s.cols; c++ {
|
||||
s.grid[s.curR][c] = ' '
|
||||
}
|
||||
default: // 2 (layar penuh) & 3 (scrollback) → bersihkan
|
||||
s.clearAll()
|
||||
}
|
||||
case 'K':
|
||||
|
||||
+281
-60
@@ -24,16 +24,38 @@ type viewKind int
|
||||
|
||||
const (
|
||||
viewHome viewKind = iota
|
||||
viewContainers
|
||||
viewList
|
||||
)
|
||||
|
||||
// containersMsg — hasil muat daftar kontainer (async via tea.Cmd).
|
||||
// section — bagian resource di workspace (PRD F-001: 1-4 jump to section).
|
||||
type section int
|
||||
|
||||
const (
|
||||
sectionContainers section = iota
|
||||
sectionImages
|
||||
sectionVolumes
|
||||
sectionNetworks
|
||||
sectionCount = 4
|
||||
)
|
||||
|
||||
// --- messages (async via tea.Cmd) ---
|
||||
|
||||
type containersMsg struct {
|
||||
items []docker.Container
|
||||
err error
|
||||
}
|
||||
|
||||
// infoMsg — ringkasan daemon docker.
|
||||
type imagesMsg struct {
|
||||
items []docker.Image
|
||||
err error
|
||||
}
|
||||
type volumesMsg struct {
|
||||
items []docker.Volume
|
||||
err error
|
||||
}
|
||||
type networksMsg struct {
|
||||
items []docker.Network
|
||||
err error
|
||||
}
|
||||
type infoMsg struct {
|
||||
info docker.Info
|
||||
err error
|
||||
@@ -51,18 +73,24 @@ type Model struct {
|
||||
fps int
|
||||
lastFPSAt time.Time
|
||||
|
||||
view viewKind
|
||||
view viewKind
|
||||
section section
|
||||
|
||||
hostname string
|
||||
sys sysinfo.Stats
|
||||
|
||||
cl *docker.Client
|
||||
engine docker.Info
|
||||
engineErr error
|
||||
cl *docker.Client
|
||||
engine docker.Info
|
||||
|
||||
containers []docker.Container
|
||||
cursor int
|
||||
loadErr error
|
||||
loading bool
|
||||
images []docker.Image
|
||||
volumes []docker.Volume
|
||||
networks []docker.Network
|
||||
cursors [sectionCount]int
|
||||
|
||||
engineErr error
|
||||
loadErr error
|
||||
loading bool
|
||||
}
|
||||
|
||||
func New() Model {
|
||||
@@ -87,6 +115,7 @@ func New() Model {
|
||||
animating: cfg.UI.AnimationEnabled,
|
||||
toast: components.NewToast(),
|
||||
view: viewHome,
|
||||
section: sectionContainers,
|
||||
hostname: host,
|
||||
sys: sysinfo.Read(),
|
||||
cl: cl,
|
||||
@@ -109,11 +138,21 @@ func (m Model) Init() tea.Cmd {
|
||||
cmds = append(cmds, animation.Next(m.tickID))
|
||||
}
|
||||
if m.cl != nil {
|
||||
cmds = append(cmds, loadContainers(m.cl), loadInfo(m.cl))
|
||||
cmds = append(cmds, loadAll(m.cl)...)
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func loadAll(cl *docker.Client) []tea.Cmd {
|
||||
return []tea.Cmd{
|
||||
loadContainers(cl),
|
||||
loadImages(cl),
|
||||
loadVolumes(cl),
|
||||
loadNetworks(cl),
|
||||
loadInfo(cl),
|
||||
}
|
||||
}
|
||||
|
||||
func loadContainers(cl *docker.Client) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
@@ -123,6 +162,33 @@ func loadContainers(cl *docker.Client) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func loadImages(cl *docker.Client) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
items, err := cl.ListImages(ctx)
|
||||
return imagesMsg{items: items, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func loadVolumes(cl *docker.Client) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
items, err := cl.ListVolumes(ctx)
|
||||
return volumesMsg{items: items, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func loadNetworks(cl *docker.Client) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
items, err := cl.ListNetworks(ctx)
|
||||
return networksMsg{items: items, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func loadInfo(cl *docker.Client) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
@@ -153,20 +219,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, animation.Next(m.tickID)
|
||||
|
||||
case containersMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
m.loadErr = msg.err
|
||||
m.toast.Show(components.LevelError, i18n.T("toast.dockerError"), 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, loadInfo(m.cl) // sinkronkan hitungan di dashboard
|
||||
}
|
||||
return m, nil
|
||||
return m.applyList(msg.err, func(mm *Model) { mm.containers = msg.items })
|
||||
|
||||
case imagesMsg:
|
||||
return m.applyList(msg.err, func(mm *Model) { mm.images = msg.items })
|
||||
|
||||
case volumesMsg:
|
||||
return m.applyList(msg.err, func(mm *Model) { mm.volumes = msg.items })
|
||||
|
||||
case networksMsg:
|
||||
return m.applyList(msg.err, func(mm *Model) { mm.networks = msg.items })
|
||||
|
||||
case infoMsg:
|
||||
if msg.err != nil {
|
||||
@@ -201,7 +263,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.view = viewHome
|
||||
m.sound.Play(audio.Click)
|
||||
case "1", "enter":
|
||||
m.view = viewContainers
|
||||
m.jumpSection(sectionContainers)
|
||||
m.sound.Play(audio.Click)
|
||||
case "2":
|
||||
m.jumpSection(sectionImages)
|
||||
m.sound.Play(audio.Click)
|
||||
case "3":
|
||||
m.jumpSection(sectionVolumes)
|
||||
m.sound.Play(audio.Click)
|
||||
case "4":
|
||||
m.jumpSection(sectionNetworks)
|
||||
m.sound.Play(audio.Click)
|
||||
case "a": // reduced motion
|
||||
m.animating = !m.animating
|
||||
@@ -209,13 +280,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.tickID = animation.NewID()
|
||||
return m, animation.Next(m.tickID)
|
||||
}
|
||||
case "r": // muat ulang data docker
|
||||
case "r": // muat ulang semua data docker
|
||||
if m.cl == nil {
|
||||
break
|
||||
}
|
||||
m.loading = true
|
||||
m.sound.Play(audio.Click)
|
||||
return m, tea.Batch(loadContainers(m.cl), loadInfo(m.cl))
|
||||
return m, tea.Batch(loadAll(m.cl)...)
|
||||
case "T": // tombol cepat tema (PRD §5.4): auto → dark → light → terminal
|
||||
cur := theme.Current()
|
||||
next := theme.CycleMode(cur.Mode)
|
||||
@@ -228,13 +299,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.toast.Show(components.LevelSuccess, i18n.T("toast.demoTitle"), i18n.T("toast.demoBody"))
|
||||
m.sound.Play(audio.NotifySuccess)
|
||||
case "j", "down":
|
||||
if len(m.containers) > 0 && m.cursor < len(m.containers)-1 {
|
||||
m.cursor++
|
||||
if c := m.cur(); c < m.sectionLen()-1 {
|
||||
m.setCur(c + 1)
|
||||
}
|
||||
m.sound.Play(audio.Navigate)
|
||||
case "k", "up":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
if c := m.cur(); c > 0 {
|
||||
m.setCur(c - 1)
|
||||
}
|
||||
m.sound.Play(audio.Navigate)
|
||||
}
|
||||
@@ -245,12 +316,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.toast.Active() {
|
||||
m.toast = components.NewToast() // klik = tutup toast
|
||||
}
|
||||
// hit-test sidebar (hanya di view kontainer):
|
||||
// hit-test sidebar (hanya di view list):
|
||||
// baris item pertama = headerRows + 1 (border atas panel)
|
||||
if m.view == viewContainers && msg.X < m.leftWidth() && len(m.containers) > 0 {
|
||||
if m.view == viewList && msg.X < m.leftWidth() && m.sectionLen() > 0 {
|
||||
idx := msg.Y - (m.headerRows() + 1) + m.scrollStart()
|
||||
if idx >= 0 && idx < len(m.containers) {
|
||||
m.cursor = idx
|
||||
if idx >= 0 && idx < m.sectionLen() {
|
||||
m.setCur(idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,6 +329,90 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// applyList — jalur bersama semua hasil muat daftar.
|
||||
// Dipanggil dengan pointer supaya mutasi set() mengenai salinan receiver
|
||||
// yang memang dikembalikan (bug closure-by-value pernah terjadi di sini).
|
||||
func (m Model) applyList(err error, set func(*Model)) (tea.Model, tea.Cmd) {
|
||||
m.loading = false
|
||||
if err != nil {
|
||||
m.loadErr = err
|
||||
m.toast.Show(components.LevelError, i18n.T("toast.dockerError"), err.Error())
|
||||
m.sound.Play(audio.NotifyError)
|
||||
return m, nil
|
||||
}
|
||||
m.loadErr = nil
|
||||
set(&m)
|
||||
for i := range m.cursors {
|
||||
if n := m.lenOf(section(i)); m.cursors[i] >= n {
|
||||
m.cursors[i] = max(0, n-1)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) jumpSection(s section) {
|
||||
m.section = s
|
||||
m.view = viewList
|
||||
}
|
||||
|
||||
// --- data helpers ---
|
||||
|
||||
func (m Model) lenOf(s section) int {
|
||||
switch s {
|
||||
case sectionImages:
|
||||
return len(m.images)
|
||||
case sectionVolumes:
|
||||
return len(m.volumes)
|
||||
case sectionNetworks:
|
||||
return len(m.networks)
|
||||
default:
|
||||
return len(m.containers)
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) sectionLen() int { return m.lenOf(m.section) }
|
||||
|
||||
func (m Model) cur() int { return m.cursors[m.section] }
|
||||
|
||||
func (m *Model) setCur(v int) { m.cursors[m.section] = v }
|
||||
|
||||
func (m Model) sectionName() string {
|
||||
switch m.section {
|
||||
case sectionImages:
|
||||
return i18n.T("section.images")
|
||||
case sectionVolumes:
|
||||
return i18n.T("section.volumes")
|
||||
case sectionNetworks:
|
||||
return i18n.T("section.networks")
|
||||
default:
|
||||
return i18n.T("section.containers")
|
||||
}
|
||||
}
|
||||
|
||||
func shortID(s string) string {
|
||||
s = strings.TrimPrefix(s, "sha256:")
|
||||
if len(s) > 12 {
|
||||
s = s[:12]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (m Model) imageInUse(id string) bool {
|
||||
for _, c := range m.containers {
|
||||
if shortID(c.ImageID) == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func imageDisplayName(im docker.Image) string {
|
||||
if len(im.Tags) > 0 {
|
||||
return im.Tags[0]
|
||||
}
|
||||
return im.ID
|
||||
}
|
||||
|
||||
// --- layout helpers ---
|
||||
|
||||
func (m Model) toastRows() int {
|
||||
@@ -294,10 +449,10 @@ func (m Model) leftWidth() int {
|
||||
// scrollStart — offset window daftar agar kursor selalu terlihat.
|
||||
func (m Model) scrollStart() int {
|
||||
rows := m.bodyHeight() - 2 // konten dalam panel (tanpa border)
|
||||
if rows <= 0 || m.cursor < rows {
|
||||
if rows <= 0 || m.cur() < rows {
|
||||
return 0
|
||||
}
|
||||
return m.cursor - rows + 1
|
||||
return m.cur() - rows + 1
|
||||
}
|
||||
|
||||
func stateStyle(state string) (glyph string, c theme.Color) {
|
||||
@@ -316,7 +471,7 @@ func stateStyle(state string) (glyph string, c theme.Color) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- styling helpers (semua via token tema — F-asi §5.4) ---
|
||||
// --- styling helpers (semua via token tema — PRD §5.4) ---
|
||||
|
||||
func dim(s string) string {
|
||||
return theme.Style(theme.Current().Subtext).Render(s)
|
||||
@@ -436,7 +591,8 @@ func (m Model) footerLine(hints string) string {
|
||||
|
||||
func (m Model) sidebarView() string {
|
||||
w, h := m.leftWidth(), m.bodyHeight()
|
||||
title, meta := i18n.T("section.containers"), fmt.Sprintf("(%d)", len(m.containers))
|
||||
title := m.sectionName()
|
||||
meta := fmt.Sprintf("(%d)", m.sectionLen())
|
||||
if m.loading {
|
||||
meta = i18n.T("state.loading")
|
||||
}
|
||||
@@ -445,39 +601,104 @@ func (m Model) sidebarView() string {
|
||||
switch {
|
||||
case m.loadErr != nil:
|
||||
lines = append(lines, red(i18n.T("error.loadFailed")), dim(i18n.T("hint.retry")))
|
||||
case m.loading:
|
||||
case m.loading && m.sectionLen() == 0:
|
||||
lines = append(lines, dim(i18n.T("state.loading")))
|
||||
case len(m.containers) == 0:
|
||||
lines = append(lines, dim(i18n.T("state.noContainers")))
|
||||
case m.sectionLen() == 0:
|
||||
lines = append(lines, dim(i18n.Tf("hint.emptyItems", title)))
|
||||
default:
|
||||
rows := h - 2
|
||||
start := m.scrollStart()
|
||||
innerW := w - 4
|
||||
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 = styled(theme.Current().Accent, "▶ ")
|
||||
}
|
||||
lines = append(lines, mark+styled(color, glyph)+" "+text(truncate(c.Name, innerW-4)))
|
||||
for i := start; i < m.sectionLen() && i-start < rows; i++ {
|
||||
lines = append(lines, m.sectionLine(i, w-4))
|
||||
}
|
||||
}
|
||||
return panel(title, meta, lines, w, h, false)
|
||||
}
|
||||
|
||||
func (m Model) sectionLine(i, innerW int) string {
|
||||
mark := " "
|
||||
if i == m.cur() {
|
||||
mark = styled(theme.Current().Accent, "▶ ")
|
||||
}
|
||||
th := theme.Current()
|
||||
switch m.section {
|
||||
case sectionImages:
|
||||
im := m.images[i]
|
||||
glyph, color := "○", th.Overlay
|
||||
if m.imageInUse(im.ID) {
|
||||
glyph, color = "●", th.Success
|
||||
}
|
||||
return mark + styled(color, glyph) + " " +
|
||||
text(truncate(imageDisplayName(im), innerW-10)) + dim(" "+humanBytes(im.Size))
|
||||
case sectionVolumes:
|
||||
v := m.volumes[i]
|
||||
return mark + text(truncate(v.Name, innerW-6)) + dim(" "+v.Driver)
|
||||
case sectionNetworks:
|
||||
n := m.networks[i]
|
||||
return mark + text(truncate(n.Name, innerW-6)) + dim(" "+n.Driver)
|
||||
default:
|
||||
c := m.containers[i]
|
||||
glyph, color := stateStyle(c.State)
|
||||
return mark + styled(color, glyph) + " " + text(truncate(c.Name, innerW-4))
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) contentView() string {
|
||||
w, h := m.width-m.leftWidth(), m.bodyHeight()
|
||||
title, meta := i18n.T("section.detail"), ""
|
||||
|
||||
if m.loadErr != nil {
|
||||
return panel(m.sectionName(), "", []string{
|
||||
red(i18n.Tf("error.loadSection", m.sectionName())), "",
|
||||
dim(truncate(m.loadErr.Error(), w-6)),
|
||||
}, w, h, false)
|
||||
}
|
||||
if m.sectionLen() == 0 {
|
||||
return panel(m.sectionName(), "", []string{
|
||||
dim(i18n.Tf("hint.emptyItems", m.sectionName())),
|
||||
}, w, h, false)
|
||||
}
|
||||
|
||||
idx := min(m.cur(), m.sectionLen()-1)
|
||||
title, meta := m.sectionName(), ""
|
||||
var lines []string
|
||||
switch {
|
||||
case m.loadErr != nil:
|
||||
lines = append(lines, red(i18n.T("error.loadContainers")), "", dim(truncate(m.loadErr.Error(), w-6)))
|
||||
case len(m.containers) == 0:
|
||||
lines = append(lines, dim(i18n.T("hint.noContainers")))
|
||||
|
||||
switch m.section {
|
||||
case sectionImages:
|
||||
im := m.images[idx]
|
||||
title, meta = imageDisplayName(im), humanBytes(im.Size)
|
||||
tags := strings.Join(im.Tags, ", ")
|
||||
if tags == "" {
|
||||
tags = i18n.T("state.untagged")
|
||||
}
|
||||
inUse := i18n.T("state.no")
|
||||
if m.imageInUse(im.ID) {
|
||||
inUse = i18n.T("state.yes")
|
||||
}
|
||||
lines = append(lines,
|
||||
field(i18n.T("field.id"))+dim(im.ID),
|
||||
field(i18n.T("field.tags"))+dim(truncate(tags, w-12)),
|
||||
field(i18n.T("field.size"))+dim(humanBytes(im.Size)),
|
||||
field(i18n.T("field.created"))+dim(time.Unix(im.Created, 0).Format("02 Jan 2006")),
|
||||
field(i18n.T("field.inUse"))+dim(inUse),
|
||||
)
|
||||
case sectionVolumes:
|
||||
v := m.volumes[idx]
|
||||
title, meta = v.Name, v.Driver
|
||||
lines = append(lines,
|
||||
field(i18n.T("field.driver"))+dim(v.Driver),
|
||||
field(i18n.T("field.mountpoint"))+dim(truncate(v.Mountpoint, w-14)),
|
||||
)
|
||||
case sectionNetworks:
|
||||
n := m.networks[idx]
|
||||
title, meta = n.Name, n.Driver
|
||||
lines = append(lines,
|
||||
field(i18n.T("field.id"))+dim(n.ID),
|
||||
field(i18n.T("field.driver"))+dim(n.Driver),
|
||||
field(i18n.T("field.scope"))+dim(n.Scope),
|
||||
field(i18n.T("field.subnet"))+dim(n.Subnet),
|
||||
)
|
||||
default:
|
||||
c := m.containers[min(m.cursor, len(m.containers)-1)]
|
||||
c := m.containers[idx]
|
||||
glyph, color := stateStyle(c.State)
|
||||
title, meta = c.Name, c.State
|
||||
lines = append(lines,
|
||||
|
||||
@@ -154,6 +154,21 @@ func humanKBs(kb uint64) string {
|
||||
return strings.Replace(humanKB(kb), " ", "", 1)
|
||||
}
|
||||
|
||||
// humanBytes — ukuran byte → "1.4 GB" (untuk image).
|
||||
func humanBytes(n int64) string {
|
||||
f := float64(n)
|
||||
units := []string{"B", "KB", "MB", "GB", "TB"}
|
||||
i := 0
|
||||
for f >= 1024 && i < len(units)-1 {
|
||||
f /= 1024
|
||||
i++
|
||||
}
|
||||
if i <= 1 {
|
||||
return fmt.Sprintf("%.0f %s", f, units[i])
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", f, units[i])
|
||||
}
|
||||
|
||||
func humanDuration(sec float64) string {
|
||||
d := time.Duration(sec) * time.Second
|
||||
days := int(d.Hours()) / 24
|
||||
|
||||
+106
-10
@@ -8,6 +8,9 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/image"
|
||||
"github.com/docker/docker/api/types/network"
|
||||
"github.com/docker/docker/api/types/volume"
|
||||
"github.com/docker/docker/client"
|
||||
)
|
||||
|
||||
@@ -65,11 +68,12 @@ func (c *Client) Ping(ctx context.Context) error {
|
||||
|
||||
// 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)"
|
||||
ID string
|
||||
ImageID string // id image lengkap (untuk menandai image yang dipakai)
|
||||
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).
|
||||
@@ -89,12 +93,104 @@ func (c *Client) ListContainers(ctx context.Context) ([]Container, error) {
|
||||
id = id[:12]
|
||||
}
|
||||
out = append(out, Container{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Image: ct.Image,
|
||||
State: ct.State,
|
||||
Status: ct.Status,
|
||||
ID: id,
|
||||
ImageID: ct.ImageID,
|
||||
Name: name,
|
||||
Image: ct.Image,
|
||||
State: ct.State,
|
||||
Status: ct.Status,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Image — ringkasan image untuk daftar.
|
||||
type Image struct {
|
||||
ID string // 12 char tanpa prefix sha256:
|
||||
Tags []string
|
||||
Size int64
|
||||
Created int64 // unix
|
||||
}
|
||||
|
||||
func (c *Client) ListImages(ctx context.Context) ([]Image, error) {
|
||||
list, err := c.cli.ImageList(ctx, image.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker: image list: %w", err)
|
||||
}
|
||||
out := make([]Image, 0, len(list))
|
||||
for _, im := range list {
|
||||
tags := im.RepoTags
|
||||
if len(tags) == 1 && tags[0] == "<none>:<none>" {
|
||||
tags = nil
|
||||
}
|
||||
out = append(out, Image{
|
||||
ID: trimID(im.ID),
|
||||
Tags: tags,
|
||||
Size: im.Size,
|
||||
Created: im.Created,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Volume — ringkasan volume.
|
||||
type Volume struct {
|
||||
Name string
|
||||
Driver string
|
||||
Mountpoint string
|
||||
}
|
||||
|
||||
func (c *Client) ListVolumes(ctx context.Context) ([]Volume, error) {
|
||||
resp, err := c.cli.VolumeList(ctx, volume.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker: volume list: %w", err)
|
||||
}
|
||||
out := make([]Volume, 0, len(resp.Volumes))
|
||||
for _, v := range resp.Volumes {
|
||||
out = append(out, Volume{Name: v.Name, Driver: v.Driver, Mountpoint: v.Mountpoint})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Network — ringkasan jaringan (subnet pertama dari IPAM).
|
||||
type Network struct {
|
||||
ID string
|
||||
Name string
|
||||
Driver string
|
||||
Scope string
|
||||
Subnet string
|
||||
}
|
||||
|
||||
func (c *Client) ListNetworks(ctx context.Context) ([]Network, error) {
|
||||
list, err := c.cli.NetworkList(ctx, network.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker: network list: %w", err)
|
||||
}
|
||||
out := make([]Network, 0, len(list))
|
||||
for _, n := range list {
|
||||
subnet := ""
|
||||
for _, cfg := range n.IPAM.Config {
|
||||
if cfg.Subnet != "" {
|
||||
subnet = cfg.Subnet
|
||||
break
|
||||
}
|
||||
}
|
||||
out = append(out, Network{
|
||||
ID: trimID(n.ID),
|
||||
Name: n.Name,
|
||||
Driver: n.Driver,
|
||||
Scope: n.Scope,
|
||||
Subnet: subnet,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// trimID — buang prefix sha256: dan potong ke 12 karakter.
|
||||
func trimID(s string) string {
|
||||
s = strings.TrimPrefix(s, "sha256:")
|
||||
if len(s) > 12 {
|
||||
s = s[:12]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -47,4 +47,18 @@ func TestIntegrationInfo(t *testing.T) {
|
||||
}
|
||||
t.Logf(" - %-20s %-10s %s", ct.Name, ct.State, ct.Image)
|
||||
}
|
||||
|
||||
images, err := c.ListImages(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListImages: %v", err)
|
||||
}
|
||||
volumes, err := c.ListVolumes(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListVolumes: %v", err)
|
||||
}
|
||||
networks, err := c.ListNetworks(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListNetworks: %v", err)
|
||||
}
|
||||
t.Logf("images=%d volumes=%d networks=%d", len(images), len(volumes), len(networks))
|
||||
}
|
||||
@@ -10,6 +10,9 @@ section:
|
||||
clock: "Clock"
|
||||
host: "Host"
|
||||
containers: "Containers"
|
||||
images: "Images"
|
||||
volumes: "Volumes"
|
||||
networks: "Networks"
|
||||
activity: "Activity"
|
||||
detail: "Detail"
|
||||
|
||||
@@ -26,6 +29,9 @@ theme:
|
||||
field:
|
||||
id: "ID"
|
||||
image: "Image"
|
||||
tags: "Tags"
|
||||
size: "Size"
|
||||
created: "Created"
|
||||
state: "State"
|
||||
status: "Status"
|
||||
host: "Host"
|
||||
@@ -34,6 +40,11 @@ field:
|
||||
uptime: "Uptime"
|
||||
engine: "Engine"
|
||||
api: "API"
|
||||
driver: "Driver"
|
||||
mountpoint: "Mountpoint"
|
||||
scope: "Scope"
|
||||
subnet: "Subnet"
|
||||
inUse: "In use"
|
||||
|
||||
state:
|
||||
loading: "loading…"
|
||||
@@ -45,6 +56,9 @@ state:
|
||||
restarting: "restarting"
|
||||
created: "created"
|
||||
dead: "dead"
|
||||
untagged: "<untagged>"
|
||||
yes: "yes"
|
||||
no: "no"
|
||||
|
||||
error:
|
||||
fail: "FAILED"
|
||||
@@ -52,14 +66,16 @@ error:
|
||||
ok: "OK"
|
||||
loadFailed: "Failed to load"
|
||||
loadContainers: "Failed to load container list"
|
||||
loadSection: "Failed to load %s list"
|
||||
|
||||
hint:
|
||||
retry: "press r to retry"
|
||||
noContainers: "No containers yet — run something with Docker, then press r."
|
||||
miniNoContainers: "no containers yet — press 1"
|
||||
emptyItems: "no %s yet"
|
||||
tabsComing: "Detail tabs (Logs · Stats · Config) coming in Phase 2."
|
||||
footerHome: "[1/enter] containers · [t] toast · [T] theme · [a] reduced-motion · [q] quit"
|
||||
footerContainers: "[j/k] select · [r] reload · [0/esc] home · [T] theme · [q] quit"
|
||||
footerContainers: "[1-4] sections · [j/k] select · [r] reload · [0/esc] home · [T] theme · [q] quit"
|
||||
|
||||
footer:
|
||||
status: "%s animation %s fps · audio: %s"
|
||||
|
||||
@@ -10,6 +10,9 @@ section:
|
||||
clock: "Jam"
|
||||
host: "Host"
|
||||
containers: "Kontainer"
|
||||
images: "Image"
|
||||
volumes: "Volume"
|
||||
networks: "Jaringan"
|
||||
activity: "Aktivitas"
|
||||
detail: "Detail"
|
||||
|
||||
@@ -26,6 +29,9 @@ theme:
|
||||
field:
|
||||
id: "ID"
|
||||
image: "Image"
|
||||
tags: "Tags"
|
||||
size: "Size"
|
||||
created: "Created"
|
||||
state: "State"
|
||||
status: "Status"
|
||||
host: "Host"
|
||||
@@ -34,6 +40,11 @@ field:
|
||||
uptime: "Uptime"
|
||||
engine: "Engine"
|
||||
api: "API"
|
||||
driver: "Driver"
|
||||
mountpoint: "Mountpoint"
|
||||
scope: "Scope"
|
||||
subnet: "Subnet"
|
||||
inUse: "Dipakai"
|
||||
|
||||
state:
|
||||
loading: "memuat…"
|
||||
@@ -45,6 +56,9 @@ state:
|
||||
restarting: "memulai ulang"
|
||||
created: "dibuat"
|
||||
dead: "mati"
|
||||
untagged: "<tanpa tag>"
|
||||
yes: "ya"
|
||||
no: "tidak"
|
||||
|
||||
error:
|
||||
fail: "GAGAL"
|
||||
@@ -52,14 +66,16 @@ error:
|
||||
ok: "OK"
|
||||
loadFailed: "GAGAL memuat"
|
||||
loadContainers: "Gagal memuat daftar kontainer"
|
||||
loadSection: "Gagal memuat daftar %s"
|
||||
|
||||
hint:
|
||||
retry: "tekan r untuk coba lagi"
|
||||
noContainers: "Belum ada kontainer — jalankan sesuatu dengan Docker, lalu tekan r."
|
||||
miniNoContainers: "belum ada kontainer — tekan 1"
|
||||
emptyItems: "belum ada %s"
|
||||
tabsComing: "Tab detail (Log · Statistik · Konfigurasi) menyusul di Phase 2."
|
||||
footerHome: "[1/enter] kontainer · [t] toast · [T] tema · [a] reduced-motion · [q] keluar"
|
||||
footerContainers: "[j/k] pilih · [r] muat ulang · [0/esc] beranda · [T] tema · [q] keluar"
|
||||
footerContainers: "[1-4] bagian · [j/k] pilih · [r] muat ulang · [0/esc] beranda · [T] tema · [q] keluar"
|
||||
|
||||
footer:
|
||||
status: "%s animasi %s fps · audio: %s"
|
||||
|
||||
Reference in new issue
Block a user