feat(ui): home dashboard ala mirador — jam blok, panel judul-di-border, metrik docker+host, navigasi 0/1
CI / test (push) Successful in 10s

This commit is contained in:
oxpoe committed 2026-10-15 09:43:54 +07:00
1 parent fe781591e3
commit 76cdb0b5bb
10 files changed
+641 -66

No files matched your search

+192
View File
@@ -0,0 +1,192 @@
package app
import (
"fmt"
"runtime"
"strings"
"time"
"charm.land/lipgloss/v2"
"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("Kontainer", fmt.Sprintf("%d total", len(m.containers)),
m.miniContainerLines(row2H-2), k1, row2H, false),
panel("Aktivitas", "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)
}
lines := append(rows, "", dim(" "+hariID(now)+", "+tanggalID(now)+" · "+now.Format("MST")))
if len(lines) > h-2 {
lines = lines[:h-2]
}
return panel("Jam", "", lines, w, h, false)
}
func (m Model) dockerPanel(w, h int) string {
var lines []string
switch {
case m.engineErr != nil:
lines = append(lines, red("GAGAL"), "", dim(truncate(m.engineErr.Error(), w-6)))
case m.engine.ServerVersion == "":
lines = append(lines, dim("memuat…"))
default:
lines = append(lines,
text("Engine ")+dim(m.engine.ServerVersion),
text("API ")+dim(m.engine.APIVersion),
text("Kontainer ")+dim(fmt.Sprintf("%d jalan / %d total", m.engine.Running, m.engine.Containers)),
text("Image ")+dim(fmt.Sprintf("%d", m.engine.Images)),
"",
styled("#a6e3a1", "● terhubung"),
)
}
return panel("Docker", "lokal", 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{
text("Host ") + dim(m.hostname),
text("CPU ") + dim(fmt.Sprintf("%d core · load %.2f", cpus, s.Load1)),
text("RAM ") + dim(fmt.Sprintf("%3d%% ", pct)) + memBar(pct, 10) +
dim(fmt.Sprintf(" %s/%s", humanKBs(s.MemTotal-s.MemAvail), humanKBs(s.MemTotal))),
text("Uptime ") + dim(humanDuration(s.Uptime)),
}
return panel("Host", "", lines, w, h, false)
}
func (m Model) miniContainerLines(maxLines int) []string {
if m.loading && len(m.containers) == 0 {
return []string{dim("memuat…")}
}
if len(m.containers) == 0 {
return []string{dim("belum ada kontainer — tekan 1")}
}
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(fmt.Sprintf("… %d lainnya (tekan 1)", extra)))
}
return lines
}
func activityLines() []string {
return []string{
dim("Listener Docker events menyusul (Phase 2)."),
"",
dim("Panel ini akan menampilkan 5 aktivitas terbaru:"),
dim("kontainer start/stop, image di-pull, error terakhir."),
}
}
// --- small formatting helpers ---
var hari = [...]string{"Minggu", "Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu"}
var bulan = [...]string{"Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"}
func hariID(t time.Time) string { return hari[int(t.Weekday())] }
func tanggalID(t time.Time) string {
return fmt.Sprintf("%d %s %d", t.Day(), bulan[int(t.Month())-1], t.Year())
}
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 fmt.Sprintf("%d hari %d jam", days, hours)
case hours > 0:
return fmt.Sprintf("%d jam %d mnt", hours, mins)
default:
return fmt.Sprintf("%d mnt", 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))
}