From 76cdb0b5bbb1ce5b98a7cc93d307657b7d8192ac Mon Sep 17 00:00:00 2001 From: oxpoe <324556608+oxpoe@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:43:54 +0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20home=20dashboard=20ala=20mirador=20?= =?UTF-8?q?=E2=80=94=20jam=20blok,=20panel=20judul-di-border,=20metrik=20d?= =?UTF-8?q?ocker+host,=20navigasi=200/1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- THIRD_PARTY_NOTICES.md | 1 + docs/PRD.md | 1 + e2e/e2e_test.go | 25 ++- internal/app/ansi.go | 77 +++++++++ internal/app/app.go | 228 +++++++++++++++++++-------- internal/app/home.go | 192 ++++++++++++++++++++++ internal/sysinfo/sysinfo.go | 61 +++++++ internal/sysinfo/sysinfo_test.go | 35 ++++ internal/ui/components/clock.go | 54 +++++++ internal/ui/components/clock_test.go | 33 ++++ 10 files changed, 641 insertions(+), 66 deletions(-) create mode 100644 internal/app/ansi.go create mode 100644 internal/app/home.go create mode 100644 internal/sysinfo/sysinfo.go create mode 100644 internal/sysinfo/sysinfo_test.go create mode 100644 internal/ui/components/clock.go create mode 100644 internal/ui/components/clock_test.go diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 32c021b..d6cf093 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -11,6 +11,7 @@ mengimplementasikan ulang dalam Go. Tidak ada kode yang disalin. Tabel pemetaan | composerize | https://github.com/composerize/composerize | `3e39eb7` | MIT | | Dockge | https://github.com/louislam/dockge | `f809ae1` | MIT | | lazydocker | https://github.com/jesseduffield/lazydocker | `7e7aadc` | MIT | +| Mirador (inspirasi tata letak dashboard — tanpa kode) | https://github.com/jchultarsky/mirador | — | MIT | Repo referensi hanya ada di `reference/` (git-ignored). diff --git a/docs/PRD.md b/docs/PRD.md index 9eb3610..d8362b5 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -1478,6 +1478,7 @@ flowchart LR | Charm (bubbles) | https://github.com/charmbracelet/bubbles | UI components | | Charm (lipgloss) | https://github.com/charmbracelet/lipgloss | Styling | | Charm (huh) | https://github.com/charmbracelet/huh | Forms | +| Mirador | https://github.com/jchultarsky/mirador | Inspirasi tata letak dashboard TUI (panel grid dengan judul di border, meta kanan, jam blok, satu panel fokus terang) | ## Appendix B: Charm v2 Import Paths diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index c9688f4..7708a57 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -211,12 +211,29 @@ func TestDoctorCommand(t *testing.T) { } } +func TestDashboardNavigation(t *testing.T) { + u := startTUI(t) + u.waitContains("beranda", 5*time.Second) + u.waitContains("Jam", 5*time.Second) // panel jam blok + u.waitContains("Docker", 5*time.Second) + u.send("1") + u.waitContains("Kontainer", 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 TestMouseAndNavigateKeys(t *testing.T) { u := startTUI(t) - u.waitContains("Kontainer", 5*time.Second) + u.waitContains("beranda", 5*time.Second) + u.send("1") // masuk view kontainer + u.waitContains("Kontainer", 3*time.Second) u.send("j") u.send("k") - u.send("\x1b[<0;10;3M\x1b[<0;10;3m") // klik kiri di area sidebar + u.send("\x1b[<0;10;4M\x1b[<0;10;4m") // klik kiri di area sidebar u.send("r") // muat ulang u.send("q") if code := u.waitExit(5 * time.Second); code != 0 { @@ -243,7 +260,9 @@ func TestClickSelectsContainer(t *testing.T) { } u := startTUI(t) - u.waitContains("Kontainer (", 5*time.Second) + u.waitContains("beranda", 5*time.Second) + u.send("1") // masuk view kontainer + u.waitContains("Kontainer", 3*time.Second) u.send("\x1b[<0;10;4M\x1b[<0;10;4m") // klik baris item pertama sidebar u.waitContains("▶", 3*time.Second) u.send("q") diff --git a/internal/app/ansi.go b/internal/app/ansi.go new file mode 100644 index 0000000..e374772 --- /dev/null +++ b/internal/app/ansi.go @@ -0,0 +1,77 @@ +package app + +import ( + "strings" + + "charm.land/lipgloss/v2" +) + +// ansiTruncate — potong string ber-ANSI ke lebar tampilan w (sel), aman +// terhadap escape sequence; bila terpotong ditutup reset + "…". +// Dipakai panel() agar konten tidak pernah wrap (tinggi panel tetap). +func ansiTruncate(s string, w int) string { + if w <= 0 { + return "" + } + if lipgloss.Width(s) <= w { + return s + } + const ( + stNormal = iota + stEsc + stCSI + stOSC + stOSCEsc + ) + state := stNormal + width := 0 + var b strings.Builder + for _, r := range s { + switch state { + case stNormal: + if r == 0x1b { + state = stEsc + b.WriteRune(r) + continue + } + if width >= w-1 { // sisakan satu sel untuk "…" + b.WriteString("\x1b[0m…") + return b.String() + } + b.WriteRune(r) + width++ + case stEsc: + b.WriteRune(r) + switch { + case r == '[': + state = stCSI + case r == ']': + state = stOSC + case r >= 0x20 && r <= 0x2f: + // intermediate — tunggu final byte + default: + state = stNormal + } + case stCSI: + b.WriteRune(r) + if r >= 0x40 && r <= 0x7e { + state = stNormal + } + case stOSC: + b.WriteRune(r) + if r == 0x07 { + state = stNormal + } else if r == 0x1b { + state = stOSCEsc + } + case stOSCEsc: + b.WriteRune(r) + if r == '\\' { + state = stNormal + } else { + state = stOSC + } + } + } + return b.String() +} diff --git a/internal/app/app.go b/internal/app/app.go index 3cfd2bb..9c616a9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -3,6 +3,7 @@ package app import ( "context" "fmt" + "os" "strings" "time" @@ -11,16 +12,30 @@ import ( "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/audio" "gitea.oxpoe.qzz.io/oxpoe/tia-love/internal/docker" + "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" ) +type viewKind int + +const ( + viewHome viewKind = iota + viewContainers +) + // containersMsg — hasil muat daftar kontainer (async via tea.Cmd). type containersMsg struct { items []docker.Container err error } +// infoMsg — ringkasan daemon docker. +type infoMsg struct { + info docker.Info + err error +} + type Model struct { width, height int sound *audio.Engine @@ -33,7 +48,14 @@ type Model struct { fps int lastFPSAt time.Time + view viewKind + + hostname string + sys sysinfo.Stats + cl *docker.Client + engine docker.Info + engineErr error containers []docker.Container cursor int loadErr error @@ -42,16 +64,21 @@ type Model struct { func New() Model { cl, err := docker.New() + host, _ := os.Hostname() m := Model{ sound: audio.New(audio.Options{Enabled: true, Volume: 70, MuteOnSSH: true}), tickID: animation.NewID(), animating: true, toast: components.NewToast(), + view: viewHome, + hostname: host, + sys: sysinfo.Read(), cl: cl, loading: true, } if err != nil { m.loadErr = err + m.engineErr = err m.loading = false } return m @@ -61,7 +88,7 @@ func (m Model) Init() tea.Cmd { if m.cl == nil { return animation.Next(m.tickID) } - return tea.Batch(animation.Next(m.tickID), loadContainers(m.cl)) + return tea.Batch(animation.Next(m.tickID), loadContainers(m.cl), loadInfo(m.cl)) } func loadContainers(cl *docker.Client) tea.Cmd { @@ -73,6 +100,15 @@ func loadContainers(cl *docker.Client) tea.Cmd { } } +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: @@ -89,6 +125,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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) @@ -104,6 +141,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 + + case infoMsg: + if msg.err != nil { + m.engineErr = msg.err + } else { + m.engineErr = nil + m.engine = msg.info } return m, nil @@ -112,22 +159,35 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: switch msg.String() { - case "q", "ctrl+c", "esc": + 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.view = viewContainers + 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 daftar kontainer + case "r": // muat ulang data docker if m.cl == nil { break } m.loading = true m.sound.Play(audio.Click) - return m, loadContainers(m.cl) + return m, tea.Batch(loadContainers(m.cl), loadInfo(m.cl)) case "t": // demo toast (sampai modal asli datang) m.toast.Show("✔ [ SUKSES ] Konversi Berhasil Disimpan", "Stack 'web-app' tersimpan di ~/.tia/stacks/web-app/") @@ -150,10 +210,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.toast.Active() { m.toast = components.NewToast() // klik = tutup toast } - // hit-test sisi sidebar: baris item pertama = bodyTop + 2 - // (border atas + baris judul panel) - if msg.X < m.leftWidth() && len(m.containers) > 0 { - idx := msg.Y - (m.headerRows() + 2) + m.scrollStart() + // hit-test sidebar (hanya di view kontainer): + // baris item pertama = headerRows + 1 (border atas panel) + if m.view == viewContainers && msg.X < m.leftWidth() && len(m.containers) > 0 { + idx := msg.Y - (m.headerRows() + 1) + m.scrollStart() if idx >= 0 && idx < len(m.containers) { m.cursor = idx } @@ -198,7 +258,7 @@ func (m Model) leftWidth() int { // scrollStart — offset window daftar agar kursor selalu terlihat. func (m Model) scrollStart() int { - rows := m.bodyHeight() - 3 // tinggi dalam panel - border - baris judul + rows := m.bodyHeight() - 2 // konten dalam panel (tanpa border) if rows <= 0 || m.cursor < rows { return 0 } @@ -220,24 +280,7 @@ func stateStyle(state string) (glyph, color string) { } } -func truncate(s string, w int) string { - r := []rune(s) - if w <= 1 || len(r) <= w { - return s - } - return string(r[:w-1]) + "…" -} - -// box — panel ber-border; w/h = ukuran total panel (termasuk border+padding). -func box(lines []string, w, h int) string { - return lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#6c7086")). - Width(w-4). - Height(h-2). - Padding(0, 1). - Render(strings.Join(lines, "\n")) -} +// --- styling helpers --- func dim(s string) string { return lipgloss.NewStyle().Foreground(lipgloss.Color("#6c7086")).Render(s) @@ -247,24 +290,86 @@ func text(s string) string { return lipgloss.NewStyle().Foreground(lipgloss.Color("#cdd6f4")).Render(s) } +func red(s string) string { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#f38ba8")).Render(s) +} + +func styled(color, s string) string { + return lipgloss.NewStyle().Foreground(lipgloss.Color(color)).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]) + "…" +} + +// 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 "" + } + bc := "#6c7086" + if focused { + bc = "#cba6f7" + } + border := lipgloss.NewStyle().Foreground(lipgloss.Color(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 := lipgloss.NewStyle().Bold(true). Foreground(lipgloss.Color("#cba6f7")). - Render("tia — Terminal Is A My Way") + - dim(" · kontainer") + Render("tia — Terminal Is A My Way") - body := lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView()) - - anim := "—" - if m.animating { - anim = fmt.Sprintf("%d", m.fps) + var body, footer string + switch m.view { + case viewHome: + header += dim(" · beranda") + body = m.homeBody() + footer = m.footerLine("[1/enter] kontainer · [t] toast · [a] reduced-motion · [q] keluar") + default: + header += dim(" · kontainer") + body = lipgloss.JoinHorizontal(lipgloss.Top, m.sidebarView(), m.contentView()) + footer = m.footerLine("[j/k] pilih · [r] muat ulang · [0/esc] beranda · [q] keluar") } - footerPlain := fmt.Sprintf( - "[j/k] pilih · [r] muat ulang · [t] toast · [a] reduced-motion · [q] keluar animasi %s fps · audio: %s", - anim, m.sound.Status()) - footer := dim(truncate(footerPlain, m.width)) parts := []string{} if t := m.toast.View(max(m.width, 20)); t != "" { @@ -278,71 +383,68 @@ func (m Model) View() tea.View { return v } +func (m Model) footerLine(hints string) string { + anim := "—" + if m.animating { + anim = fmt.Sprintf("%d", m.fps) + } + return dim(truncate(fmt.Sprintf("%s animasi %s fps · audio: %s", hints, anim, m.sound.Status()), m.width)) +} + func (m Model) sidebarView() string { w, h := m.leftWidth(), m.bodyHeight() - innerW := w - 4 - - head := fmt.Sprintf("Kontainer (%d)", len(m.containers)) + title, meta := "Kontainer", fmt.Sprintf("(%d)", len(m.containers)) if m.loading { - head = "Kontainer · memuat…" + meta = "memuat…" } - lines := []string{lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#cdd6f4")).Render(head)} + var lines []string switch { case m.loadErr != nil: - lines = append(lines, - lipgloss.NewStyle().Foreground(lipgloss.Color("#f38ba8")).Render("GAGAL memuat"), - dim("tekan r untuk coba lagi"), - ) + lines = append(lines, red("GAGAL memuat"), dim("tekan r untuk coba lagi")) case m.loading: lines = append(lines, dim("memuat…")) case len(m.containers) == 0: lines = append(lines, dim("belum ada kontainer")) default: - rows := h - 3 + 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 = lipgloss.NewStyle().Foreground(lipgloss.Color("#cba6f7")).Render("▶ ") + mark = styled("#cba6f7", "▶ ") } - line := mark + - lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(glyph) + " " + - lipgloss.NewStyle().Foreground(lipgloss.Color("#cdd6f4")).Render(truncate(c.Name, innerW-4)) - lines = append(lines, line) + lines = append(lines, mark+styled(color, glyph)+" "+text(truncate(c.Name, innerW-4))) } } - return box(lines, w, h) + return panel(title, meta, lines, w, h, false) } func (m Model) contentView() string { w, h := m.width-m.leftWidth(), m.bodyHeight() + title, meta := "Detail", "" var lines []string switch { case m.loadErr != nil: - lines = append(lines, - lipgloss.NewStyle().Foreground(lipgloss.Color("#f38ba8")).Render("Gagal memuat daftar kontainer"), - "", - dim(truncate(m.loadErr.Error(), w-6)), - ) + lines = append(lines, red("Gagal memuat daftar kontainer"), "", dim(truncate(m.loadErr.Error(), w-6))) case len(m.containers) == 0: lines = append(lines, dim("Belum ada kontainer — jalankan sesuatu dengan Docker, lalu tekan r.")) default: c := m.containers[min(m.cursor, len(m.containers)-1)] glyph, color := stateStyle(c.State) + title, meta = c.Name, c.State lines = append(lines, - lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#cdd6f4")).Render(c.Name), - "", text("ID ")+dim(c.ID), text("Image ")+dim(truncate(c.Image, w-20)), - text("State ")+lipgloss.NewStyle().Foreground(lipgloss.Color(color)).Render(glyph+" "+c.State), + text("State ")+styled(color, glyph+" "+c.State), text("Status ")+dim(truncate(c.Status, w-20)), "", dim("Tab detail (Log · Statistik · Konfigurasi) menyusul di Phase 2."), ) } - return box(lines, w, h) + return panel(title, meta, lines, w, h, false) } diff --git a/internal/app/home.go b/internal/app/home.go new file mode 100644 index 0000000..6181a61 --- /dev/null +++ b/internal/app/home.go @@ -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)) +} diff --git a/internal/sysinfo/sysinfo.go b/internal/sysinfo/sysinfo.go new file mode 100644 index 0000000..bf60b94 --- /dev/null +++ b/internal/sysinfo/sysinfo.go @@ -0,0 +1,61 @@ +// Package sysinfo — metrik host ringan untuk dashboard (F-027). +// Linux: baca /proc. OS lain: nilai nol (degrade anggun, tidak pernah panic). +package sysinfo + +import ( + "os" + "strconv" + "strings" +) + +type Stats struct { + MemTotal uint64 // KB + MemAvail uint64 // KB + Load1 float64 // load average 1 menit + Uptime float64 // detik +} + +func Read() Stats { + var s Stats + if b, err := os.ReadFile("/proc/meminfo"); err == nil { + parseMeminfo(string(b), &s) + } + if b, err := os.ReadFile("/proc/loadavg"); err == nil { + parseLoadavg(string(b), &s) + } + if b, err := os.ReadFile("/proc/uptime"); err == nil { + parseUptime(string(b), &s) + } + return s +} + +func parseMeminfo(s string, st *Stats) { + for _, ln := range strings.Split(s, "\n") { + f := strings.Fields(ln) + if len(f) < 2 { + continue + } + v, err := strconv.ParseUint(f[1], 10, 64) + if err != nil { + continue + } + switch f[0] { + case "MemTotal:": + st.MemTotal = v + case "MemAvailable:": + st.MemAvail = v + } + } +} + +func parseLoadavg(s string, st *Stats) { + if f := strings.Fields(s); len(f) > 0 { + st.Load1, _ = strconv.ParseFloat(f[0], 64) + } +} + +func parseUptime(s string, st *Stats) { + if f := strings.Fields(s); len(f) > 0 { + st.Uptime, _ = strconv.ParseFloat(f[0], 64) + } +} diff --git a/internal/sysinfo/sysinfo_test.go b/internal/sysinfo/sysinfo_test.go new file mode 100644 index 0000000..f30183b --- /dev/null +++ b/internal/sysinfo/sysinfo_test.go @@ -0,0 +1,35 @@ +package sysinfo + +import "testing" + +func TestParse(t *testing.T) { + s := Stats{} + parseMeminfo("MemTotal: 32740916 kB\nMemFree: 9410408 kB\nMemAvailable: 25719724 kB\n", &s) + if s.MemTotal != 32740916 || s.MemAvail != 25719724 { + t.Fatalf("meminfo salah: %+v", s) + } + parseLoadavg("0.42 0.61 0.55 3/1812 123456\n", &s) + if s.Load1 != 0.42 { + t.Fatalf("loadavg salah: %v", s.Load1) + } + parseUptime("123456.78 9876.5\n", &s) + if s.Uptime != 123456.78 { + t.Fatalf("uptime salah: %v", s.Uptime) + } + + // input rusak tidak boleh panic & tidak mengubah nilai valid + parseMeminfo("sampah\nMemTotal:\n", &s) + parseLoadavg("", &s) + if s.MemTotal != 32740916 { + t.Fatalf("nilai valid berubah setelah input rusak: %+v", s) + } +} + +func TestReadLive(t *testing.T) { + s := Read() + if s.MemTotal == 0 { + t.Skip("bukan Linux / /proc tidak tersedia — dilewati") + } + t.Logf("total %d MB · avail %d MB · load %.2f · uptime %.0f dtk", + s.MemTotal/1024, s.MemAvail/1024, s.Load1, s.Uptime) +} diff --git a/internal/ui/components/clock.go b/internal/ui/components/clock.go new file mode 100644 index 0000000..4c1c47d --- /dev/null +++ b/internal/ui/components/clock.go @@ -0,0 +1,54 @@ +package components + +import ( + "strings" + "time" +) + +// RenderClock — jam digital blok, 5 baris, format HH:MM:SS. +// Teks polos (tanpa ANSI); pemanggil yang mewarnai. +func RenderClock(t time.Time) []string { + s := t.Format("15:04:05") + rows := make([]string, 5) + for _, r := range s { + glyph, ok := blockDigits[r] + if !ok { + glyph = blockDigits[' '] + } + for i := range rows { + if rows[i] != "" { + rows[i] += " " + } + rows[i] += glyph[i] + } + } + return rows +} + +var blockDigits = map[rune][]string{ + '0': {"███", "█ █", "█ █", "█ █", "███"}, + '1': {" █ ", " █ ", " █ ", " █ ", " █ "}, + '2': {"███", " █", "███", "█ ", "███"}, + '3': {"███", " █", "███", " █", "███"}, + '4': {"█ █", "█ █", "███", " █", " █"}, + '5': {"███", "█ ", "███", " █", "███"}, + '6': {"███", "█ ", "███", "█ █", "███"}, + '7': {"███", " █", " █", " █", " █"}, + '8': {"███", "█ █", "███", "█ █", "███"}, + '9': {"███", "█ █", "███", " █", "███"}, + ':': {" ", "█", " ", "█", " "}, + ' ': {" ", " ", " ", " ", " "}, +} + +// CenterBlock — pusatkan tiap baris blok dalam lebar w (helper tampilan). +func CenterBlock(rows []string, w int) []string { + out := make([]string, len(rows)) + for i, r := range rows { + pad := (w - len([]rune(r))) / 2 + if pad < 0 { + pad = 0 + } + out[i] = strings.Repeat(" ", pad) + r + } + return out +} diff --git a/internal/ui/components/clock_test.go b/internal/ui/components/clock_test.go new file mode 100644 index 0000000..54940f0 --- /dev/null +++ b/internal/ui/components/clock_test.go @@ -0,0 +1,33 @@ +package components + +import ( + "testing" + "time" +) + +func TestRenderClock(t *testing.T) { + rows := RenderClock(time.Date(2026, 10, 15, 18, 28, 41, 0, time.UTC)) + if len(rows) != 5 { + t.Fatalf("baris jam: %d, mau 5", len(rows)) + } + w := len([]rune(rows[0])) + for i, r := range rows { + if len([]rune(r)) != w { + t.Fatalf("lebar baris %d tidak konsisten: %d vs %d", i, len([]rune(r)), w) + } + } + // "18:28:41" = 8 glyph × 3 + 7 spasi = 31 + if w != 27 { + t.Fatalf("lebar jam: %d, mau 27", w) + } + if rows[0] == "" || rows[4] == "" { + t.Fatal("baris kosong") + } +} + +func TestCenterBlock(t *testing.T) { + rows := CenterBlock([]string{"ab", "abcd"}, 8) + if rows[0] != " ab" || rows[1] != " abcd" { + t.Fatalf("center salah: %q %q", rows[0], rows[1]) + } +}