382 lines
11 KiB
Go
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))
|
|
}
|