diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index f3f1938..c66a00a 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -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) diff --git a/e2e/screen_test.go b/e2e/screen_test.go index 4f48567..9f0e224 100644 --- a/e2e/screen_test.go +++ b/e2e/screen_test.go @@ -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': diff --git a/internal/app/app.go b/internal/app/app.go index 4813f04..1fcfb21 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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, diff --git a/internal/app/home.go b/internal/app/home.go index c3b1a12..d55a286 100644 --- a/internal/app/home.go +++ b/internal/app/home.go @@ -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 diff --git a/internal/docker/client.go b/internal/docker/client.go index 2c31121..93e8150 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -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] == ":" { + 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 +} diff --git a/internal/docker/client_test.go b/internal/docker/client_test.go index 71bf2e2..66b0d94 100644 --- a/internal/docker/client_test.go +++ b/internal/docker/client_test.go @@ -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)) } diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 112e47e..bf4f4f7 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -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: "" + 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" diff --git a/internal/i18n/locales/id.yaml b/internal/i18n/locales/id.yaml index d346053..7cb8d61 100644 --- a/internal/i18n/locales/id.yaml +++ b/internal/i18n/locales/id.yaml @@ -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: "" + 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"