fix(dev): address CLI review feedback
This commit is contained in:
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
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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]) + "…"
|
||||
}
|
||||
@@ -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>")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user