feat(ui): bagian Image/Volume/Jaringan di workspace (F-003) + fix closure applyList + emulator ESC[J penuh
CI / test (push) Successful in 12m9s

This commit is contained in:
oxpoe committed 2026-10-15 14:06:51 +07:00
1 parent 8b2a86877e
commit a28b83513d
8 files changed
+487 -73

No files matched your search

+17
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+15
View File
@@ -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
View File
@@ -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
}
+14
View File
@@ -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))
}
+17 -1
View File
@@ -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"
+17 -1
View File
@@ -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"