416 lines
11 KiB
Go
416 lines
11 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"charm.land/bubbles/v2/table"
|
|
"charm.land/bubbles/v2/textinput"
|
|
"charm.land/bubbles/v2/viewport"
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
|
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/platform"
|
|
)
|
|
|
|
var (
|
|
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212"))
|
|
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
|
logHeadSt = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
|
runningLegend = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("● running")
|
|
stoppedLegend = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("○ stopped")
|
|
)
|
|
|
|
type dashModel struct {
|
|
table table.Model
|
|
allRows []table.Row
|
|
filter textinput.Model
|
|
filtering bool
|
|
logs viewport.Model
|
|
showLogs bool
|
|
follow bool
|
|
status string
|
|
confirmRemove string
|
|
width int
|
|
height int
|
|
}
|
|
|
|
type tickMsg time.Time
|
|
type actionMsg string
|
|
type rowsMsg []table.Row
|
|
type logsMsg struct {
|
|
name string
|
|
content string
|
|
}
|
|
|
|
func tick() tea.Cmd {
|
|
return tea.Tick(3*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })
|
|
}
|
|
|
|
func fetchLogs(name string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
if name == "" {
|
|
return logsMsg{}
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
out, err := exec.CommandContext(ctx, "docker", "logs", "--tail", "200", name).CombinedOutput()
|
|
if err != nil {
|
|
return logsMsg{name: name, content: "logs unavailable: " + err.Error()}
|
|
}
|
|
return logsMsg{name: name, content: string(out)}
|
|
}
|
|
}
|
|
|
|
func loadRows() tea.Cmd {
|
|
return func() tea.Msg { return rowsMsg(refreshRows()) }
|
|
}
|
|
|
|
func refreshRows() []table.Row {
|
|
containers, _ := docker.List()
|
|
rows := make([]table.Row, 0, len(containers))
|
|
for _, c := range containers {
|
|
port := c.HostPort()
|
|
url := ""
|
|
if port != "" && c.Running() {
|
|
url = "http://localhost:" + port
|
|
}
|
|
state := "● " + c.State
|
|
if !c.Running() {
|
|
state = "○ " + c.State
|
|
}
|
|
img := strings.TrimPrefix(c.Image, "ghcr.io/homarr-labs/")
|
|
rows = append(rows, table.Row{c.Name, state, img, port, url, c.Status})
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func dashboardColumns(width int) []table.Column {
|
|
nameWidth := 26
|
|
imageWidth := 30
|
|
urlWidth := 24
|
|
if width < 100 {
|
|
nameWidth = 22
|
|
imageWidth = max(width-54, 18)
|
|
urlWidth = 0
|
|
}
|
|
return []table.Column{
|
|
{Title: "NAME", Width: nameWidth},
|
|
{Title: "STATE", Width: 14},
|
|
{Title: "IMAGE", Width: imageWidth},
|
|
{Title: "PORT", Width: 6},
|
|
{Title: "URL", Width: urlWidth},
|
|
{Title: "STATUS", Width: 0},
|
|
}
|
|
}
|
|
|
|
func newDashModel() dashModel {
|
|
t := table.New(
|
|
table.WithColumns(dashboardColumns(104)),
|
|
table.WithFocused(true),
|
|
table.WithHeight(10),
|
|
table.WithWidth(104),
|
|
)
|
|
s := table.DefaultStyles()
|
|
s.Header = s.Header.Bold(true)
|
|
s.Selected = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("57"))
|
|
t.SetStyles(s)
|
|
logs := viewport.New(viewport.WithWidth(104), viewport.WithHeight(12))
|
|
logs.SoftWrap = true
|
|
filter := textinput.New()
|
|
filter.Prompt = "/ "
|
|
filter.Placeholder = "filter name, image, port, state…"
|
|
filter.SetWidth(50)
|
|
styles := textinput.DefaultDarkStyles()
|
|
styles.Focused.Prompt = styles.Focused.Prompt.Foreground(lipgloss.Color("212")).Bold(true)
|
|
styles.Focused.Text = styles.Focused.Text.Foreground(lipgloss.Color("229"))
|
|
filter.SetStyles(styles)
|
|
return dashModel{table: t, filter: filter, logs: logs, showLogs: true, follow: true, width: 104, height: 30}
|
|
}
|
|
|
|
func (m *dashModel) applyFilter() {
|
|
query := strings.ToLower(strings.TrimSpace(m.filter.Value()))
|
|
if query == "" {
|
|
m.table.SetRows(m.allRows)
|
|
return
|
|
}
|
|
rows := make([]table.Row, 0, len(m.allRows))
|
|
for _, row := range m.allRows {
|
|
if strings.Contains(strings.ToLower(strings.Join(row, " ")), query) {
|
|
rows = append(rows, row)
|
|
}
|
|
}
|
|
m.table.SetRows(rows)
|
|
}
|
|
|
|
func (m dashModel) selected() string {
|
|
row := m.table.SelectedRow()
|
|
if len(row) == 0 {
|
|
return ""
|
|
}
|
|
return row[0]
|
|
}
|
|
|
|
func (m dashModel) selectedPort() string {
|
|
row := m.table.SelectedRow()
|
|
if len(row) < 4 {
|
|
return ""
|
|
}
|
|
return row[3]
|
|
}
|
|
|
|
func (m dashModel) Init() tea.Cmd {
|
|
return tea.Batch(tick(), loadRows(), fetchLogs(m.selected()))
|
|
}
|
|
|
|
func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.table.SetColumns(dashboardColumns(msg.Width))
|
|
m.table.SetWidth(msg.Width)
|
|
m.logs.SetWidth(msg.Width)
|
|
m.filter.SetWidth(max(min(msg.Width-4, 60), 10))
|
|
m.layout()
|
|
return m, nil
|
|
case tickMsg:
|
|
return m, tea.Batch(tick(), loadRows())
|
|
case rowsMsg:
|
|
m.allRows = []table.Row(msg)
|
|
m.applyFilter()
|
|
return m, fetchLogs(m.selected())
|
|
case logsMsg:
|
|
if msg.name != "" && msg.name != m.selected() {
|
|
return m, nil
|
|
}
|
|
m.logs.SetContent(strings.TrimRight(msg.content, "\n"))
|
|
if m.follow {
|
|
m.logs.GotoBottom()
|
|
}
|
|
return m, nil
|
|
case actionMsg:
|
|
m.status = string(msg)
|
|
m.confirmRemove = ""
|
|
return m, loadRows()
|
|
case tea.KeyPressMsg:
|
|
if m.filtering {
|
|
switch msg.Key().Code {
|
|
case tea.KeyEscape:
|
|
m.filtering = false
|
|
m.filter.Reset()
|
|
m.filter.Blur()
|
|
m.applyFilter()
|
|
return m, fetchLogs(m.selected())
|
|
case tea.KeyEnter:
|
|
m.filtering = false
|
|
m.filter.Blur()
|
|
return m, fetchLogs(m.selected())
|
|
}
|
|
var cmd tea.Cmd
|
|
m.filter, cmd = m.filter.Update(msg)
|
|
m.applyFilter()
|
|
return m, tea.Batch(cmd, fetchLogs(m.selected()))
|
|
}
|
|
name := m.selected()
|
|
switch msg.Key().Code {
|
|
case tea.KeyUp, tea.KeyKpUp:
|
|
m.table.MoveUp(1)
|
|
return m, fetchLogs(m.selected())
|
|
case tea.KeyDown, tea.KeyKpDown:
|
|
m.table.MoveDown(1)
|
|
return m, fetchLogs(m.selected())
|
|
case tea.KeyPgUp, tea.KeyKpPgUp:
|
|
m.follow = false
|
|
m.logs.PageUp()
|
|
return m, nil
|
|
case tea.KeyPgDown, tea.KeyKpPgDown:
|
|
m.logs.PageDown()
|
|
m.follow = m.logs.AtBottom()
|
|
return m, nil
|
|
}
|
|
switch msg.String() {
|
|
case "q", "ctrl+c", "esc":
|
|
return m, tea.Quit
|
|
case "l":
|
|
m.showLogs = !m.showLogs
|
|
m.layout()
|
|
if m.showLogs {
|
|
return m, fetchLogs(name)
|
|
}
|
|
return m, nil
|
|
case "/":
|
|
m.filtering = true
|
|
return m, m.filter.Focus()
|
|
case "f":
|
|
m.follow = !m.follow
|
|
if m.follow {
|
|
m.logs.GotoBottom()
|
|
}
|
|
return m, nil
|
|
case "s":
|
|
if name != "" {
|
|
m.status = "stopping " + name + "…"
|
|
return m, func() tea.Msg {
|
|
if err := docker.Stop(name); err != nil {
|
|
return actionMsg("stop failed: " + err.Error())
|
|
}
|
|
return actionMsg("stopped " + name)
|
|
}
|
|
}
|
|
case "x":
|
|
if name != "" {
|
|
if m.confirmRemove != name {
|
|
m.confirmRemove = name
|
|
m.status = "press x again to remove " + name
|
|
return m, nil
|
|
}
|
|
m.status = "removing " + name + "…"
|
|
return m, func() tea.Msg {
|
|
if err := docker.Remove(name); err != nil {
|
|
return actionMsg("remove failed: " + err.Error())
|
|
}
|
|
return actionMsg("removed " + name)
|
|
}
|
|
}
|
|
case "R":
|
|
if name != "" {
|
|
m.status = "restarting " + name + "…"
|
|
return m, func() tea.Msg {
|
|
if err := docker.Restart(name); err != nil {
|
|
return actionMsg("restart failed: " + err.Error())
|
|
}
|
|
return actionMsg("restarted " + name)
|
|
}
|
|
}
|
|
case "o":
|
|
if name != "" {
|
|
if port := m.selectedPort(); port != "" {
|
|
if err := platform.OpenURL("http://localhost:" + port); err != nil {
|
|
m.status = "open failed: " + err.Error()
|
|
}
|
|
}
|
|
}
|
|
case "c":
|
|
if name != "" {
|
|
if port := m.selectedPort(); port != "" {
|
|
url := "http://localhost:" + port
|
|
if err := platform.CopyText(url); err != nil {
|
|
m.status = "copy failed: " + err.Error()
|
|
} else {
|
|
m.status = "copied " + url
|
|
}
|
|
}
|
|
}
|
|
case "r":
|
|
return m, loadRows()
|
|
case "k":
|
|
m.table.MoveUp(1)
|
|
return m, fetchLogs(m.selected())
|
|
case "j":
|
|
m.table.MoveDown(1)
|
|
return m, fetchLogs(m.selected())
|
|
}
|
|
}
|
|
var cmd tea.Cmd
|
|
m.table, cmd = m.table.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
func (m *dashModel) layout() {
|
|
available := m.height - 11
|
|
if m.filtering || m.filter.Value() != "" {
|
|
available -= 2
|
|
}
|
|
if !m.showLogs {
|
|
m.table.SetHeight(max(available, 5))
|
|
return
|
|
}
|
|
tableH := min(12, max(available/2, 5))
|
|
m.table.SetHeight(tableH)
|
|
logsH := available - tableH
|
|
if logsH < 3 {
|
|
logsH = 3
|
|
}
|
|
m.logs.SetHeight(logsH)
|
|
}
|
|
|
|
func (m dashModel) selectedDetailView() string {
|
|
row := m.table.SelectedRow()
|
|
if len(row) < 6 {
|
|
return ""
|
|
}
|
|
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")).Render(row[0])
|
|
meta := row[2] + " · " + row[1]
|
|
if row[3] != "" {
|
|
meta += " · port " + row[3]
|
|
}
|
|
if row[4] != "" {
|
|
meta += " · " + row[4]
|
|
}
|
|
if row[5] != "" {
|
|
meta += " · " + row[5]
|
|
}
|
|
return title + "\n" + helpStyle.Render(meta)
|
|
}
|
|
|
|
func (m dashModel) View() tea.View {
|
|
var b strings.Builder
|
|
running := 0
|
|
for _, row := range m.allRows {
|
|
if len(row) > 1 && strings.Contains(row[1], "running") {
|
|
running++
|
|
}
|
|
}
|
|
b.WriteString(titleStyle.Render("🦞 homarr instances") + " " + helpStyle.Render(fmt.Sprintf("%d total · %d running · %d shown", len(m.allRows), running, len(m.table.Rows()))))
|
|
b.WriteString("\n\n")
|
|
if m.filtering || m.filter.Value() != "" {
|
|
b.WriteString(m.filter.View() + "\n\n")
|
|
}
|
|
if len(m.table.Rows()) == 0 {
|
|
message := "no homarr containers — start one with `homarr run <tag>` or `homarr run --pr N`"
|
|
if len(m.allRows) > 0 {
|
|
message = "no instances match " + m.filter.Value()
|
|
}
|
|
b.WriteString(helpStyle.Render(" " + message + "\n"))
|
|
} else {
|
|
b.WriteString(m.table.View())
|
|
b.WriteString("\n")
|
|
}
|
|
if detail := m.selectedDetailView(); detail != "" {
|
|
b.WriteString("\n" + detail + "\n")
|
|
}
|
|
if m.showLogs && len(m.table.Rows()) > 0 {
|
|
mode := "paused"
|
|
if m.follow {
|
|
mode = "following"
|
|
}
|
|
b.WriteString("\n" + logHeadSt.Render("── logs: "+m.selected()+" ("+mode+") ──") + "\n")
|
|
b.WriteString(m.logs.View())
|
|
}
|
|
if m.status != "" {
|
|
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
|
lower := strings.ToLower(m.status)
|
|
if strings.Contains(lower, "fail") || strings.Contains(lower, "error") {
|
|
statusStyle = statusStyle.Foreground(lipgloss.Color("196"))
|
|
} else if strings.Contains(lower, "press x") {
|
|
statusStyle = statusStyle.Foreground(lipgloss.Color("214"))
|
|
}
|
|
b.WriteString("\n" + statusStyle.Render(m.status))
|
|
}
|
|
b.WriteString("\n\n" + helpStyle.Render("/ filter · ↑/↓ select · l logs · f follow · pgup/pgdn scroll · s stop · R restart · x remove · o open · c copy URL · r refresh · d development · q quit"))
|
|
b.WriteString("\n" + helpStyle.Render(runningLegend+" · "+stoppedLegend))
|
|
v := tea.NewView(b.String())
|
|
v.AltScreen = true
|
|
v.WindowTitle = "🦞 Homarr instances"
|
|
return v
|
|
}
|
|
|
|
func RunDashboard() error {
|
|
return runApp(true)
|
|
}
|