package app import ( "fmt" "runtime" "strings" "time" "charm.land/lipgloss/v2" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/i18n" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/ui/components" ) // homeBody — dashboard beranda (F-027). Tata letak terinspirasi mirador: // grid panel dengan judul di border; baris atas Jam · Docker · Host, // baris bawah Kontainer · Aktivitas. func (m Model) homeBody() string { W := max(m.width, 60) bodyH := m.bodyHeight() row1H := 9 row2H := bodyH - row1H if row2H < 5 { row1H = 6 row2H = max(bodyH-row1H, 3) } w1 := int(float64(W) * 0.36) w2 := int(float64(W) * 0.30) w3 := W - w1 - w2 row1 := lipgloss.JoinHorizontal(lipgloss.Top, m.clockPanel(w1, row1H), m.dockerPanel(w2, row1H), m.hostPanel(w3, row1H), ) k1 := int(float64(W) * 0.55) k2 := W - k1 row2 := lipgloss.JoinHorizontal(lipgloss.Top, panel(i18n.T("section.containers"), i18n.Tf("fmt.countTotal", len(m.containers)), m.miniContainerLines(row2H-2), k1, row2H, false), panel(i18n.T("section.activity"), i18n.Tf("fmt.phase", "2"), activityLines(), k2, row2H, false), ) return row1 + "\n" + row2 } func (m Model) clockPanel(w, h int) string { now := time.Now() rows := components.CenterBlock(components.RenderClock(now), w-4) for i, r := range rows { rows[i] = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#cba6f7")).Render(r) } tanggal := fmt.Sprintf("%d %s %d", now.Day(), i18n.MonthName(now), now.Year()) lines := append(rows, "", dim(" "+i18n.WeekdayName(now)+", "+tanggal+" · "+now.Format("MST"))) if len(lines) > h-2 { lines = lines[:h-2] } return panel(i18n.T("section.clock"), "", lines, w, h, false) } func (m Model) dockerPanel(w, h int) string { var lines []string switch { case m.engineErr != nil: lines = append(lines, red(i18n.T("error.fail")), "", dim(truncate(m.engineErr.Error(), w-6))) case m.engine.ServerVersion == "": lines = append(lines, dim(i18n.T("state.loading"))) default: lines = append(lines, fieldW(i18n.T("field.engine"), 9)+dim(m.engine.ServerVersion), fieldW(i18n.T("field.api"), 9)+dim(m.engine.APIVersion), fieldW(i18n.T("section.containers"), 9)+dim(i18n.Tf("fmt.dockerContainers", m.engine.Running, m.engine.Containers)), fieldW(i18n.T("field.image"), 9)+dim(fmt.Sprintf("%d", m.engine.Images)), "", styled("#a6e3a1", i18n.T("state.connected")), ) } return panel("Docker", i18n.T("host.local"), lines, w, h, false) } func (m Model) hostPanel(w, h int) string { s := m.sys cpus := m.engine.CPUs if cpus == 0 { cpus = runtime.NumCPU() } pct := 0 if s.MemTotal > 0 { pct = int((s.MemTotal - s.MemAvail) * 100 / s.MemTotal) } lines := []string{ fieldW(i18n.T("field.host"), 9) + dim(m.hostname), fieldW(i18n.T("field.cpu"), 9) + dim(i18n.Tf("fmt.coreLoad", cpus, s.Load1)), fieldW(i18n.T("field.ram"), 9) + dim(i18n.Tf("fmt.ramUsage", pct)) + memBar(pct, 10) + dim(fmt.Sprintf(" %s/%s", humanKBs(s.MemTotal-s.MemAvail), humanKBs(s.MemTotal))), fieldW(i18n.T("field.uptime"), 9) + dim(humanDuration(s.Uptime)), } return panel(i18n.T("section.host"), "", lines, w, h, false) } func (m Model) miniContainerLines(maxLines int) []string { if m.loading && len(m.containers) == 0 { return []string{dim(i18n.T("state.loading"))} } if len(m.containers) == 0 { return []string{dim(i18n.T("hint.miniNoContainers"))} } max := maxLines if len(m.containers) > max { max-- } lines := make([]string, 0, maxLines) for i := 0; i < len(m.containers) && i < max; i++ { c := m.containers[i] glyph, color := stateStyle(c.State) lines = append(lines, styled(color, glyph)+" "+text(truncate(c.Name, 24))+" "+dim(truncate(c.Image, 32))) } if extra := len(m.containers) - len(lines); extra > 0 { lines = append(lines, dim(i18n.Tf("fmt.listMore", extra))) } return lines } func activityLines() []string { return []string{ dim(i18n.T("activity.coming")), "", dim(i18n.T("activity.willShow")), dim(i18n.T("activity.examples")), } } // --- small formatting helpers --- func humanKB(kb uint64) string { f := float64(kb) * 1024 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 humanKBs(kb uint64) string { return strings.Replace(humanKB(kb), " ", "", 1) } func humanDuration(sec float64) string { d := time.Duration(sec) * time.Second days := int(d.Hours()) / 24 hours := int(d.Hours()) % 24 mins := int(d.Minutes()) % 60 switch { case days > 0: return i18n.Tf("duration.days", days, hours) case hours > 0: return i18n.Tf("duration.hours", hours, mins) default: return i18n.Tf("duration.minutes", mins) } } // memBar — bar RAM; warna ikut beban (hijau <60% · kuning 60–85% · merah >85%). func memBar(pct, w int) string { fill := pct * w / 100 if fill > w { fill = w } color := "#a6e3a1" switch { case pct >= 85: color = "#f38ba8" case pct >= 60: color = "#f9e2af" } return styled(color, strings.Repeat("█", fill)) + dim(strings.Repeat("░", w-fill)) }