Files
homarr/tools/homarr-dev/internal/tui/rows.go
T

382 lines
11 KiB
Go

package tui
import (
"fmt"
"path/filepath"
"strings"
"charm.land/bubbles/v2/table"
"charm.land/lipgloss/v2"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/registry"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui"
)
type rowKind int
const (
rowRemote rowKind = iota
rowLocal
)
// devRow is one line of the development screen: either an open pull request,
// or a local image that no open pull request accounts for.
type devRow struct {
kind rowKind
pr gh.PR
local docker.Image
image ui.ImageState
running bool
port string
state string
}
// hasCurrentLocalImage reports whether a local build matches the pull request's
// current head. Only an exact revision match counts: an image built from an
// older commit is a different thing and stays on its own row rather than
// silently shadowing the pull request.
func (r devRow) hasCurrentLocalImage() bool {
return r.kind == rowRemote && r.local.Tag != "" && r.pr.HeadSHA != "" && r.local.Revision == r.pr.HeadSHA
}
func (r devRow) containerName() string {
if r.kind == rowLocal {
return "homarr_" + r.local.Tag
}
return fmt.Sprintf("homarr_pr_%d", r.pr.Number)
}
func (r devRow) volumeName() string { return r.containerName() + "_data" }
// localReference is the local image this row would run, if one exists.
func (r devRow) localReference() string {
if r.local.Tag == "" {
return ""
}
return r.local.Reference()
}
func (r devRow) remoteReference() string {
if r.kind != rowRemote {
return ""
}
return registry.ImageReference(r.pr.Number)
}
func (r devRow) label() string {
if r.kind == rowLocal {
return r.local.Reference()
}
return fmt.Sprintf("PR #%d", r.pr.Number)
}
func (r devRow) key() string {
if r.kind == rowLocal {
return "local:" + r.local.Tag
}
return fmt.Sprintf("pr:%d", r.pr.Number)
}
// searchTerms is everything the filter matches against for this row.
func (r devRow) searchTerms() string {
terms := []string{
r.pr.Title, r.pr.Author, r.pr.HeadRef, r.pr.CIState,
r.local.Tag, r.local.Source, r.local.Revision, r.port,
}
if r.kind == rowRemote {
terms = append(terms, "remote", fmt.Sprint(r.pr.Number), fmt.Sprintf("#%d pr-%d", r.pr.Number, r.pr.Number))
if r.pr.IsDraft {
terms = append(terms, "draft")
}
} else {
terms = append(terms, "local", r.local.Reference())
}
if r.hasCurrentLocalImage() {
terms = append(terms, "local built")
}
if r.running {
terms = append(terms, "running started up")
}
return strings.ToLower(strings.Join(terms, " "))
}
// buildDevRows merges open pull requests with local images into a single list.
// A local image folds into its pull request only when the revisions match, so
// the row exposes both sources when the registry image is available too.
func buildDevRows(prs []gh.PR, images []docker.Image, tags map[string]bool, tagsKnown bool) []devRow {
rows := make([]devRow, 0, len(prs)+len(images))
indexByPR := make(map[int]int, len(prs))
for _, pr := range prs {
indexByPR[pr.Number] = len(rows)
rows = append(rows, devRow{kind: rowRemote, pr: pr})
}
for _, image := range images {
index, matched := indexByPR[image.PRNumber]
if matched && image.Revision != "" && image.Revision == rows[index].pr.HeadSHA {
if rows[index].local.Tag == "" {
rows[index].local = image
continue
}
}
rows = append(rows, devRow{kind: rowLocal, local: image, image: ui.ImageLocal})
}
for index := range rows {
if rows[index].kind != rowRemote {
continue
}
switch {
case rows[index].hasCurrentLocalImage():
rows[index].image = ui.ImageLocal
case rows[index].local.Tag != "":
rows[index].image = ui.ImageStale
case !tagsKnown:
rows[index].image = ui.ImageChecking
case registry.HasPRImage(tags, rows[index].pr.Number):
rows[index].image = ui.ImageRemote
default:
rows[index].image = ui.ImageMissing
}
}
return rows
}
// applyContainers annotates rows with the state of their container.
func applyContainers(rows []devRow, containers []docker.Container) []devRow {
byName := make(map[string]docker.Container, len(containers))
for _, container := range containers {
byName[container.Name] = container
}
for index := range rows {
container, found := byName[rows[index].containerName()]
rows[index].running = found && container.Running()
rows[index].state = ""
rows[index].port = ""
if found {
rows[index].state = container.State
rows[index].port = container.HostPort()
}
}
return rows
}
// flexWidth hands the leftover table width to one column. Every visible column
// costs its own width plus the two padding cells the table adds, and columns set
// to zero are skipped entirely, which is how the narrow layouts drop them.
func flexWidth(columns []table.Column, available, index, minimum int) int {
used := 0
for position, column := range columns {
if position == index || column.Width <= 0 {
continue
}
used += column.Width + 2
}
return max(available-used-2, minimum)
}
func devColumns(width int) []table.Column {
author, port := 15, 6
switch {
case width < 68:
author, port = 0, 0
case width < 92:
author = 0
case width < 108:
author = 10
}
columns := []table.Column{
{Title: "", Width: 1},
{Title: "SOURCE", Width: 6},
{Title: "REF", Width: 9},
{Title: "TITLE", Width: 0},
{Title: "AUTHOR", Width: author},
{Title: "IMG", Width: 3},
{Title: "CI", Width: 2},
{Title: "PORT", Width: port},
}
columns[3].Width = flexWidth(columns, width, 3, 18)
return columns
}
func devTableRows(rows []devRow, columns []table.Column) []table.Row {
titleWidth, authorWidth := columns[3].Width, columns[4].Width
rendered := make([]table.Row, 0, len(rows))
for _, row := range rows {
marker := " "
switch {
case row.running:
marker = ui.OK.Render(ui.IconRunning)
case row.state != "":
marker = ui.Dim.Render(ui.IconStopped)
}
// Cells are truncated as plain text and styled afterwards. Styling
// first would leave the table cutting through an escape sequence,
// because it measures width in bytes it cannot interpret.
source, reference, title, author := "remote", fmt.Sprint(row.pr.Number), row.pr.Title, row.pr.Author
ci := ui.CIBadge(row.pr.CIState, false)
draft := false
if row.kind == rowLocal {
source, reference, title = "local", row.local.Tag, row.local.Reference()
ci = ui.CIBadge("", false)
author = "untracked"
if row.local.Source != "" {
author = filepath.Base(row.local.Source)
}
} else if row.pr.IsDraft {
title, draft = "DRAFT · "+title, true
}
styledSource := ui.Dim.Render(source)
if row.kind == rowLocal {
styledSource = ui.Local.Render(source)
}
styledTitle := ui.Truncate(title, titleWidth)
if draft {
styledTitle = ui.Dim.Render(styledTitle)
}
port := ""
if row.port != "" {
port = ui.OK.Render(":" + row.port)
}
rendered = append(rendered, table.Row{
marker,
styledSource,
ui.Truncate(reference, columns[2].Width),
styledTitle,
ui.Help.Render(ui.Truncate(author, authorWidth)),
ui.ImageBadge(row.image, false),
ci,
port,
})
}
return rendered
}
// instanceRow is one line of the instances screen.
type instanceRow struct {
container docker.Container
volume string
health string
}
func (r instanceRow) prNumber() int {
var number int
if _, err := fmt.Sscanf(r.container.Name, "homarr_pr_%d", &number); err == nil {
return number
}
return 0
}
func (r instanceRow) url() string {
if !r.container.Running() || r.container.HostPort() == "" {
return ""
}
return "http://localhost:" + r.container.HostPort()
}
func (r instanceRow) searchTerms() string {
return strings.ToLower(strings.Join([]string{
r.container.Name, r.container.Image, r.container.State,
r.container.Status, r.container.HostPort(), r.health, r.url(),
}, " "))
}
var healthStatusMap = map[string]string{
"healthy": "healthy",
"unhealthy": "unhealthy",
"health: starting": "starting",
}
// parseHealth pulls the healthcheck verdict out of the status string Docker
// renders, for example "Up 3 minutes (healthy)".
func parseHealth(status string) string {
open := strings.Index(status, "(")
close := strings.LastIndex(status, ")")
if open < 0 || close <= open {
return ""
}
return healthStatusMap[status[open+1:close]]
}
func buildInstanceRows(containers []docker.Container, volumes []docker.Volume) []instanceRow {
byName := make(map[string]docker.Volume, len(volumes))
for _, volume := range volumes {
byName[volume.Name] = volume
}
rows := make([]instanceRow, 0, len(containers))
for _, container := range containers {
row := instanceRow{container: container, health: parseHealth(container.Status)}
if volume, found := byName[container.Name+"_data"]; found {
row.volume = volume.Name
}
rows = append(rows, row)
}
return rows
}
func instanceColumns(width int) []table.Column {
image, status := 24, 22
switch {
case width < 72:
image, status = 14, 0
case width < 100:
image, status = 18, 0
case width < 124:
status = 16
}
columns := []table.Column{
{Title: "", Width: 1},
{Title: "NAME", Width: 0},
{Title: "STATE", Width: 10},
{Title: "IMAGE", Width: image},
{Title: "PORT", Width: 6},
{Title: "STATUS", Width: status},
}
columns[1].Width = flexWidth(columns, width, 1, 16)
return columns
}
func instanceTableRows(rows []instanceRow, columns []table.Column) []table.Row {
nameWidth, imageWidth, statusWidth := columns[1].Width, columns[3].Width, columns[5].Width
rendered := make([]table.Row, 0, len(rows))
for _, row := range rows {
marker := ui.Dim.Render(ui.IconStopped)
if row.container.Running() {
marker = ui.OK.Render(ui.IconRunning)
}
port := ""
if value := row.container.HostPort(); value != "" {
port = ui.OK.Render(":" + value)
}
image := strings.TrimPrefix(row.container.Image, "ghcr.io/homarr-labs/")
rendered = append(rendered, table.Row{
marker,
ui.Body.Render(ui.Truncate(row.container.Name, nameWidth)),
ui.StateBadge(row.container.State),
ui.Help.Render(ui.Truncate(image, imageWidth)),
port,
prefixed(ui.HealthBadge(row.health), row.container.Status, statusWidth),
})
}
return rendered
}
// prefixed puts a rendered badge in front of plain text, truncating the text to
// whatever width the badge leaves behind.
func prefixed(badge, text string, width int) string {
if width <= 0 {
return ""
}
if badge == "" {
return ui.Help.Render(ui.Truncate(text, width))
}
remaining := width - lipgloss.Width(badge) - 1
if remaining <= 0 {
return badge
}
return badge + " " + ui.Help.Render(ui.Truncate(text, remaining))
}