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
+472
-58
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) {
|
func TestThemeCycle(t *testing.T) {
|
||||||
u := startTUI(t)
|
u := startTUI(t)
|
||||||
u.waitContains("beranda", 5*time.Second)
|
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.curR = at(nums, 0, 1) - 1
|
||||||
s.clampCursor()
|
s.clampCursor()
|
||||||
case 'J':
|
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()
|
s.clearAll()
|
||||||
}
|
}
|
||||||
case 'K':
|
case 'K':
|
||||||
|
|||||||
+276
-55
@@ -24,16 +24,38 @@ type viewKind int
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
viewHome viewKind = iota
|
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 {
|
type containersMsg struct {
|
||||||
items []docker.Container
|
items []docker.Container
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
type imagesMsg struct {
|
||||||
// infoMsg — ringkasan daemon docker.
|
items []docker.Image
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
type volumesMsg struct {
|
||||||
|
items []docker.Volume
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
type networksMsg struct {
|
||||||
|
items []docker.Network
|
||||||
|
err error
|
||||||
|
}
|
||||||
type infoMsg struct {
|
type infoMsg struct {
|
||||||
info docker.Info
|
info docker.Info
|
||||||
err error
|
err error
|
||||||
@@ -52,15 +74,21 @@ type Model struct {
|
|||||||
lastFPSAt time.Time
|
lastFPSAt time.Time
|
||||||
|
|
||||||
view viewKind
|
view viewKind
|
||||||
|
section section
|
||||||
|
|
||||||
hostname string
|
hostname string
|
||||||
sys sysinfo.Stats
|
sys sysinfo.Stats
|
||||||
|
|
||||||
cl *docker.Client
|
cl *docker.Client
|
||||||
engine docker.Info
|
engine docker.Info
|
||||||
engineErr error
|
|
||||||
containers []docker.Container
|
containers []docker.Container
|
||||||
cursor int
|
images []docker.Image
|
||||||
|
volumes []docker.Volume
|
||||||
|
networks []docker.Network
|
||||||
|
cursors [sectionCount]int
|
||||||
|
|
||||||
|
engineErr error
|
||||||
loadErr error
|
loadErr error
|
||||||
loading bool
|
loading bool
|
||||||
}
|
}
|
||||||
@@ -87,6 +115,7 @@ func New() Model {
|
|||||||
animating: cfg.UI.AnimationEnabled,
|
animating: cfg.UI.AnimationEnabled,
|
||||||
toast: components.NewToast(),
|
toast: components.NewToast(),
|
||||||
view: viewHome,
|
view: viewHome,
|
||||||
|
section: sectionContainers,
|
||||||
hostname: host,
|
hostname: host,
|
||||||
sys: sysinfo.Read(),
|
sys: sysinfo.Read(),
|
||||||
cl: cl,
|
cl: cl,
|
||||||
@@ -109,11 +138,21 @@ func (m Model) Init() tea.Cmd {
|
|||||||
cmds = append(cmds, animation.Next(m.tickID))
|
cmds = append(cmds, animation.Next(m.tickID))
|
||||||
}
|
}
|
||||||
if m.cl != nil {
|
if m.cl != nil {
|
||||||
cmds = append(cmds, loadContainers(m.cl), loadInfo(m.cl))
|
cmds = append(cmds, loadAll(m.cl)...)
|
||||||
}
|
}
|
||||||
return tea.Batch(cmds...)
|
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 {
|
func loadContainers(cl *docker.Client) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
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 {
|
func loadInfo(cl *docker.Client) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
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)
|
return m, animation.Next(m.tickID)
|
||||||
|
|
||||||
case containersMsg:
|
case containersMsg:
|
||||||
m.loading = false
|
return m.applyList(msg.err, func(mm *Model) { mm.containers = msg.items })
|
||||||
if msg.err != nil {
|
|
||||||
m.loadErr = msg.err
|
case imagesMsg:
|
||||||
m.toast.Show(components.LevelError, i18n.T("toast.dockerError"), msg.err.Error())
|
return m.applyList(msg.err, func(mm *Model) { mm.images = msg.items })
|
||||||
m.sound.Play(audio.NotifyError)
|
|
||||||
} else {
|
case volumesMsg:
|
||||||
m.loadErr = nil
|
return m.applyList(msg.err, func(mm *Model) { mm.volumes = msg.items })
|
||||||
m.containers = msg.items
|
|
||||||
if m.cursor >= len(m.containers) {
|
case networksMsg:
|
||||||
m.cursor = max(0, len(m.containers)-1)
|
return m.applyList(msg.err, func(mm *Model) { mm.networks = msg.items })
|
||||||
}
|
|
||||||
return m, loadInfo(m.cl) // sinkronkan hitungan di dashboard
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case infoMsg:
|
case infoMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
@@ -201,7 +263,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.view = viewHome
|
m.view = viewHome
|
||||||
m.sound.Play(audio.Click)
|
m.sound.Play(audio.Click)
|
||||||
case "1", "enter":
|
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)
|
m.sound.Play(audio.Click)
|
||||||
case "a": // reduced motion
|
case "a": // reduced motion
|
||||||
m.animating = !m.animating
|
m.animating = !m.animating
|
||||||
@@ -209,13 +280,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.tickID = animation.NewID()
|
m.tickID = animation.NewID()
|
||||||
return m, animation.Next(m.tickID)
|
return m, animation.Next(m.tickID)
|
||||||
}
|
}
|
||||||
case "r": // muat ulang data docker
|
case "r": // muat ulang semua data docker
|
||||||
if m.cl == nil {
|
if m.cl == nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
m.loading = true
|
m.loading = true
|
||||||
m.sound.Play(audio.Click)
|
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
|
case "T": // tombol cepat tema (PRD §5.4): auto → dark → light → terminal
|
||||||
cur := theme.Current()
|
cur := theme.Current()
|
||||||
next := theme.CycleMode(cur.Mode)
|
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.toast.Show(components.LevelSuccess, i18n.T("toast.demoTitle"), i18n.T("toast.demoBody"))
|
||||||
m.sound.Play(audio.NotifySuccess)
|
m.sound.Play(audio.NotifySuccess)
|
||||||
case "j", "down":
|
case "j", "down":
|
||||||
if len(m.containers) > 0 && m.cursor < len(m.containers)-1 {
|
if c := m.cur(); c < m.sectionLen()-1 {
|
||||||
m.cursor++
|
m.setCur(c + 1)
|
||||||
}
|
}
|
||||||
m.sound.Play(audio.Navigate)
|
m.sound.Play(audio.Navigate)
|
||||||
case "k", "up":
|
case "k", "up":
|
||||||
if m.cursor > 0 {
|
if c := m.cur(); c > 0 {
|
||||||
m.cursor--
|
m.setCur(c - 1)
|
||||||
}
|
}
|
||||||
m.sound.Play(audio.Navigate)
|
m.sound.Play(audio.Navigate)
|
||||||
}
|
}
|
||||||
@@ -245,12 +316,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.toast.Active() {
|
if m.toast.Active() {
|
||||||
m.toast = components.NewToast() // klik = tutup toast
|
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)
|
// 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()
|
idx := msg.Y - (m.headerRows() + 1) + m.scrollStart()
|
||||||
if idx >= 0 && idx < len(m.containers) {
|
if idx >= 0 && idx < m.sectionLen() {
|
||||||
m.cursor = idx
|
m.setCur(idx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,6 +329,90 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
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 ---
|
// --- layout helpers ---
|
||||||
|
|
||||||
func (m Model) toastRows() int {
|
func (m Model) toastRows() int {
|
||||||
@@ -294,10 +449,10 @@ func (m Model) leftWidth() int {
|
|||||||
// scrollStart — offset window daftar agar kursor selalu terlihat.
|
// scrollStart — offset window daftar agar kursor selalu terlihat.
|
||||||
func (m Model) scrollStart() int {
|
func (m Model) scrollStart() int {
|
||||||
rows := m.bodyHeight() - 2 // konten dalam panel (tanpa border)
|
rows := m.bodyHeight() - 2 // konten dalam panel (tanpa border)
|
||||||
if rows <= 0 || m.cursor < rows {
|
if rows <= 0 || m.cur() < rows {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return m.cursor - rows + 1
|
return m.cur() - rows + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func stateStyle(state string) (glyph string, c theme.Color) {
|
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 {
|
func dim(s string) string {
|
||||||
return theme.Style(theme.Current().Subtext).Render(s)
|
return theme.Style(theme.Current().Subtext).Render(s)
|
||||||
@@ -436,7 +591,8 @@ func (m Model) footerLine(hints string) string {
|
|||||||
|
|
||||||
func (m Model) sidebarView() string {
|
func (m Model) sidebarView() string {
|
||||||
w, h := m.leftWidth(), m.bodyHeight()
|
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 {
|
if m.loading {
|
||||||
meta = i18n.T("state.loading")
|
meta = i18n.T("state.loading")
|
||||||
}
|
}
|
||||||
@@ -445,39 +601,104 @@ func (m Model) sidebarView() string {
|
|||||||
switch {
|
switch {
|
||||||
case m.loadErr != nil:
|
case m.loadErr != nil:
|
||||||
lines = append(lines, red(i18n.T("error.loadFailed")), dim(i18n.T("hint.retry")))
|
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")))
|
lines = append(lines, dim(i18n.T("state.loading")))
|
||||||
case len(m.containers) == 0:
|
case m.sectionLen() == 0:
|
||||||
lines = append(lines, dim(i18n.T("state.noContainers")))
|
lines = append(lines, dim(i18n.Tf("hint.emptyItems", title)))
|
||||||
default:
|
default:
|
||||||
rows := h - 2
|
rows := h - 2
|
||||||
start := m.scrollStart()
|
start := m.scrollStart()
|
||||||
innerW := w - 4
|
for i := start; i < m.sectionLen() && i-start < rows; i++ {
|
||||||
for i := start; i < len(m.containers) && i-start < rows; i++ {
|
lines = append(lines, m.sectionLine(i, w-4))
|
||||||
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)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return panel(title, meta, lines, w, h, false)
|
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 {
|
func (m Model) contentView() string {
|
||||||
w, h := m.width-m.leftWidth(), m.bodyHeight()
|
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
|
var lines []string
|
||||||
switch {
|
|
||||||
case m.loadErr != nil:
|
switch m.section {
|
||||||
lines = append(lines, red(i18n.T("error.loadContainers")), "", dim(truncate(m.loadErr.Error(), w-6)))
|
case sectionImages:
|
||||||
case len(m.containers) == 0:
|
im := m.images[idx]
|
||||||
lines = append(lines, dim(i18n.T("hint.noContainers")))
|
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:
|
default:
|
||||||
c := m.containers[min(m.cursor, len(m.containers)-1)]
|
c := m.containers[idx]
|
||||||
glyph, color := stateStyle(c.State)
|
glyph, color := stateStyle(c.State)
|
||||||
title, meta = c.Name, c.State
|
title, meta = c.Name, c.State
|
||||||
lines = append(lines,
|
lines = append(lines,
|
||||||
|
|||||||
@@ -154,6 +154,21 @@ func humanKBs(kb uint64) string {
|
|||||||
return strings.Replace(humanKB(kb), " ", "", 1)
|
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 {
|
func humanDuration(sec float64) string {
|
||||||
d := time.Duration(sec) * time.Second
|
d := time.Duration(sec) * time.Second
|
||||||
days := int(d.Hours()) / 24
|
days := int(d.Hours()) / 24
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"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"
|
"github.com/docker/docker/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,6 +69,7 @@ func (c *Client) Ping(ctx context.Context) error {
|
|||||||
// Container — ringkasan kontainer untuk sidebar/dashboard.
|
// Container — ringkasan kontainer untuk sidebar/dashboard.
|
||||||
type Container struct {
|
type Container struct {
|
||||||
ID string
|
ID string
|
||||||
|
ImageID string // id image lengkap (untuk menandai image yang dipakai)
|
||||||
Name string
|
Name string
|
||||||
Image string
|
Image string
|
||||||
State string // running, exited, paused, restarting, created, dead
|
State string // running, exited, paused, restarting, created, dead
|
||||||
@@ -90,6 +94,7 @@ func (c *Client) ListContainers(ctx context.Context) ([]Container, error) {
|
|||||||
}
|
}
|
||||||
out = append(out, Container{
|
out = append(out, Container{
|
||||||
ID: id,
|
ID: id,
|
||||||
|
ImageID: ct.ImageID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Image: ct.Image,
|
Image: ct.Image,
|
||||||
State: ct.State,
|
State: ct.State,
|
||||||
@@ -98,3 +103,94 @@ func (c *Client) ListContainers(ctx context.Context) ([]Container, error) {
|
|||||||
}
|
}
|
||||||
return out, nil
|
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)
|
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"
|
clock: "Clock"
|
||||||
host: "Host"
|
host: "Host"
|
||||||
containers: "Containers"
|
containers: "Containers"
|
||||||
|
images: "Images"
|
||||||
|
volumes: "Volumes"
|
||||||
|
networks: "Networks"
|
||||||
activity: "Activity"
|
activity: "Activity"
|
||||||
detail: "Detail"
|
detail: "Detail"
|
||||||
|
|
||||||
@@ -26,6 +29,9 @@ theme:
|
|||||||
field:
|
field:
|
||||||
id: "ID"
|
id: "ID"
|
||||||
image: "Image"
|
image: "Image"
|
||||||
|
tags: "Tags"
|
||||||
|
size: "Size"
|
||||||
|
created: "Created"
|
||||||
state: "State"
|
state: "State"
|
||||||
status: "Status"
|
status: "Status"
|
||||||
host: "Host"
|
host: "Host"
|
||||||
@@ -34,6 +40,11 @@ field:
|
|||||||
uptime: "Uptime"
|
uptime: "Uptime"
|
||||||
engine: "Engine"
|
engine: "Engine"
|
||||||
api: "API"
|
api: "API"
|
||||||
|
driver: "Driver"
|
||||||
|
mountpoint: "Mountpoint"
|
||||||
|
scope: "Scope"
|
||||||
|
subnet: "Subnet"
|
||||||
|
inUse: "In use"
|
||||||
|
|
||||||
state:
|
state:
|
||||||
loading: "loading…"
|
loading: "loading…"
|
||||||
@@ -45,6 +56,9 @@ state:
|
|||||||
restarting: "restarting"
|
restarting: "restarting"
|
||||||
created: "created"
|
created: "created"
|
||||||
dead: "dead"
|
dead: "dead"
|
||||||
|
untagged: "<untagged>"
|
||||||
|
yes: "yes"
|
||||||
|
no: "no"
|
||||||
|
|
||||||
error:
|
error:
|
||||||
fail: "FAILED"
|
fail: "FAILED"
|
||||||
@@ -52,14 +66,16 @@ error:
|
|||||||
ok: "OK"
|
ok: "OK"
|
||||||
loadFailed: "Failed to load"
|
loadFailed: "Failed to load"
|
||||||
loadContainers: "Failed to load container list"
|
loadContainers: "Failed to load container list"
|
||||||
|
loadSection: "Failed to load %s list"
|
||||||
|
|
||||||
hint:
|
hint:
|
||||||
retry: "press r to retry"
|
retry: "press r to retry"
|
||||||
noContainers: "No containers yet — run something with Docker, then press r."
|
noContainers: "No containers yet — run something with Docker, then press r."
|
||||||
miniNoContainers: "no containers yet — press 1"
|
miniNoContainers: "no containers yet — press 1"
|
||||||
|
emptyItems: "no %s yet"
|
||||||
tabsComing: "Detail tabs (Logs · Stats · Config) coming in Phase 2."
|
tabsComing: "Detail tabs (Logs · Stats · Config) coming in Phase 2."
|
||||||
footerHome: "[1/enter] containers · [t] toast · [T] theme · [a] reduced-motion · [q] quit"
|
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:
|
footer:
|
||||||
status: "%s animation %s fps · audio: %s"
|
status: "%s animation %s fps · audio: %s"
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ section:
|
|||||||
clock: "Jam"
|
clock: "Jam"
|
||||||
host: "Host"
|
host: "Host"
|
||||||
containers: "Kontainer"
|
containers: "Kontainer"
|
||||||
|
images: "Image"
|
||||||
|
volumes: "Volume"
|
||||||
|
networks: "Jaringan"
|
||||||
activity: "Aktivitas"
|
activity: "Aktivitas"
|
||||||
detail: "Detail"
|
detail: "Detail"
|
||||||
|
|
||||||
@@ -26,6 +29,9 @@ theme:
|
|||||||
field:
|
field:
|
||||||
id: "ID"
|
id: "ID"
|
||||||
image: "Image"
|
image: "Image"
|
||||||
|
tags: "Tags"
|
||||||
|
size: "Size"
|
||||||
|
created: "Created"
|
||||||
state: "State"
|
state: "State"
|
||||||
status: "Status"
|
status: "Status"
|
||||||
host: "Host"
|
host: "Host"
|
||||||
@@ -34,6 +40,11 @@ field:
|
|||||||
uptime: "Uptime"
|
uptime: "Uptime"
|
||||||
engine: "Engine"
|
engine: "Engine"
|
||||||
api: "API"
|
api: "API"
|
||||||
|
driver: "Driver"
|
||||||
|
mountpoint: "Mountpoint"
|
||||||
|
scope: "Scope"
|
||||||
|
subnet: "Subnet"
|
||||||
|
inUse: "Dipakai"
|
||||||
|
|
||||||
state:
|
state:
|
||||||
loading: "memuat…"
|
loading: "memuat…"
|
||||||
@@ -45,6 +56,9 @@ state:
|
|||||||
restarting: "memulai ulang"
|
restarting: "memulai ulang"
|
||||||
created: "dibuat"
|
created: "dibuat"
|
||||||
dead: "mati"
|
dead: "mati"
|
||||||
|
untagged: "<tanpa tag>"
|
||||||
|
yes: "ya"
|
||||||
|
no: "tidak"
|
||||||
|
|
||||||
error:
|
error:
|
||||||
fail: "GAGAL"
|
fail: "GAGAL"
|
||||||
@@ -52,14 +66,16 @@ error:
|
|||||||
ok: "OK"
|
ok: "OK"
|
||||||
loadFailed: "GAGAL memuat"
|
loadFailed: "GAGAL memuat"
|
||||||
loadContainers: "Gagal memuat daftar kontainer"
|
loadContainers: "Gagal memuat daftar kontainer"
|
||||||
|
loadSection: "Gagal memuat daftar %s"
|
||||||
|
|
||||||
hint:
|
hint:
|
||||||
retry: "tekan r untuk coba lagi"
|
retry: "tekan r untuk coba lagi"
|
||||||
noContainers: "Belum ada kontainer — jalankan sesuatu dengan Docker, lalu tekan r."
|
noContainers: "Belum ada kontainer — jalankan sesuatu dengan Docker, lalu tekan r."
|
||||||
miniNoContainers: "belum ada kontainer — tekan 1"
|
miniNoContainers: "belum ada kontainer — tekan 1"
|
||||||
|
emptyItems: "belum ada %s"
|
||||||
tabsComing: "Tab detail (Log · Statistik · Konfigurasi) menyusul di Phase 2."
|
tabsComing: "Tab detail (Log · Statistik · Konfigurasi) menyusul di Phase 2."
|
||||||
footerHome: "[1/enter] kontainer · [t] toast · [T] tema · [a] reduced-motion · [q] keluar"
|
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:
|
footer:
|
||||||
status: "%s animasi %s fps · audio: %s"
|
status: "%s animasi %s fps · audio: %s"
|
||||||
|
|||||||
Reference in new issue
Block a user