fix(dev): address CLI review feedback

This commit is contained in:
Thomas Camlong committed 2026-07-31 14:53:43 +02:00
1 parent e97eb7fc58
commit c8485ed7fc
18 files changed
+307 -160

No files matched your search

@@ -0,0 +1,37 @@
# Developer Docker CLI
The host-side developer CLI builds and runs local Homarr images and pull-request previews. It is installed from a Homarr checkout with `pnpm dev:setup` and is separate from the recovery CLI included in production images.
## Browse development images
```bash
homarr dev
```
The interactive browser combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to rebuild a local image, or use `p` to pull a remote preview.
## Build images
```bash
homarr build <name>
homarr build --pr <number> [name]
```
The first command builds the current checkout as `homarr:<name>`. The pull-request form clones the requested branch into a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
## Rebuild images
```bash
homarr rebuild <name>
```
Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout.
## Run images
```bash
homarr run <name>
homarr run --pr <number>
```
Add `--detach` to run in the background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
@@ -16,9 +16,9 @@ This guide will help you set up your development environment to start contributi
- **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/).
- **pnpm (latest)**: You can install it by running `npm install -g pnpm`.
- **Go (latest)**: You can download it from the [official website](https://go.dev/).
- **Go 1.25+**: You can download it from the [official website](https://go.dev/).
- **Git**: You can download it from the [official website](https://git-scm.com/).
- **GitHub CLI**: You can download it from the [official website](https://cli.github.com/).
- **GitHub CLI**: Install it from the [official website](https://cli.github.com/), then authenticate with `gh auth login`.
- **Docker or Docker Desktop**: You can download it from the [official website](https://www.docker.com/).
- **A GitHub account**: You can create one on the [official website](https://github.com/).
@@ -67,4 +67,4 @@ This guide will help you set up your development environment to start contributi
- `pnpm build`: Build the project for production.
- `homarr build <name>`: Build a `homarr:<name>` image and record its checkout for later rebuilds.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:latest`: Run the created docker image.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>`: Run the created docker image.
+34 -4
View File
@@ -10,6 +10,18 @@ for command in corepack go docker gh; do
exit 1
fi
done
if ! docker compose version >/dev/null 2>&1; then
printf 'Required command not found: docker compose\n' >&2
exit 1
fi
if ! docker info >/dev/null 2>&1; then
printf 'Docker daemon is not available.\n' >&2
exit 1
fi
if ! gh auth status >/dev/null 2>&1; then
printf 'GitHub CLI is not authenticated. Run `gh auth login`.\n' >&2
exit 1
fi
if [ ! -f "$repository_root/.env" ]; then
database_directory="$HOME/.local/share/homarr"
@@ -22,10 +34,22 @@ fi
corepack enable pnpm
pnpm --dir "$repository_root" install
if [ -z "$(docker ps --filter publish=6379 --format '{{.ID}}')" ]; then
docker compose -f "$repository_root/development/development.docker-compose.yml" up -d --no-recreate redis
else
compose_file="$repository_root/development/development.docker-compose.yml"
redis_id="$(docker compose -f "$compose_file" ps -q redis)"
if [ -n "$redis_id" ] && [ "$(docker inspect --format '{{.State.Running}}' "$redis_id")" = "true" ] && [ "$(docker compose -f "$compose_file" exec -T redis redis-cli ping)" = "PONG" ]; then
printf 'Redis is already available on port 6379.\n'
else
docker compose -f "$compose_file" up -d --no-recreate redis
for _ in {1..30}; do
if [ "$(docker compose -f "$compose_file" exec -T redis redis-cli ping 2>/dev/null)" = "PONG" ]; then
break
fi
sleep 1
done
if [ "$(docker compose -f "$compose_file" exec -T redis redis-cli ping 2>/dev/null)" != "PONG" ]; then
printf 'Redis did not become ready.\n' >&2
exit 1
fi
fi
pnpm --dir "$repository_root" db:migration:sqlite:run
mkdir -p "$HOME/.local/bin"
@@ -42,4 +66,10 @@ if [ -d "$HOME/.config/fish/completions" ]; then
"$HOME/.local/bin/homarr" completion fish > "$HOME/.config/fish/completions/homarr.fish"
fi
printf 'Development environment ready. Run `homarr dev` or `pnpm dev`.\n'
cli_command="$HOME/.local/bin/homarr"
case ":$PATH:" in
*":$HOME/.local/bin:"*) cli_command="homarr" ;;
*) printf 'Add %s to PATH to run `homarr` directly.\n' "$HOME/.local/bin" ;;
esac
printf 'Development environment ready. Run `%s dev` or `pnpm dev`.\n' "$cli_command"
+1 -1
View File
@@ -14,7 +14,7 @@ Build and launch local Homarr images and GHCR pull-request builds, then manage t
pnpm dev:setup
```
This installs monorepo dependencies, creates and migrates the local SQLite database, starts or reuses Redis, installs `homarr` to `~/.local/bin`, and generates Fish completions when Fish is configured.
This installs monorepo dependencies, creates and migrates the local SQLite database, starts or reuses Redis, installs `homarr` to `~/.local/bin`, and generates Fish completions when Fish is configured. Add `~/.local/bin` to `PATH` to invoke `homarr` directly; setup prints the absolute command when it is not available on `PATH`.
## Build
+42 -25
View File
@@ -12,6 +12,8 @@ import (
"strings"
)
const defaultDevelopmentEncryptionKey = "cdca265c289df60f6605a2fc9c43cfa8eb6efdc98b203cad2c3f2970aca09fbe"
type Container struct {
ID string
Name string
@@ -82,8 +84,12 @@ func List() ([]Container, error) {
}
func FindFreePort(start int) int {
if start > 65535 {
return 0
}
used := usedDockerPorts()
for port := start; ; port++ {
start = max(start, 1)
for port := start; port <= 65535; port++ {
if used[port] {
continue
}
@@ -93,36 +99,43 @@ func FindFreePort(start int) int {
return port
}
}
return 0
}
func usedDockerPorts() map[int]bool {
used := make(map[int]bool)
out, err := exec.Command("docker", "ps", "--format", "{{.Ports}}").Output()
if err != nil {
return used
return make(map[int]bool)
}
for _, mapping := range strings.Split(string(out), ",") {
left, _, found := strings.Cut(mapping, "->")
if !found {
continue
}
colon := strings.LastIndex(left, ":")
if colon < 0 {
continue
}
ports := strings.SplitN(strings.TrimSpace(left[colon+1:]), "-", 2)
first, err := strconv.Atoi(ports[0])
if err != nil {
continue
}
last := first
if len(ports) == 2 {
if parsed, err := strconv.Atoi(ports[1]); err == nil {
last = parsed
return parseUsedDockerPorts(out)
}
func parseUsedDockerPorts(out []byte) map[int]bool {
used := make(map[int]bool)
for _, line := range strings.Split(string(out), "\n") {
for _, mapping := range strings.Split(line, ",") {
left, _, found := strings.Cut(mapping, "->")
if !found {
continue
}
colon := strings.LastIndex(left, ":")
if colon < 0 {
continue
}
ports := strings.SplitN(strings.TrimSpace(left[colon+1:]), "-", 2)
first, err := strconv.Atoi(ports[0])
if err != nil {
continue
}
last := first
if len(ports) == 2 {
if parsed, err := strconv.Atoi(ports[1]); err == nil {
last = parsed
}
}
for port := first; port <= last; port++ {
used[port] = true
}
}
for port := first; port <= last; port++ {
used[port] = true
}
}
return used
@@ -162,8 +175,12 @@ func Start(opts StartOptions) error {
_ = exec.Command("docker", "rm", "-f", opts.Name).Run()
encryptionKey := os.Getenv("HOMARR_DEV_SECRET_ENCRYPTION_KEY")
if encryptionKey == "" {
encryptionKey = defaultDevelopmentEncryptionKey
}
args := []string{"run", "--rm", "--name", opts.Name,
"-e", "SECRET_ENCRYPTION_KEY=cdca265c289df60f6605a2fc9c43cfa8eb6efdc98b203cad2c3f2970aca09fbe",
"-e", "SECRET_ENCRYPTION_KEY=" + encryptionKey,
}
if opts.Platform != "" {
args = append(args, "--platform", opts.Platform)
@@ -25,3 +25,18 @@ func TestIsPortConflict(t *testing.T) {
t.Fatal("manifest error classified as port conflict")
}
}
func TestParseUsedDockerPortsPreservesLineBoundaries(t *testing.T) {
ports := parseUsedDockerPorts([]byte("6379/tcp\n0.0.0.0:7575->7575/tcp, [::]:7575->7575/tcp\n0.0.0.0:8000-8002->8000-8002/tcp\n"))
for _, port := range []int{7575, 8000, 8001, 8002} {
if !ports[port] {
t.Fatalf("port %d was not detected", port)
}
}
}
func TestFindFreePortRejectsOutOfRangeStart(t *testing.T) {
if port := FindFreePort(65536); port != 0 {
t.Fatalf("port = %d, want 0", port)
}
}
+5 -9
View File
@@ -56,8 +56,8 @@ func parseImageList(output []byte) ([]Image, error) {
return images, nil
}
func ListLocalImages() ([]Image, error) {
out, err := exec.Command("docker", "image", "ls", "--filter", "reference=homarr:*", "--format", "{{json .}}").Output()
func ListLocalImages(ctx context.Context) ([]Image, error) {
out, err := exec.CommandContext(ctx, "docker", "image", "ls", "--filter", "reference=homarr:*", "--format", "{{json .}}").Output()
if err != nil {
return nil, fmt.Errorf("list local Homarr images: %w", err)
}
@@ -65,7 +65,7 @@ func ListLocalImages() ([]Image, error) {
if err != nil {
return nil, err
}
labelsByImage, err := inspectImageLabels(images)
labelsByImage, err := inspectImageLabels(ctx, images)
if err != nil {
return nil, err
}
@@ -77,7 +77,7 @@ func ListLocalImages() ([]Image, error) {
return images, nil
}
func inspectImageLabels(images []Image) ([]map[string]string, error) {
func inspectImageLabels(ctx context.Context, images []Image) ([]map[string]string, error) {
if len(images) == 0 {
return nil, nil
}
@@ -85,7 +85,7 @@ func inspectImageLabels(images []Image) ([]map[string]string, error) {
for _, image := range images {
args = append(args, image.Reference())
}
out, err := exec.Command("docker", args...).Output()
out, err := exec.CommandContext(ctx, "docker", args...).Output()
if err != nil {
return nil, fmt.Errorf("inspect local Homarr images: %w", err)
}
@@ -135,10 +135,6 @@ func BuildCommand(ctx context.Context, options BuildOptions) (*exec.Cmd, error)
return command, nil
}
func Build(options BuildOptions) error {
return BuildContext(context.Background(), options, os.Stdout, os.Stderr)
}
func BuildContext(ctx context.Context, options BuildOptions, stdout, stderr io.Writer) error {
command, err := BuildCommand(ctx, options)
if err != nil {
@@ -17,7 +17,7 @@ func TestParseImageListFiltersHomarrTags(t *testing.T) {
}
func TestInspectImageLabelsWithNoImages(t *testing.T) {
labels, err := inspectImageLabels(nil)
labels, err := inspectImageLabels(t.Context(), nil)
if err != nil {
t.Fatal(err)
}
@@ -53,7 +53,7 @@ func TestListLocalImagesIntegration(t *testing.T) {
if wantTag == "" {
t.Skip("set HOMARR_TEST_LOCAL_IMAGE to a local homarr image tag")
}
images, err := ListLocalImages()
images, err := ListLocalImages(t.Context())
if err != nil {
t.Fatal(err)
}
+5 -4
View File
@@ -1,6 +1,7 @@
package gh
import (
"context"
"encoding/json"
"fmt"
"os/exec"
@@ -76,8 +77,8 @@ func rollupState(checks []rawCheck) string {
return "SUCCESS"
}
func ListPRs(limit int, includeBots bool) ([]PR, error) {
out, err := exec.Command("gh", "pr", "list",
func ListPRs(ctx context.Context, limit int, includeBots bool) ([]PR, error) {
out, err := exec.CommandContext(ctx, "gh", "pr", "list",
"--repo", Repo,
"--state", "open",
"--limit", fmt.Sprint(limit),
@@ -110,8 +111,8 @@ func ListPRs(limit int, includeBots bool) ([]PR, error) {
return prs, nil
}
func GetPR(number int) (*PR, error) {
out, err := exec.Command("gh", "pr", "view", fmt.Sprint(number),
func GetPR(ctx context.Context, number int) (*PR, error) {
out, err := exec.CommandContext(ctx, "gh", "pr", "view", fmt.Sprint(number),
"--repo", Repo,
"--json", "number,title,author,headRefName,updatedAt,isDraft,statusCheckRollup",
).Output()
+1 -1
View File
@@ -47,7 +47,7 @@ func TestListPRsExcludesBotsIntegration(t *testing.T) {
t.Skip("set HOMARR_INTEGRATION_TEST=1 to query GitHub")
}
prs, err := ListPRs(50, false)
prs, err := ListPRs(t.Context(), 50, false)
if err != nil {
t.Fatal(err)
}
+18 -37
View File
@@ -1,20 +1,22 @@
package run
import (
"context"
"fmt"
"os"
"strings"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
)
type Options struct {
Context context.Context
PR int
Tag string
Demo bool
Env []string
FetchTitle bool
FindPort func(int) int
}
type Plan struct {
@@ -22,7 +24,6 @@ type Plan struct {
Name string
Volume string
Label string
TabTitle string
HostPort int
Env []string
Platform string
@@ -32,6 +33,14 @@ type Plan struct {
func BuildPlan(opts Options) (*Plan, error) {
p := &Plan{}
ctx := opts.Context
if ctx == nil {
ctx = context.Background()
}
findPort := opts.FindPort
if findPort == nil {
findPort = docker.FindFreePort
}
if opts.PR > 0 {
p.PRNumber = opts.PR
@@ -42,9 +51,8 @@ func BuildPlan(opts Options) (*Plan, error) {
p.Pull = true
p.Platform = "linux/amd64"
if opts.FetchTitle {
if pr, err := gh.GetPR(opts.PR); err == nil && pr.Title != "" {
if pr, err := gh.GetPR(ctx, opts.PR); err == nil && pr.Title != "" {
p.Label = fmt.Sprintf("PR #%d — %s", opts.PR, pr.Title)
p.TabTitle = truncate(fmt.Sprintf("🦞 %s", pr.Title), 40)
}
}
} else if opts.Tag != "" {
@@ -56,11 +64,10 @@ func BuildPlan(opts Options) (*Plan, error) {
return nil, fmt.Errorf("need a tag or --pr")
}
if p.TabTitle == "" {
p.TabTitle = "🦞 " + strings.TrimPrefix(p.Label, "tag: ")
p.HostPort = findPort(7575)
if p.HostPort == 0 {
return nil, fmt.Errorf("no free host port is available")
}
p.HostPort = docker.FindFreePort(7575)
p.Env = append(p.Env, opts.Env...)
if opts.Demo {
p.Env = append(p.Env, "DEMO_MODE=true", "UNSAFE_ENABLE_MOCK_INTEGRATION=true")
@@ -101,38 +108,12 @@ func start(p *Plan, daemon bool) error {
}
previousPort := p.HostPort
p.HostPort = docker.FindFreePort(p.HostPort + 1)
if p.HostPort == 0 {
return fmt.Errorf("no free host port is available: %w", err)
}
if !daemon {
fmt.Fprintf(os.Stderr, "Port %d unavailable; retrying on %d.\n", previousPort, p.HostPort)
}
}
return nil
}
func SetTerminalChrome(title string) {
fmt.Printf("\033]0;%s\007", title)
if os.Getenv("ITERM_SESSION_ID") != "" {
fmt.Printf("\033]6;1;bg;red;brightness;%d\007", randInt())
fmt.Printf("\033]6;1;bg;green;brightness;%d\007", randInt())
fmt.Printf("\033]6;1;bg;blue;brightness;%d\007", randInt())
}
if tabID := os.Getenv("TABBY_TAB_ID"); tabID != "" {
if dir := os.Getenv("TABBY_TITLES_DIR"); dir != "" {
os.WriteFile(fmt.Sprintf("%s/%s.txt", dir, tabID), []byte(title), 0644)
}
}
}
var seed = int64(os.Getpid())
func randInt() int {
seed = seed*1103515245 + 12345
return int((seed / 65536) % 256)
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n-1]) + "…"
}
+8 -1
View File
@@ -9,7 +9,7 @@ import (
)
func TestPRPlanUsesAMD64(t *testing.T) {
plan, err := BuildPlan(Options{PR: 1})
plan, err := BuildPlan(Options{PR: 1, FindPort: func(int) int { return 7575 }})
if err != nil {
t.Fatal(err)
}
@@ -18,6 +18,13 @@ func TestPRPlanUsesAMD64(t *testing.T) {
}
}
func TestBuildPlanFailsWhenNoPortIsAvailable(t *testing.T) {
_, err := BuildPlan(Options{Tag: "dev", FindPort: func(int) int { return 0 }})
if err == nil {
t.Fatal("expected no-free-port error")
}
}
func TestStartPRIntegration(t *testing.T) {
if os.Getenv("HOMARR_DOCKER_INTEGRATION_TEST") != "1" {
t.Skip("set HOMARR_DOCKER_INTEGRATION_TEST=1 and HOMARR_TEST_PR=<number>")
+29 -29
View File
@@ -1,6 +1,7 @@
package tui
import (
"context"
"fmt"
"os/exec"
"strings"
@@ -40,6 +41,7 @@ type dashModel struct {
type tickMsg time.Time
type actionMsg string
type rowsMsg []table.Row
type logsMsg struct {
name string
content string
@@ -54,7 +56,9 @@ func fetchLogs(name string) tea.Cmd {
if name == "" {
return logsMsg{}
}
out, err := exec.Command("docker", "logs", "--tail", "200", name).CombinedOutput()
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()}
}
@@ -62,6 +66,10 @@ func fetchLogs(name string) tea.Cmd {
}
}
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))
@@ -103,7 +111,6 @@ func dashboardColumns(width int) []table.Column {
func newDashModel() dashModel {
t := table.New(
table.WithColumns(dashboardColumns(104)),
table.WithRows(refreshRows()),
table.WithFocused(true),
table.WithHeight(10),
table.WithWidth(104),
@@ -122,8 +129,7 @@ func newDashModel() dashModel {
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)
rows := refreshRows()
return dashModel{table: t, allRows: rows, filter: filter, logs: logs, showLogs: true, follow: true, width: 104, height: 30}
return dashModel{table: t, filter: filter, logs: logs, showLogs: true, follow: true, width: 104, height: 30}
}
func (m *dashModel) applyFilter() {
@@ -141,11 +147,6 @@ func (m *dashModel) applyFilter() {
m.table.SetRows(rows)
}
func (m *dashModel) refresh() {
m.allRows = refreshRows()
m.applyFilter()
}
func (m dashModel) selected() string {
row := m.table.SelectedRow()
if len(row) == 0 {
@@ -154,8 +155,16 @@ func (m dashModel) selected() string {
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(), fetchLogs(m.selected()))
return tea.Batch(tick(), loadRows(), fetchLogs(m.selected()))
}
func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -170,8 +179,11 @@ func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.layout()
return m, nil
case tickMsg:
m.refresh()
return m, tea.Batch(tick(), fetchLogs(m.selected()))
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
@@ -184,8 +196,7 @@ func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case actionMsg:
m.status = string(msg)
m.confirmRemove = ""
m.refresh()
return m, nil
return m, loadRows()
case tea.KeyPressMsg:
if m.filtering {
switch msg.Key().Code {
@@ -278,7 +289,7 @@ func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case "o":
if name != "" {
if port := portOf(name); port != "" {
if port := m.selectedPort(); port != "" {
if err := platform.OpenURL("http://localhost:" + port); err != nil {
m.status = "open failed: " + err.Error()
}
@@ -286,7 +297,7 @@ func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
case "c":
if name != "" {
if port := portOf(name); port != "" {
if port := m.selectedPort(); port != "" {
url := "http://localhost:" + port
if err := platform.CopyText(url); err != nil {
m.status = "copy failed: " + err.Error()
@@ -296,8 +307,7 @@ func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
case "r":
m.refresh()
return m, fetchLogs(name)
return m, loadRows()
case "k":
m.table.MoveUp(1)
return m, fetchLogs(m.selected())
@@ -348,16 +358,6 @@ func (m dashModel) selectedDetailView() string {
return title + "\n" + helpStyle.Render(meta)
}
func portOf(name string) string {
containers, _ := docker.List()
for _, c := range containers {
if c.Name == name {
return c.HostPort()
}
}
return ""
}
func (m dashModel) View() tea.View {
var b strings.Builder
running := 0
@@ -372,7 +372,7 @@ func (m dashModel) View() tea.View {
b.WriteString(m.filter.View() + "\n\n")
}
if len(m.table.Rows()) == 0 {
message := "no homarr containers — start one with `homarr <tag>` or `homarr --pr N`"
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()
}
@@ -16,6 +16,7 @@ func TestDashboardRendersRowsAndLogs(t *testing.T) {
"homarr-test:pr-6441",
"7575",
"http://localhost:7575",
"Up 2 minutes",
}})
m.logs.SetWidth(104)
m.logs.SetHeight(5)
@@ -35,8 +36,8 @@ func TestDashboardRendersRowsAndLogs(t *testing.T) {
func TestDashboardArrowNavigation(t *testing.T) {
m := newDashModel()
m.table.SetRows([]table.Row{
{"homarr_first", "running", "homarr:first", "7575", ""},
{"homarr_second", "running", "homarr:second", "7576", ""},
{"homarr_first", "running", "homarr:first", "7575", "", "Up 1 minute"},
{"homarr_second", "running", "homarr:second", "7576", "", "Up 1 minute"},
})
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown}))
@@ -48,7 +49,7 @@ func TestDashboardArrowNavigation(t *testing.T) {
func TestDashboardIgnoresStaleLogs(t *testing.T) {
m := newDashModel()
m.table.SetRows([]table.Row{{"homarr_current", "running", "homarr:current", "7575", ""}})
m.table.SetRows([]table.Row{{"homarr_current", "running", "homarr:current", "7575", "", "Up 1 minute"}})
m.logs.SetWidth(104)
m.logs.SetHeight(5)
m.logs.SetContent("current logs")
+51 -28
View File
@@ -109,6 +109,10 @@ type rebuildDoneMsg struct {
err error
canceled bool
}
type runningRefreshedMsg struct {
rows []prRow
generation int
}
type pullReadyMsg struct{}
type prLogTickMsg struct{}
type pullEvent struct {
@@ -129,7 +133,7 @@ func splitDockerProgress(data []byte, atEOF bool) (advance int, token []byte, er
return 0, nil, nil
}
func startPull(cmd *exec.Cmd, events chan<- pullEvent) tea.Cmd {
func startPull(ctx context.Context, cmd *exec.Cmd, events chan<- pullEvent) tea.Cmd {
return func() tea.Msg {
reader, writer, err := os.Pipe()
if err != nil {
@@ -150,7 +154,7 @@ func startPull(cmd *exec.Cmd, events chan<- pullEvent) tea.Cmd {
scanner.Split(splitDockerProgress)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" {
if line != "" && ctx.Err() == nil && len(events) < cap(events)-1 {
events <- pullEvent{line: line}
}
}
@@ -180,8 +184,10 @@ func prLogTick() tea.Cmd {
func loadDev(includeBots bool) tea.Cmd {
return func() tea.Msg {
prs, prErr := gh.ListPRs(50, includeBots)
images, imgErr := docker.ListLocalImages()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
prs, prErr := gh.ListPRs(ctx, 50, includeBots)
images, imgErr := docker.ListLocalImages(ctx)
return prsLoadedMsg{prs: prs, images: images, prErr: prErr, imgErr: imgErr}
}
}
@@ -422,7 +428,7 @@ func (m *prsModel) refreshSelectedLogs() tea.Cmd {
}
func (m *prsModel) beginPull(row prRow) tea.Cmd {
plan, err := run.BuildPlan(run.Options{PR: row.pr.Number, Demo: m.demo})
plan, err := run.BuildPlan(run.Options{Context: context.Background(), PR: row.pr.Number, Demo: m.demo})
if err != nil {
m.status = err.Error()
return nil
@@ -433,7 +439,7 @@ func (m *prsModel) beginPull(row prRow) tea.Cmd {
}
}
ctx, cancel := context.WithCancel(context.Background())
events := make(chan pullEvent)
events := make(chan pullEvent, 128)
m.pulling = true
m.pullPlan = plan
m.pullCancel = cancel
@@ -450,7 +456,7 @@ func (m *prsModel) beginPull(row prRow) tea.Cmd {
m.status = fmt.Sprintf("pulling latest image for PR #%d…", row.pr.Number)
}
m.layout()
return tea.Batch(startPull(docker.PullCommandContext(ctx, plan.Image, plan.Platform), events), m.spinner.Tick)
return tea.Batch(startPull(ctx, docker.PullCommandContext(ctx, plan.Image, plan.Platform), events), m.spinner.Tick)
}
func (m *prsModel) appendPullLine(line string) {
@@ -488,24 +494,27 @@ func (m prsModel) Init() tea.Cmd {
return tea.Batch(loadDev(m.includeBots), m.spinner.Tick)
}
func refreshRunning(rows []prRow) []prRow {
containers, _ := docker.List()
for i, r := range rows {
name := fmt.Sprintf("homarr_pr_%d", r.pr.Number)
if r.kind == "local" {
name = "homarr_" + r.local.Tag
}
r.running = false
r.port = ""
for _, c := range containers {
if c.Name == name && c.Running() {
r.running = true
r.port = c.HostPort()
func refreshRunning(rows []prRow, generation int) tea.Cmd {
return func() tea.Msg {
containers, _ := docker.List()
updated := append([]prRow(nil), rows...)
for index, row := range updated {
name := fmt.Sprintf("homarr_pr_%d", row.pr.Number)
if row.kind == "local" {
name = "homarr_" + row.local.Tag
}
row.running = false
row.port = ""
for _, container := range containers {
if container.Name == name && container.Running() {
row.running = true
row.port = container.HostPort()
}
}
updated[index] = row
}
rows[i] = r
return runningRefreshedMsg{rows: updated, generation: generation}
}
return rows
}
func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -542,9 +551,8 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
for _, image := range msg.images {
m.rows = append(m.rows, prRow{kind: "local", local: image, imageState: "yes"})
}
m.rows = refreshRunning(m.rows)
m.applyFilter()
return m, m.startImageChecks()
return m, tea.Batch(m.startImageChecks(), refreshRunning(m.rows, m.imageGen))
case imageCheckedMsg:
if msg.gen != m.imageGen {
return m, nil
@@ -566,11 +574,19 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.startImageChecks()
case prActionMsg:
m.status = msg.text
m.rows = refreshRunning(m.rows)
return m, refreshRunning(m.rows, m.imageGen)
case runningRefreshedMsg:
if msg.generation != m.imageGen {
return m, nil
}
m.rows = msg.rows
m.applyFilter()
return m, m.refreshSelectedLogs()
case rebuildDoneMsg:
m.rebuilding = false
if m.rebuildCancel != nil {
m.rebuildCancel()
}
m.rebuildCancel = nil
m.loading = true
if msg.canceled {
@@ -604,6 +620,9 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
m.pulling = false
m.pullEvents = nil
if m.pullCancel != nil {
m.pullCancel()
}
m.pullCancel = nil
m.layout()
if msg.err != nil {
@@ -619,6 +638,9 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.pullCanceled = false
plan := m.pullPlan
m.pullPlan = nil
if plan == nil {
return m, nil
}
for i := range m.rows {
if m.rows[i].pr.Number == plan.PRNumber {
m.rows[i].imageState = "yes"
@@ -731,7 +753,7 @@ func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, m.beginPull(*row)
}
plan, err := run.BuildPlan(run.Options{Tag: row.local.Tag, Demo: m.demo})
plan, err := run.BuildPlan(run.Options{Context: context.Background(), Tag: row.local.Tag, Demo: m.demo})
if err != nil {
m.status = err.Error()
return m, nil
@@ -897,10 +919,11 @@ func (m prsModel) selectedDetailView() string {
}
func truncateText(value string, length int) string {
if len(value) <= length {
runes := []rune(value)
if len(runes) <= length {
return value
}
return value[:length]
return string(runes[:length])
}
func (m prsModel) View() tea.View {
+18
View File
@@ -115,3 +115,21 @@ func TestRebuildCanBeCanceled(t *testing.T) {
t.Fatalf("cancel state: canceled=%v status=%q", canceled, got.status)
}
}
func TestCompletedPullWithoutPlanDoesNotPanic(t *testing.T) {
m := newPRsModel()
m.pulling = true
m.pullCancel = func() {}
updated, cmd := m.Update(pullEvent{done: true})
got := updated.(prsModel)
if got.pulling || cmd != nil {
t.Fatalf("pulling=%v cmd=%v", got.pulling, cmd)
}
}
func TestTruncateTextPreservesUTF8(t *testing.T) {
if got := truncateText("🦞homarr", 2); got != "🦞h" {
t.Fatalf("truncateText() = %q", got)
}
}
+23 -13
View File
@@ -105,11 +105,11 @@ var buildCmd = &cobra.Command{
}
var rebuildCmd = &cobra.Command{
Use: "rebuild [name]",
Use: "rebuild <name>",
Short: "Rebuild a local image from its recorded checkout or pull request",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
images, err := docker.ListLocalImages()
images, err := docker.ListLocalImages(cmd.Context())
if err != nil {
return err
}
@@ -123,7 +123,7 @@ var rebuildCmd = &cobra.Command{
}
var logsCmd = &cobra.Command{
Use: "logs [container]",
Use: "logs <container>",
Short: "Follow logs of a running instance",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
@@ -132,7 +132,7 @@ var logsCmd = &cobra.Command{
}
var stopCmd = &cobra.Command{
Use: "stop [container]",
Use: "stop <container>",
Short: "Stop a running instance",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
@@ -141,7 +141,7 @@ var stopCmd = &cobra.Command{
}
var restartCmd = &cobra.Command{
Use: "restart [container]",
Use: "restart <container>",
Short: "Restart a running instance",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
@@ -150,7 +150,7 @@ var restartCmd = &cobra.Command{
}
var removeCmd = &cobra.Command{
Use: "remove [container]",
Use: "remove <container>",
Short: "Force-remove an instance",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
@@ -159,7 +159,7 @@ var removeCmd = &cobra.Command{
}
var openCmd = &cobra.Command{
Use: "open [container]",
Use: "open <container>",
Short: "Open a running instance in the browser",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
@@ -168,11 +168,19 @@ var openCmd = &cobra.Command{
return err
}
for _, container := range containers {
if container.Name == args[0] && container.Running() && container.HostPort() != "" {
return platform.OpenURL("http://localhost:" + container.HostPort())
if container.Name != args[0] {
continue
}
if !container.Running() {
return fmt.Errorf("container %q is not running", args[0])
}
port := container.HostPort()
if port == "" {
return fmt.Errorf("container %q does not publish the Homarr port", args[0])
}
return platform.OpenURL("http://localhost:" + port)
}
return fmt.Errorf("running container %q has no Homarr port", args[0])
return fmt.Errorf("container %q was not found", args[0])
},
}
@@ -253,10 +261,14 @@ func runLaunch(cmd *cobra.Command, args []string) error {
return fmt.Errorf("tag and --pr cannot be used together")
}
if flagPR == 0 && tag == "" {
return cmd.Usage()
if err := cmd.Usage(); err != nil {
return err
}
return fmt.Errorf("provide a tag or --pr")
}
plan, err := run.BuildPlan(run.Options{
Context: cmd.Context(),
PR: flagPR,
Tag: tag,
Demo: flagDemo,
@@ -267,8 +279,6 @@ func runLaunch(cmd *cobra.Command, args []string) error {
return err
}
run.SetTerminalChrome(plan.TabTitle)
fmt.Printf("Starting Homarr (%s)\n", plan.Label)
fmt.Printf(" Image : %s\n", plan.Image)
fmt.Printf(" Container : %s\n", plan.Name)
+11
View File
@@ -26,3 +26,14 @@ func TestRunLaunchRejectsNegativePR(t *testing.T) {
t.Fatalf("error = %v", err)
}
}
func TestRunLaunchRejectsMissingImage(t *testing.T) {
previousPR := flagPR
t.Cleanup(func() { flagPR = previousPR })
flagPR = 0
err := runLaunch(runCmd, nil)
if err == nil || !strings.Contains(err.Error(), "provide a tag or --pr") {
t.Fatalf("error = %v", err)
}
}