715 lines
17 KiB
Go
715 lines
17 KiB
Go
package app
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "charm.land/bubbletea/v2"
|
||
"charm.land/lipgloss/v2"
|
||
|
||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio"
|
||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/config"
|
||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker"
|
||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n"
|
||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/sysinfo"
|
||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/animation"
|
||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components"
|
||
"gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/theme"
|
||
)
|
||
|
||
type viewKind int
|
||
|
||
const (
|
||
viewHome viewKind = iota
|
||
viewList
|
||
)
|
||
|
||
// 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
|
||
}
|
||
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
|
||
}
|
||
|
||
type Model struct {
|
||
width, height int
|
||
sound *audio.Engine
|
||
toast components.Toast
|
||
|
||
tickID int64
|
||
animating bool
|
||
phase float64
|
||
frames int
|
||
fps int
|
||
lastFPSAt time.Time
|
||
|
||
view viewKind
|
||
section section
|
||
|
||
hostname string
|
||
sys sysinfo.Stats
|
||
|
||
cl *docker.Client
|
||
engine docker.Info
|
||
|
||
containers []docker.Container
|
||
images []docker.Image
|
||
volumes []docker.Volume
|
||
networks []docker.Network
|
||
cursors [sectionCount]int
|
||
|
||
engineErr error
|
||
loadErr error
|
||
loading bool
|
||
}
|
||
|
||
func New() Model {
|
||
cfg, cfgErr := config.Load()
|
||
// shortcut: TIA_LANG = override khusus dev/tes; sumber resmi bahasa
|
||
// tetap config (ui.language) — dihapus saat layar Pengaturan jadi.
|
||
if l := os.Getenv("TIA_LANG"); l != "" {
|
||
cfg.UI.Language = l
|
||
}
|
||
i18n.SetLang(cfg.UI.Language)
|
||
theme.Init(theme.Mode(cfg.UI.ThemeMode), cfg.UI.ThemeAccent)
|
||
|
||
cl, err := docker.New()
|
||
host, _ := os.Hostname()
|
||
m := Model{
|
||
sound: audio.New(audio.Options{
|
||
Enabled: cfg.UI.SoundEnabled,
|
||
Volume: cfg.UI.SoundVolume,
|
||
MuteOnSSH: cfg.UI.SoundMuteOnSSH,
|
||
}),
|
||
tickID: animation.NewID(),
|
||
animating: cfg.UI.AnimationEnabled,
|
||
toast: components.NewToast(),
|
||
view: viewHome,
|
||
section: sectionContainers,
|
||
hostname: host,
|
||
sys: sysinfo.Read(),
|
||
cl: cl,
|
||
loading: true,
|
||
}
|
||
if cfgErr != nil {
|
||
m.toast.Show(components.LevelWarning, i18n.T("toast.configError"), cfgErr.Error())
|
||
}
|
||
if err != nil {
|
||
m.loadErr = err
|
||
m.engineErr = err
|
||
m.loading = false
|
||
}
|
||
return m
|
||
}
|
||
|
||
func (m Model) Init() tea.Cmd {
|
||
cmds := []tea.Cmd{tea.RequestBackgroundColor} // untuk mode tema "auto"
|
||
if m.animating {
|
||
cmds = append(cmds, animation.Next(m.tickID))
|
||
}
|
||
if m.cl != nil {
|
||
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)
|
||
defer cancel()
|
||
items, err := cl.ListContainers(ctx)
|
||
return containersMsg{items: items, err: err}
|
||
}
|
||
}
|
||
|
||
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)
|
||
defer cancel()
|
||
info, err := cl.Info(ctx)
|
||
return infoMsg{info: info, err: err}
|
||
}
|
||
}
|
||
|
||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case animation.TickMsg:
|
||
if msg.ID != m.tickID || !m.animating {
|
||
return m, nil // tick basi / reduced-motion
|
||
}
|
||
m.phase += 0.9
|
||
m.toast.Update(animation.FrameInterval)
|
||
m.frames++
|
||
if m.lastFPSAt.IsZero() {
|
||
m.lastFPSAt = msg.Time
|
||
}
|
||
if d := msg.Time.Sub(m.lastFPSAt); d >= time.Second {
|
||
m.fps = int(float64(m.frames) / d.Seconds())
|
||
m.frames = 0
|
||
m.lastFPSAt = msg.Time
|
||
m.sys = sysinfo.Read() // metrik host: refresh 1×/detik
|
||
}
|
||
return m, animation.Next(m.tickID)
|
||
|
||
case containersMsg:
|
||
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 {
|
||
m.engineErr = msg.err
|
||
} else {
|
||
m.engineErr = nil
|
||
m.engine = msg.info
|
||
}
|
||
return m, nil
|
||
|
||
case tea.BackgroundColorMsg:
|
||
// Mode tema "auto": Mocha vs Latte sesuai background terminal.
|
||
theme.SetDetectedDark(msg.IsDark())
|
||
return m, nil
|
||
|
||
case tea.WindowSizeMsg:
|
||
m.width, m.height = msg.Width, msg.Height
|
||
|
||
case tea.KeyPressMsg:
|
||
switch msg.String() {
|
||
case "q", "ctrl+c":
|
||
m.sound.Play(audio.ModalClose)
|
||
return m, tea.Quit
|
||
case "esc":
|
||
if m.view == viewHome {
|
||
m.sound.Play(audio.ModalClose)
|
||
return m, tea.Quit
|
||
}
|
||
m.view = viewHome
|
||
m.sound.Play(audio.Click)
|
||
case "0":
|
||
m.view = viewHome
|
||
m.sound.Play(audio.Click)
|
||
case "1", "enter":
|
||
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
|
||
if m.animating {
|
||
m.tickID = animation.NewID()
|
||
return m, animation.Next(m.tickID)
|
||
}
|
||
case "r": // muat ulang semua data docker
|
||
if m.cl == nil {
|
||
break
|
||
}
|
||
m.loading = true
|
||
m.sound.Play(audio.Click)
|
||
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)
|
||
theme.Set(next, cur.AccentName)
|
||
m.toast.Show(components.LevelInfo,
|
||
i18n.Tf("toast.themeMode", i18n.T("theme."+string(next))),
|
||
i18n.T("theme.switchHint"))
|
||
m.sound.Play(audio.Click)
|
||
case "t": // demo toast (sampai modal asli datang)
|
||
m.toast.Show(components.LevelSuccess, i18n.T("toast.demoTitle"), i18n.T("toast.demoBody"))
|
||
m.sound.Play(audio.NotifySuccess)
|
||
case "j", "down":
|
||
if c := m.cur(); c < m.sectionLen()-1 {
|
||
m.setCur(c + 1)
|
||
}
|
||
m.sound.Play(audio.Navigate)
|
||
case "k", "up":
|
||
if c := m.cur(); c > 0 {
|
||
m.setCur(c - 1)
|
||
}
|
||
m.sound.Play(audio.Navigate)
|
||
}
|
||
|
||
case tea.MouseClickMsg:
|
||
if msg.Button == tea.MouseLeft {
|
||
m.sound.Play(audio.Click)
|
||
if m.toast.Active() {
|
||
m.toast = components.NewToast() // klik = tutup toast
|
||
}
|
||
// hit-test sidebar (hanya di view list):
|
||
// baris item pertama = headerRows + 1 (border atas panel)
|
||
if m.view == viewList && msg.X < m.leftWidth() && m.sectionLen() > 0 {
|
||
idx := msg.Y - (m.headerRows() + 1) + m.scrollStart()
|
||
if idx >= 0 && idx < m.sectionLen() {
|
||
m.setCur(idx)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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 {
|
||
if m.toast.Active() {
|
||
return 5 // 4 baris toast + 1 baris jarak
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func (m Model) headerRows() int { return 1 + m.toastRows() }
|
||
|
||
func (m Model) bodyHeight() int {
|
||
h := m.height - 3 - m.toastRows() // header(1) + footer(1) + margin(1)
|
||
if h < 5 {
|
||
h = 5
|
||
}
|
||
return h
|
||
}
|
||
|
||
func (m Model) leftWidth() int {
|
||
w := int(float64(m.width) * 0.33)
|
||
if w < 24 {
|
||
w = 24
|
||
}
|
||
if w > 46 {
|
||
w = 46
|
||
}
|
||
if w > m.width-24 {
|
||
w = max(16, m.width-24)
|
||
}
|
||
return w
|
||
}
|
||
|
||
// scrollStart — offset window daftar agar kursor selalu terlihat.
|
||
func (m Model) scrollStart() int {
|
||
rows := m.bodyHeight() - 2 // konten dalam panel (tanpa border)
|
||
if rows <= 0 || m.cur() < rows {
|
||
return 0
|
||
}
|
||
return m.cur() - rows + 1
|
||
}
|
||
|
||
func stateStyle(state string) (glyph string, c theme.Color) {
|
||
th := theme.Current()
|
||
switch state {
|
||
case "running":
|
||
return "●", th.Success
|
||
case "paused":
|
||
return "⏸", th.Warning
|
||
case "restarting", "created":
|
||
return "◌", th.Info
|
||
case "exited", "dead":
|
||
return "○", th.Danger
|
||
default:
|
||
return "○", th.Overlay
|
||
}
|
||
}
|
||
|
||
// --- styling helpers (semua via token tema — PRD §5.4) ---
|
||
|
||
func dim(s string) string {
|
||
return theme.Style(theme.Current().Subtext).Render(s)
|
||
}
|
||
|
||
func text(s string) string {
|
||
return theme.Style(theme.Current().Text).Render(s)
|
||
}
|
||
|
||
func red(s string) string {
|
||
return theme.Style(theme.Current().Danger).Render(s)
|
||
}
|
||
|
||
func styled(c theme.Color, s string) string {
|
||
return theme.Style(c).Render(s)
|
||
}
|
||
|
||
func truncate(s string, w int) string {
|
||
r := []rune(s)
|
||
if w <= 1 || len(r) <= w {
|
||
return s
|
||
}
|
||
return string(r[:w-1]) + "…"
|
||
}
|
||
|
||
// field — label kolom (padding tetap agar nilai sejajar).
|
||
func field(label string) string { return fieldW(label, 7) }
|
||
|
||
func fieldW(label string, w int) string {
|
||
return text(fmt.Sprintf("%-*s", w, label))
|
||
}
|
||
|
||
// panel — kotak gaya mirador: judul di border atas (kiri), meta (kanan),
|
||
// border redup; konten otomatis dipangkas/padded selebar panel.
|
||
func panel(title, meta string, lines []string, w, h int, focused bool) string {
|
||
if w < 6 || h < 2 {
|
||
return ""
|
||
}
|
||
th := theme.Current()
|
||
bc := th.Overlay
|
||
if focused {
|
||
bc = th.Accent
|
||
}
|
||
border := lipgloss.NewStyle().Foreground(lipgloss.Color(string(bc)))
|
||
titleS := text(title)
|
||
|
||
left := border.Render("╭─ ") + titleS
|
||
right := border.Render("─╮")
|
||
if meta != "" {
|
||
right = " " + dim(meta) + border.Render(" ─╮")
|
||
}
|
||
gap := w - lipgloss.Width(left) - lipgloss.Width(right)
|
||
if gap < 1 {
|
||
gap = 1
|
||
}
|
||
|
||
var b strings.Builder
|
||
b.WriteString(left)
|
||
b.WriteString(border.Render(strings.Repeat("─", gap)))
|
||
b.WriteString(right)
|
||
|
||
content := lipgloss.NewStyle().Width(w - 4)
|
||
for i := 0; i < h-2; i++ {
|
||
l := ""
|
||
if i < len(lines) {
|
||
l = lines[i]
|
||
}
|
||
b.WriteString("\n")
|
||
b.WriteString(border.Render("│"))
|
||
b.WriteString(" ")
|
||
b.WriteString(content.Render(ansiTruncate(l, w-4)))
|
||
b.WriteString(" ")
|
||
b.WriteString(border.Render("│"))
|
||
}
|
||
b.WriteString("\n")
|
||
b.WriteString(border.Render("╰" + strings.Repeat("─", w-2) + "╯"))
|
||
return b.String()
|
||
}
|
||
|
||
// --- views ---
|
||
|
||
func (m Model) View() tea.View {
|
||
header := theme.Style(theme.Current().Accent).Bold(true).
|
||
Render("tia — Terminal Is A My Way")
|
||
|
||
var body, footer string
|
||
switch m.view {
|
||
case viewHome:
|
||
header += dim(" · " + i18n.T("nav.home"))
|
||
body = m.homeBody()
|
||
footer = m.footerLine(i18n.T("hint.footerHome"))
|
||
default:
|
||
header += dim(" · " + i18n.T("nav.containers"))
|
||
body = lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView())
|
||
footer = m.footerLine(i18n.T("hint.footerContainers"))
|
||
}
|
||
|
||
parts := []string{}
|
||
if t := m.toast.View(max(m.width, 20)); t != "" {
|
||
parts = append(parts, t)
|
||
}
|
||
parts = append(parts, header, body, footer)
|
||
|
||
v := tea.NewView(strings.Join(parts, "\n"))
|
||
v.AltScreen = true
|
||
v.MouseMode = tea.MouseModeCellMotion
|
||
return v
|
||
}
|
||
|
||
func (m Model) footerLine(hints string) string {
|
||
anim := "—"
|
||
if m.animating {
|
||
anim = fmt.Sprintf("%d", m.fps)
|
||
}
|
||
return dim(truncate(i18n.Tf("footer.status", hints, anim, m.sound.Status()), m.width))
|
||
}
|
||
|
||
func (m Model) sidebarView() string {
|
||
w, h := m.leftWidth(), m.bodyHeight()
|
||
title := m.sectionName()
|
||
meta := fmt.Sprintf("(%d)", m.sectionLen())
|
||
if m.loading {
|
||
meta = i18n.T("state.loading")
|
||
}
|
||
|
||
var lines []string
|
||
switch {
|
||
case m.loadErr != nil:
|
||
lines = append(lines, red(i18n.T("error.loadFailed")), dim(i18n.T("hint.retry")))
|
||
case m.loading && m.sectionLen() == 0:
|
||
lines = append(lines, dim(i18n.T("state.loading")))
|
||
case m.sectionLen() == 0:
|
||
lines = append(lines, dim(i18n.Tf("hint.emptyItems", title)))
|
||
default:
|
||
rows := h - 2
|
||
start := m.scrollStart()
|
||
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()
|
||
|
||
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 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[idx]
|
||
glyph, color := stateStyle(c.State)
|
||
title, meta = c.Name, c.State
|
||
lines = append(lines,
|
||
field(i18n.T("field.id"))+dim(c.ID),
|
||
field(i18n.T("field.image"))+dim(truncate(c.Image, w-20)),
|
||
field(i18n.T("field.state"))+styled(color, glyph+" "+i18n.TOr("state."+c.State, c.State)),
|
||
field(i18n.T("field.status"))+dim(truncate(c.Status, w-20)),
|
||
"",
|
||
dim(i18n.T("hint.tabsComing")),
|
||
)
|
||
}
|
||
return panel(title, meta, lines, w, h, false)
|
||
}
|