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

415 lines
13 KiB
Go

package tui
import (
"context"
"fmt"
"strconv"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/platform"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/registry"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/run"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/task"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui"
)
// target is everything the actions need to know about the selected row,
// resolved once so the same code can act on a pull request, a local image, or a
// bare container without caring which screen the user is on.
type target struct {
label string
container string
localImage string
volume string
pr int
running bool
port string
provenance docker.Image
buildable bool
}
func (m Model) currentTarget() (target, bool) {
if m.screen == screenInstances {
row, found := m.selectedInstance()
if !found {
return target{}, false
}
return m.instanceTarget(row), true
}
row, found := m.selectedDev()
if !found {
return target{}, false
}
return m.devTarget(row), true
}
func (m Model) devTarget(row devRow) target {
result := target{
label: row.label(),
container: row.containerName(),
volume: row.volumeName(),
running: row.running,
port: row.port,
buildable: true,
}
if row.kind == rowLocal {
result.localImage = row.local.Reference()
result.provenance = row.local
result.pr = row.local.PRNumber
return result
}
result.pr = row.pr.Number
result.localImage = row.localReference()
// A pull request always has a rebuild recipe even without a local image:
// clone the head into a temporary checkout and build it as homarr:pr-N.
result.provenance = docker.Image{Tag: fmt.Sprintf("pr-%d", row.pr.Number), PRNumber: row.pr.Number}
if row.local.Tag != "" {
result.provenance = row.local
}
return result
}
func (m Model) instanceTarget(row instanceRow) target {
result := target{
label: row.container.Name,
container: row.container.Name,
volume: row.volume,
running: row.container.Running(),
port: row.container.HostPort(),
}
if strings.HasPrefix(row.container.Image, "homarr:") {
result.localImage = row.container.Image
tag := strings.TrimPrefix(row.container.Image, "homarr:")
for _, image := range m.images {
if image.Tag == tag {
result.provenance, result.buildable = image, true
break
}
}
}
if number := row.prNumber(); number > 0 {
result.pr = number
if !result.buildable {
result.provenance = docker.Image{Tag: fmt.Sprintf("pr-%d", number), PRNumber: number}
result.buildable = true
}
}
return result
}
// primaryAction is Enter: stop what is running, start what is not. A matching
// local build and a published registry image open a small source selector;
// otherwise the only available source is started directly.
func (m Model) primaryAction() (Model, tea.Cmd) {
if m.screen == screenInstances {
row, found := m.selectedInstance()
if !found {
return m, nil
}
if row.container.Running() {
return m.stopContainer(row.container.Name)
}
return m.restartContainer(row.container.Name)
}
row, found := m.selectedDev()
if !found {
return m, nil
}
if row.running {
return m.stopContainer(row.containerName())
}
if row.kind == rowLocal {
return m.startLocal(row)
}
if row.hasCurrentLocalImage() {
if m.remoteImageAvailable(row) {
return m.selectImage(row)
}
return m.startLocalPR(row)
}
switch row.image {
case ui.ImageUnknown, ui.ImageChecking:
m.status, m.statusLevel = "still checking whether CI published an image for "+row.label(), levelWarn
return m, nil
case ui.ImageMissing:
m.status, m.statusLevel = fmt.Sprintf("CI has not published an image for PR #%d yet — press R to build it locally", row.pr.Number), levelWarn
return m, nil
}
return m.deployRemotePR(row)
}
func (m Model) remoteImageAvailable(row devRow) bool {
return row.kind == rowRemote && m.tagsKnown && registry.HasPRImage(m.tags, row.pr.Number)
}
func (m Model) selectImage(row devRow) (Model, tea.Cmd) {
m.imageSelection = imageSelection{rowKey: row.key(), choice: imageChoiceLocal}
m.mode = modeImageSelect
m.status, m.statusLevel = "choose an image source for "+row.label(), levelInfo
m.relayout()
return m, nil
}
func (m Model) startLocal(row devRow) (Model, tea.Cmd) {
plan, err := run.BuildPlan(run.Options{Context: context.Background(), Tag: row.local.Tag, Demo: m.demo})
if err != nil {
m.status, m.statusLevel = err.Error(), levelError
return m, nil
}
return m.startPlan("start "+row.local.Reference(), row.containerName(), plan)
}
func (m Model) startLocalPR(row devRow) (Model, tea.Cmd) {
plan, err := localPRPlan(row, m.demo)
if err != nil {
m.status, m.statusLevel = err.Error(), levelError
return m, nil
}
return m.startPlan(fmt.Sprintf("start PR #%d from local build", row.pr.Number), row.containerName(), plan)
}
// localPRPlan runs a locally built image under the pull request's container and
// volume identity, so switching between a local build and the registry build of
// the same pull request keeps the same data and port.
func localPRPlan(row devRow, demo bool) (*run.Plan, error) {
plan, err := run.BuildPlan(run.Options{Context: context.Background(), Tag: row.local.Tag, Demo: demo})
if err != nil {
return nil, err
}
plan.Name = row.containerName()
plan.Volume = row.volumeName()
plan.Label = fmt.Sprintf("PR #%d · local %s", row.pr.Number, truncateRevision(row.local.Revision))
plan.PRNumber = row.pr.Number
return plan, nil
}
func truncateRevision(revision string) string {
if len(revision) > 12 {
return revision[:12]
}
return revision
}
func (m Model) startPlan(title, container string, plan *run.Plan) (Model, tea.Cmd) {
m.tasks.Action(title, container, func(ctx context.Context) error {
plan.Pull = false
return run.StartDetached(plan)
})
m.status, m.statusLevel = fmt.Sprintf("%s → http://localhost:%d", title, plan.HostPort), levelInfo
return m, nil
}
// deployRemotePR pulls the registry image and starts it as one job, so the
// sidebar shows a single continuous progress stream rather than two.
func (m Model) deployRemotePR(row devRow) (Model, tea.Cmd) {
if existing, running := m.tasks.RunningFor(row.containerName()); running {
m.status, m.statusLevel = existing.Title+" is already running", levelWarn
return m, nil
}
plan, err := run.BuildPlan(run.Options{Context: context.Background(), PR: row.pr.Number, Demo: m.demo})
if err != nil {
m.status, m.statusLevel = err.Error(), levelError
return m, nil
}
// Reuse the port a running instance already holds so a redeploy does not
// move the URL out from under an open browser tab.
if row.running && row.port != "" {
if port, err := strconv.Atoi(row.port); err == nil {
plan.HostPort = port
}
}
title := fmt.Sprintf("pull and start PR #%d", row.pr.Number)
created := m.tasks.Start(task.KindPull, title, row.containerName(), func(ctx context.Context, report *task.Reporter) error {
if err := task.PullImage(ctx, report, plan.Image, plan.Platform); err != nil {
return err
}
report.SetDetail("starting container on port " + strconv.Itoa(plan.HostPort))
plan.Pull = false
return run.StartDetached(plan)
})
return m.focusOn(created.ID(), title)
}
// buildLocally rebuilds the selection in the background. A pull request without
// a local image is cloned into a temporary checkout first.
func (m Model) buildLocally(subject target) (Model, tea.Cmd) {
if !subject.buildable {
m.status, m.statusLevel = subject.label+" has no build source to rebuild from", levelWarn
return m, nil
}
reference := "homarr:" + subject.provenance.Tag
if existing, running := m.tasks.RunningFor(reference); running {
m.status, m.statusLevel = existing.Title+" is already running", levelWarn
return m, nil
}
image := subject.provenance
title := "build " + reference
created := m.tasks.BuildFrom(title, reference, func(ctx context.Context, report *task.Reporter) (docker.BuildOptions, error) {
options, cleanup, err := run.RebuildOptions(ctx, image, report.Log)
if err != nil {
return docker.BuildOptions{}, err
}
// The checkout has to outlive option construction but not the build,
// and BuildFrom runs both inside one job, so releasing it when the job
// goroutine unwinds is both correct and the simplest thing available.
context.AfterFunc(ctx, cleanup)
return options, nil
})
return m.focusOn(created.ID(), title)
}
func (m Model) pullRemote(row devRow) (Model, tea.Cmd) {
if row.kind != rowRemote {
m.status, m.statusLevel = "pulling only applies to pull-request images", levelWarn
return m, nil
}
return m.deployRemotePR(row)
}
// focusOn points the tray and sidebar at a freshly started job so its progress
// is visible without any further keystrokes.
func (m Model) focusOn(id int, title string) (Model, tea.Cmd) {
m.focusTask = id
m.sidebar.visible = true
m.sidebar.source = sourceTask
m.sidebar.follow = true
m.status, m.statusLevel = title+" started in the background", levelInfo
m.relayout()
cmd := m.refreshSidebar()
clocks := m.animate()
return m, tea.Batch(cmd, clocks)
}
func (m Model) stopContainer(name string) (Model, tea.Cmd) {
m.tasks.Action("stop "+name, name, func(ctx context.Context) error { return docker.StopContext(ctx, name) })
m.status, m.statusLevel = "stopping "+name+"…", levelInfo
return m, nil
}
func (m Model) restartContainer(name string) (Model, tea.Cmd) {
m.tasks.Action("restart "+name, name, func(ctx context.Context) error { return docker.RestartContext(ctx, name) })
m.status, m.statusLevel = "restarting "+name+"…", levelInfo
return m, nil
}
func (m Model) removeContainer(name string) (Model, tea.Cmd) {
m.tasks.Action("remove "+name, name, func(ctx context.Context) error { return docker.RemoveContext(ctx, name) })
m.status, m.statusLevel = "removing "+name+"…", levelInfo
return m, nil
}
func (m Model) deleteImage(reference string) (Model, tea.Cmd) {
m.tasks.Action("delete "+reference, reference, func(ctx context.Context) error {
return docker.RemoveImage(ctx, reference)
})
m.status, m.statusLevel = "deleting "+reference+"…", levelInfo
return m, nil
}
// deleteData removes an instance's volume. Docker refuses to delete a volume
// that is still attached, so the container is removed first — the data is going
// away either way, and failing halfway would leave the user to work out why.
func (m Model) deleteData(subject target) (Model, tea.Cmd) {
container, volume := subject.container, subject.volume
if volume == "" {
m.status, m.statusLevel = subject.label+" has no data volume to delete", levelWarn
return m, nil
}
m.tasks.Action("delete data "+volume, volume, func(ctx context.Context) error {
_ = docker.RemoveContext(ctx, container)
return docker.RemoveVolume(ctx, volume)
})
m.status, m.statusLevel = "deleting "+volume+"…", levelInfo
return m, nil
}
func (m Model) pruneStopped() (Model, tea.Cmd) {
names := make([]string, 0)
for _, container := range m.containers {
if !container.Running() {
names = append(names, container.Name)
}
}
if len(names) == 0 {
m.status, m.statusLevel = "no stopped Homarr containers to prune", levelInfo
return m, nil
}
m.tasks.Action(fmt.Sprintf("prune %d stopped containers", len(names)), "prune", func(ctx context.Context) error {
for _, name := range names {
if err := docker.RemoveContext(ctx, name); err != nil {
return err
}
}
return nil
})
m.status, m.statusLevel = fmt.Sprintf("pruning %d stopped containers…", len(names)), levelInfo
return m, nil
}
func (m Model) openPullRequest() (Model, tea.Cmd) {
row, found := m.selectedDev()
if !found || row.kind != rowRemote {
m.status, m.statusLevel = "select a pull request to open it on GitHub", levelWarn
return m, nil
}
return m.openPullRequestNumber(row.pr.Number)
}
func (m Model) openPullRequestNumber(number int) (Model, tea.Cmd) {
if number <= 0 {
return m, nil
}
url := fmt.Sprintf("https://github.com/%s/pull/%d", gitHubRepo, number)
if err := platform.OpenURL(url); err != nil {
m.status, m.statusLevel = "open failed: "+err.Error(), levelError
return m, nil
}
m.status, m.statusLevel = "opened "+url, levelOK
return m, nil
}
const gitHubRepo = "homarr-labs/homarr"
func (m Model) openApp() (Model, tea.Cmd) {
subject, found := m.currentTarget()
if !found || !subject.running || subject.port == "" {
m.status, m.statusLevel = "selection is not running", levelWarn
return m, nil
}
url := "http://localhost:" + subject.port
if err := platform.OpenURL(url); err != nil {
m.status, m.statusLevel = "open failed: "+err.Error(), levelError
return m, nil
}
m.status, m.statusLevel = "opened "+url, levelOK
return m, nil
}
func (m Model) copyURL() (Model, tea.Cmd) {
subject, found := m.currentTarget()
if !found || subject.port == "" {
m.status, m.statusLevel = "selection has no published port", levelWarn
return m, nil
}
url := "http://localhost:" + subject.port
if err := platform.CopyText(url); err != nil {
m.status, m.statusLevel = "copy failed: "+err.Error(), levelError
return m, nil
}
m.status, m.statusLevel = "copied "+url, levelOK
return m, nil
}
// remoteImageFor is used by the detail view to name the registry image a row
// would pull.
func remoteImageFor(row devRow) string {
if row.kind != rowRemote {
return ""
}
return registry.ImageReference(row.pr.Number)
}