From 81aa7045371390e73e0c3e3433f11efa1d4cb846 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 21 Aug 2026 18:59:11 +0200 Subject: [PATCH] feat(cli): rework developer CLI with non-blocking tasks and GitHub CI checks (#6651) --- .agents/skills/homarr-local-harness/SKILL.md | 60 + .../homarr-local-harness/agents/openai.yaml | 4 + .gitignore | 2 + .../command-line/development/index.mdx | 168 ++- .../docs/docs/advanced/command-line/index.mdx | 24 + package.json | 5 +- packages/cli/src/commands/harness.ts | 357 +++++ packages/cli/src/index.ts | 48 +- tools/homarr-dev/README.md | 67 +- tools/homarr-dev/internal/cli/build.go | 74 ++ tools/homarr-dev/internal/cli/cli.go | 197 +++ tools/homarr-dev/internal/cli/cli_test.go | 107 ++ tools/homarr-dev/internal/cli/doctor.go | 90 ++ tools/homarr-dev/internal/cli/manage.go | 329 +++++ tools/homarr-dev/internal/docker/api.go | 187 +++ tools/homarr-dev/internal/docker/docker.go | 48 +- tools/homarr-dev/internal/docker/images.go | 49 +- tools/homarr-dev/internal/docker/progress.go | 410 ++++++ .../internal/docker/progress_test.go | 163 +++ tools/homarr-dev/internal/docker/stream.go | 102 ++ tools/homarr-dev/internal/docker/volumes.go | 151 +++ tools/homarr-dev/internal/gh/gh.go | 149 +++ tools/homarr-dev/internal/gh/gh_test.go | 44 + tools/homarr-dev/internal/logs/registry.go | 241 ++++ .../homarr-dev/internal/logs/registry_test.go | 96 ++ tools/homarr-dev/internal/platform/open.go | 23 +- tools/homarr-dev/internal/registry/ghcr.go | 165 +++ .../internal/registry/probe_test.go | 32 + tools/homarr-dev/internal/run/build.go | 68 +- tools/homarr-dev/internal/run/prepare.go | 128 ++ tools/homarr-dev/internal/run/run_test.go | 8 +- .../internal/task/build_probe_test.go | 59 + tools/homarr-dev/internal/task/jobs.go | 237 ++++ tools/homarr-dev/internal/task/task.go | 360 +++++ tools/homarr-dev/internal/tui/actions.go | 410 ++++++ tools/homarr-dev/internal/tui/app.go | 122 -- tools/homarr-dev/internal/tui/app_test.go | 47 - tools/homarr-dev/internal/tui/dashboard.go | 415 ------ .../homarr-dev/internal/tui/dashboard_test.go | 62 - tools/homarr-dev/internal/tui/data.go | 155 +++ tools/homarr-dev/internal/tui/dev.go | 1158 ----------------- tools/homarr-dev/internal/tui/dev_test.go | 228 ---- tools/homarr-dev/internal/tui/harness_test.go | 270 ++++ tools/homarr-dev/internal/tui/keys.go | 415 ++++++ tools/homarr-dev/internal/tui/manage.go | 171 +++ tools/homarr-dev/internal/tui/model.go | 788 +++++++++++ tools/homarr-dev/internal/tui/rows.go | 381 ++++++ tools/homarr-dev/internal/tui/run.go | 25 + tools/homarr-dev/internal/tui/sidebar.go | 382 ++++++ tools/homarr-dev/internal/tui/view.go | 384 ++++++ tools/homarr-dev/internal/ui/badge.go | 200 +++ tools/homarr-dev/internal/ui/keys.go | 138 ++ tools/homarr-dev/internal/ui/theme.go | 80 ++ .../internal/ui/width_probe_test.go | 34 + tools/homarr-dev/main.go | 310 +---- tools/homarr-dev/main_test.go | 71 - 56 files changed, 7950 insertions(+), 2548 deletions(-) create mode 100644 .agents/skills/homarr-local-harness/SKILL.md create mode 100644 .agents/skills/homarr-local-harness/agents/openai.yaml create mode 100644 packages/cli/src/commands/harness.ts create mode 100644 tools/homarr-dev/internal/cli/build.go create mode 100644 tools/homarr-dev/internal/cli/cli.go create mode 100644 tools/homarr-dev/internal/cli/cli_test.go create mode 100644 tools/homarr-dev/internal/cli/doctor.go create mode 100644 tools/homarr-dev/internal/cli/manage.go create mode 100644 tools/homarr-dev/internal/docker/api.go create mode 100644 tools/homarr-dev/internal/docker/progress.go create mode 100644 tools/homarr-dev/internal/docker/progress_test.go create mode 100644 tools/homarr-dev/internal/docker/stream.go create mode 100644 tools/homarr-dev/internal/docker/volumes.go create mode 100644 tools/homarr-dev/internal/logs/registry.go create mode 100644 tools/homarr-dev/internal/logs/registry_test.go create mode 100644 tools/homarr-dev/internal/registry/ghcr.go create mode 100644 tools/homarr-dev/internal/registry/probe_test.go create mode 100644 tools/homarr-dev/internal/run/prepare.go create mode 100644 tools/homarr-dev/internal/task/build_probe_test.go create mode 100644 tools/homarr-dev/internal/task/jobs.go create mode 100644 tools/homarr-dev/internal/task/task.go create mode 100644 tools/homarr-dev/internal/tui/actions.go delete mode 100644 tools/homarr-dev/internal/tui/app.go delete mode 100644 tools/homarr-dev/internal/tui/app_test.go delete mode 100644 tools/homarr-dev/internal/tui/dashboard.go delete mode 100644 tools/homarr-dev/internal/tui/dashboard_test.go create mode 100644 tools/homarr-dev/internal/tui/data.go delete mode 100644 tools/homarr-dev/internal/tui/dev.go delete mode 100644 tools/homarr-dev/internal/tui/dev_test.go create mode 100644 tools/homarr-dev/internal/tui/harness_test.go create mode 100644 tools/homarr-dev/internal/tui/keys.go create mode 100644 tools/homarr-dev/internal/tui/manage.go create mode 100644 tools/homarr-dev/internal/tui/model.go create mode 100644 tools/homarr-dev/internal/tui/rows.go create mode 100644 tools/homarr-dev/internal/tui/run.go create mode 100644 tools/homarr-dev/internal/tui/sidebar.go create mode 100644 tools/homarr-dev/internal/tui/view.go create mode 100644 tools/homarr-dev/internal/ui/badge.go create mode 100644 tools/homarr-dev/internal/ui/keys.go create mode 100644 tools/homarr-dev/internal/ui/theme.go create mode 100644 tools/homarr-dev/internal/ui/width_probe_test.go delete mode 100644 tools/homarr-dev/main_test.go diff --git a/.agents/skills/homarr-local-harness/SKILL.md b/.agents/skills/homarr-local-harness/SKILL.md new file mode 100644 index 000000000..19ae5a412 --- /dev/null +++ b/.agents/skills/homarr-local-harness/SKILL.md @@ -0,0 +1,60 @@ +--- +name: homarr-local-harness +description: Build and inspect a writable Docker demo harness from the current Homarr checkout when validating a local branch in a browser. +--- + +# Homarr Local Harness + +Use this skill when a Homarr change needs a real production image and browser validation. Work from the repository root and keep the harness name Docker-safe, such as `feature-board`. + +## Set up the harness + +Run the TypeScript operations CLI; this workflow does not use the interactive developer TUI: + +```sh +pnpm cli harness setup feature-board +``` + +`setup` builds the current checkout as `homarr:feature-board`, replaces the named harness container, and starts it with a persistent data volume. It enables demo mode, mock integrations, and writable demo mutations by default: + +- `DEMO_MODE=true` +- `DEMO_READ_ONLY=false` +- `UNSAFE_ENABLE_MOCK_INTEGRATION=true` + +Pass repeatable environment overrides when the feature needs them: + +```sh +pnpm cli harness setup feature-board \ + --env WORKSHOP_WEB_URL=https://example.test \ + --env FEATURE_FLAG=true +``` + +Use `pnpm cli harness build ` when only the image is needed, and `pnpm cli harness run ` to start an already-built image. `--port ` pins a host port; otherwise Docker assigns one. + +Always use the URL printed by the command. If the output is no longer available, query it directly: + +```sh +pnpm cli harness port feature-board +``` + +Do not assume port `7575`; the container listens on `7575`, but the host port is dynamic by default. + +## Browser validation + +Open the printed URL with the environment's `browser-harness` tool. In this workspace, the `agent-browser` CLI is the supported browser automation fallback. Load its current workflow before invoking it: + +```sh +agent-browser skills get core +``` + +Navigate to the exact harness URL, wait for the app to become usable, exercise the changed flow, and capture screenshots or other browser evidence. Keep browser state in a named session when the workflow needs login persistence. Report the exact URL, viewport/session used, and screenshot paths; a Docker process or HTTP health response alone is not browser acceptance. + +## Cleanup + +Stop the named container when finished: + +```sh +pnpm cli harness stop feature-board +``` + +Stopping keeps the data volume so the same harness can be restarted. A later `setup` rebuilds the image and replaces only the container. Preserve unrelated containers, images, volumes, and working-tree changes. diff --git a/.agents/skills/homarr-local-harness/agents/openai.yaml b/.agents/skills/homarr-local-harness/agents/openai.yaml new file mode 100644 index 000000000..fbe8007db --- /dev/null +++ b/.agents/skills/homarr-local-harness/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Homarr Local Harness" + short_description: "Build and browse a writable local Homarr branch harness" + default_prompt: "Use $homarr-local-harness to set up a writable Homarr harness for the current branch and validate it in the browser." diff --git a/.gitignore b/.gitignore index 64749f817..be3e30235 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ packages/db/migrations/*/migrate.cjs # production build +tools/homarr-dev/homarr +tools/homarr-dev/homarr-dev # misc .DS_Store diff --git a/apps/docs/docs/advanced/command-line/development/index.mdx b/apps/docs/docs/advanced/command-line/development/index.mdx index ee5036a72..6f4701af8 100644 --- a/apps/docs/docs/advanced/command-line/development/index.mdx +++ b/apps/docs/docs/advanced/command-line/development/index.mdx @@ -1,43 +1,175 @@ # Developer Docker CLI -The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images. +The host-side developer CLI builds and runs local Homarr images and pull-request previews with non-blocking background operations. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images. -To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features. +To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication (`gh auth login`) is only needed for pull-request features. -## Browse development images +## Requirements + +- Docker or OrbStack +- Go 1.25+ (when building the CLI from source) +- GitHub CLI authenticated with `gh auth login` (for pull-request and CI features) +- A [Nerd Font](https://www.nerdfonts.com/) enabled in your terminal (recommended for icons, status indicators, and TUI glyphs) + +## Interactive Development Browser ```bash pnpm dev:cli -- +# or with global binary: +homarr ``` -The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen. +The main 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 build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` (or `1`/`2`) to toggle between the image browser and the instance dashboard. -A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds. +A locally built PR image is folded into its remote PR row only when its recorded revision matches the current GitHub head SHA (marked `◆ local`). When the matching GHCR image is also available, pressing `Enter` opens a local/remote selector with local preselected. Outdated local builds remain separate rows. -## Build images +### Multi-Source Sidebar + +Press `Tab` to cycle between sidebar tabs: + +- **`[logs]`**: Live container output streams (press `f` to follow/pause, `L` to pin to the selected container, `Page Up`/`Page Down` to scroll). +- **`[build]`**: Real-time BuildKit step streaming for active local builds. +- **`[ci]`**: GitHub Actions CI check statuses, run duration, and workflow links for pull requests. + +### Background Task Overlay + +Pulls and builds run asynchronously in the background. Press `t` to open the tasks overlay, showing: + +- Layer-by-layer download progress and transfer speeds (via Docker Engine API). +- Active build stages and logs. +- Task cancellation and status. + +### Browser Shortcuts + +| Key | Action | +| ---------------------- | --------------------------------------------------------------------------------------------- | +| `/` | Filter rows by PR number, title, author, branch, CI, image, state, or port | +| `↑` / `↓` or `j` / `k` | Navigate rows | +| `Enter` or `Space` | Start the available image; choose local or remote when both exist; stop if running | +| `R` | Build locally in background (or rebuild local image) | +| `p` | Pull remote PR image in background and start container | +| `m` | Manage mode (`b` rebuild, `i` delete image, `d` delete data, `c` remove container, `p` prune) | +| `t` | Toggle background tasks overlay | +| `Tab` | Cycle sidebar tabs (`[logs]`, `[build]`, `[ci]`) | +| `l` / `L` | Toggle sidebar visibility / Pin logs to selected instance | +| `f` | Follow or pause logs | +| `M` | Toggle demo mode for next launch | +| `b` | Toggle bot pull requests | +| `o` / `a` | Open PR on GitHub / Open running instance in browser | +| `c` | Copy instance URL to clipboard | +| `r` | Refresh data | +| `1` / `2` / `d` | Switch screen (development browser / instance dashboard) | +| `?` / `q` | Help overlay / Quit (cancels active background tasks) | + +## Instance Dashboard ```bash -pnpm dev:cli -- build -pnpm dev:cli -- build --pr [name] +homarr dash +# or +pnpm dev:cli -- dash ``` -The first command builds the current checkout as `homarr:`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-` when `name` is omitted. +The instance dashboard displays all running and stopped Homarr containers, published ports, health states, and resource volumes. -## Rebuild images +| Key | Action | +| ------------------ | ---------------------------------------- | +| `Enter` or `Space` | Start or stop selected instance | +| `s` / `S` | Stop / Restart instance | +| `x` | Remove container (preserves data volume) | +| `m` | Manage mode for selected instance | +| `R` | Rebuild from recorded build provenance | + +## CLI Commands + +The commands below use the installed `homarr` binary. Without global installation, prefix each command with `pnpm dev:cli --` (for example, `pnpm dev:cli -- list`). + +### Launch Instances ```bash -pnpm dev:cli -- rebuild +homarr run dev +homarr run --detach dev +homarr run --pr 6441 +homarr run --pr 6441 --demo +homarr --pr 6441 --env WORKSHOP_WEB_URL=https://app-v2.preview.homarr.dev/ ``` -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. +- `--pr `: Pull and run a PR image from GHCR (shorthand: `homarr --pr `). +- `--demo` (`-m`): Start with mock integrations and sample data. +- `--detach`: Start container in background. +- `--env` (`-e`): Pass additional environment variables (repeatable: `--env KEY=VALUE`). -## Run images +### Build & Rebuild ```bash -homarr --pr --env HOMARR_WEBSITE_URL=https://app-v2.preview.homarr.dev/ -homarr run -homarr run --pr +# Build current checkout as homarr: +homarr build feature + +# Build a pull request locally in a temporary checkout +homarr build --pr 6441 + +# Rebuild an existing image from recorded provenance +homarr rebuild feature ``` -The direct `homarr --pr ` form is shorthand for `homarr run --pr `. Add `--detach` to run in the -background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables. +Images built with the CLI record their source checkout, revision SHA, and optional PR number. `homarr rebuild` reads this provenance to reconstruct the build without manual configuration. + +### GitHub CI Checks + +```bash +# View CI checks for a specific PR +homarr ci 6441 + +# View CI checks for the current branch +homarr ci + +# Watch CI checks until completion +homarr ci 6441 --watch +``` + +### Interactive Shell + +```bash +# Open interactive shell in running container +homarr shell homarr-dev + +# Run specific command in container +homarr shell homarr-dev pnpm db:migration:sqlite:run +``` + +### Image & Volume Management + +```bash +# List local images with build provenance (source, revision, PR) +homarr images + +# Delete local image +homarr images rm feature + +# List data volumes with owner PR/tag and in-use status +homarr data + +# Delete data volume (safely stops and removes owning container first) +homarr data rm homarr_feature_data + +# Remove stopped containers (prompts without --force) +homarr prune --force +``` + +### Instance Operations & Diagnostics + +```bash +# List instances in a script-friendly table +homarr list + +# Follow container logs +homarr logs homarr-dev + +# Stop, restart, remove, or open container in browser +homarr stop homarr-dev +homarr restart homarr-dev +homarr remove homarr-dev +homarr open homarr-dev + +# Verify Docker daemon, Engine API, GHCR access, and gh CLI authentication +homarr doctor +``` diff --git a/apps/docs/docs/advanced/command-line/index.mdx b/apps/docs/docs/advanced/command-line/index.mdx index 287985739..04024254f 100644 --- a/apps/docs/docs/advanced/command-line/index.mdx +++ b/apps/docs/docs/advanced/command-line/index.mdx @@ -25,3 +25,27 @@ You should also create a backup of the database, in case you break something. 4. Click "Execute" in the top right of the Window. ![](img/334116411-df1482f9-3aa9-4686-b77f-e0dca09e60b2.png) 5. Select /bin/bash and click Execute ![](img/334116518-e1ddb515-99c7-4159-81bf-76e889d1e808.png) 6. Run the Homarr command and press enter (eg. homarr reset-owner-password) ![](img/334116642-08c4f204-5cd0-4e4e-a785-0142e1b279ca.png) + +## Build a local branch harness + +The host-side operations CLI can build the current checkout and run it as a named, browser-ready Docker harness. This is separate from the recovery CLI described above and does not open the interactive developer TUI. + +```sh +pnpm cli harness setup feature-board +``` + +The command builds `homarr:feature-board`, starts a container with a persistent data volume, enables demo mode, and keeps demo mutations writable (`DEMO_READ_ONLY=false`). Docker assigns a free host port by default; use the URL printed by the command or query it later: + +```sh +pnpm cli harness port feature-board +``` + +Pass environment overrides by repeating `--env`: + +```sh +pnpm cli harness setup feature-board \ + --env WORKSHOP_WEB_URL=https://example.test \ + --env FEATURE_FLAG=true +``` + +Other commands are `harness build `, `harness run `, and `harness stop `. Use the returned URL with a browser automation harness for feature validation; do not assume the host port is `7575`. diff --git a/package.json b/package.json index 927849b84..347e76aa0 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,9 @@ "db:studio": "pnpm -F db studio", "dev": "pnpm -F nextjs dev", "dev:benchmark": "node --experimental-strip-types scripts/benchmarks/dev-board.mts", - "dev:cli": "go -C tools/homarr-dev run .", - "dev:cli:install": "go -C tools/homarr-dev install .", + "dev:cli": "go -C tools/homarr-dev run -buildvcs=false .", + "dev:cli:build": "go -C tools/homarr-dev build -buildvcs=false .", + "dev:cli:install": "go -C tools/homarr-dev install -buildvcs=false .", "dev:docs": "pnpm -F @homarr/docs dev", "docker:docs": "docker compose -f apps/workshop/docker-compose.yml up --build docs", "docker:workshop": "docker compose -f apps/workshop/docker-compose.yml up --build workshop", diff --git a/packages/cli/src/commands/harness.ts b/packages/cli/src/commands/harness.ts new file mode 100644 index 000000000..aa375f85a --- /dev/null +++ b/packages/cli/src/commands/harness.ts @@ -0,0 +1,357 @@ +import { randomBytes } from "node:crypto"; +import { constants } from "node:fs"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { execFile, spawn } from "node:child_process"; +import { promisify } from "node:util"; + +import { command, number, positional, string } from "@drizzle-team/brocli"; + +const execFileAsync = promisify(execFile); +const environmentSeparator = "\u0000"; +const validEnvironmentName = /^[A-Za-z_][A-Za-z0-9_]*$/; +const validHarnessName = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/; + +const demoEnvironment: ReadonlyArray<[string, string]> = [ + ["DEMO_MODE", "true"], + ["DEMO_READ_ONLY", "false"], + ["UNSAFE_ENABLE_MOCK_INTEGRATION", "true"], +]; + +type HarnessRuntimeOptions = { + name: string; + env?: string; + port?: number; +}; + +const runtimeOptions = () => ({ + name: positional("name").required().desc("Name used for the image, container, and data volume"), + env: string("env").alias("e").desc("Environment override KEY=VALUE; repeat the flag for more values"), + port: number("port").int().min(0).max(65535).desc("Host port, or 0 for an automatically assigned port"), +}); + +function validateHarnessName(name: string): string { + if (!validHarnessName.test(name)) { + throw new Error("Harness name must be 1-63 characters and contain only letters, numbers, '.', '_' or '-'"); + } + + return name; +} + +function imageName(name: string): string { + return `homarr:${validateHarnessName(name)}`; +} + +function containerName(name: string): string { + return `homarr-harness-${validateHarnessName(name)}`; +} + +function volumeName(name: string): string { + return `homarr-harness-${validateHarnessName(name)}-data`; +} + +function hasErrorCode(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} + +function commandError(commandName: string, error: unknown): Error { + if (hasErrorCode(error, "ENOENT")) { + return new Error(`${commandName} is required but was not found on PATH`); + } + + if (error instanceof Error) { + return new Error(`${commandName} failed: ${error.message}`); + } + + return new Error(`${commandName} failed`); +} + +async function execCommand(commandName: string, args: string[], cwd?: string): Promise { + try { + const result = await execFileAsync(commandName, args, { + cwd, + maxBuffer: 10 * 1024 * 1024, + }); + return result.stdout.trim(); + } catch (error) { + throw commandError(commandName, error); + } +} + +async function runCommand(commandName: string, args: string[], cwd?: string): Promise { + await new Promise((resolve, reject) => { + const child = spawn(commandName, args, { + cwd, + stdio: "inherit", + }); + + child.once("error", (error) => reject(commandError(commandName, error))); + child.once("close", (code, signal) => { + if (code === 0) { + resolve(); + return; + } + + const reason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`; + reject(new Error(`${commandName} failed with ${reason}`)); + }); + }); +} + +async function repositoryRoot(): Promise { + const root = await execCommand("git", ["rev-parse", "--show-toplevel"], process.cwd()); + + try { + await access(join(root, "Dockerfile"), constants.R_OK); + } catch { + throw new Error("Run the harness command from a Homarr checkout containing a Dockerfile"); + } + + return root; +} + +function parseEnvironmentOverrides(raw?: string): Map { + const values = raw?.split(environmentSeparator) ?? []; + const environment = new Map(demoEnvironment); + + for (const value of values) { + const separator = value.indexOf("="); + if (separator <= 0) { + throw new Error(`Invalid environment override ${JSON.stringify(value)}; use KEY=VALUE`); + } + + const name = value.slice(0, separator); + if (!validEnvironmentName.test(name)) { + throw new Error(`Invalid environment variable name ${JSON.stringify(name)}`); + } + + environment.set(name, value.slice(separator + 1)); + } + + return environment; +} + +function isEncryptionKey(value: string | undefined): value is string { + return value !== undefined && /^[a-f0-9]{64}$/i.test(value); +} + +async function readEncryptionKey(path: string): Promise { + try { + const value = (await readFile(path, "utf8")).trim(); + if (isEncryptionKey(value)) { + return value; + } + + throw new Error(`Invalid encryption key in ${path}`); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) { + return undefined; + } + + throw error; + } +} + +async function harnessEncryptionKey(name: string): Promise { + if (isEncryptionKey(process.env.SECRET_ENCRYPTION_KEY)) { + return process.env.SECRET_ENCRYPTION_KEY; + } + + const configRoot = process.env.HOMARR_HARNESS_CONFIG_DIR ?? join(homedir(), ".config", "homarr"); + const keyPath = join(configRoot, "local-harness", `${validateHarnessName(name)}.key`); + const existingKey = await readEncryptionKey(keyPath); + if (existingKey) { + return existingKey; + } + + const newKey = randomBytes(32).toString("hex"); + await mkdir(join(configRoot, "local-harness"), { recursive: true, mode: 0o700 }); + + try { + await writeFile(keyPath, `${newKey}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" }); + return newKey; + } catch (error) { + if (!hasErrorCode(error, "EEXIST")) { + throw error; + } + + const racedKey = await readEncryptionKey(keyPath); + if (racedKey) { + return racedKey; + } + + throw new Error(`Could not read the harness encryption key at ${keyPath}`, { cause: error }); + } +} + +async function harnessEnvironment(name: string, raw?: string): Promise { + const environment = parseEnvironmentOverrides(raw); + + if (!environment.has("SECRET_ENCRYPTION_KEY")) { + environment.set("SECRET_ENCRYPTION_KEY", await harnessEncryptionKey(name)); + } + + return Array.from(environment, ([key, value]) => `${key}=${value}`); +} + +async function buildHarnessImage(name: string): Promise { + const root = await repositoryRoot(); + const image = imageName(name); + + console.log(`Building ${image} from ${root}`); + await runCommand("docker", ["build", "--tag", image, "."], root); + console.log(`Built ${image}`); +} + +async function removeExistingContainer(name: string): Promise { + try { + await execCommand("docker", ["rm", "--force", containerName(name)]); + } catch { + // A missing container is the normal first-run path. Docker reports it as + // an error, so the cleanup is intentionally best-effort. + } +} + +async function publishedPort(name: string): Promise { + const output = await execCommand("docker", ["port", containerName(name), "7575/tcp"]); + + for (const line of output.split("\n")) { + const match = line.trim().match(/:(\d+)$/); + if (match) { + return Number(match[1]); + } + } + + throw new Error(`Container ${containerName(name)} has no published port for 7575/tcp`); +} + +async function startHarness(options: HarnessRuntimeOptions): Promise { + const name = validateHarnessName(options.name); + const image = imageName(name); + const container = containerName(name); + const requestedPort = options.port ?? 0; + const environment = await harnessEnvironment(name, options.env); + + await removeExistingContainer(name); + console.log(`Starting ${container} from ${image}`); + await runCommand("docker", [ + "run", + "--detach", + "--name", + container, + "--publish", + `${requestedPort}:7575`, + "--volume", + `${volumeName(name)}:/appdata`, + ...environment.flatMap((value) => ["--env", value]), + image, + ]); + + const port = await publishedPort(name); + console.log(`Harness ${name} is running`); + console.log(` Container : ${container}`); + console.log(` Image : ${image}`); + console.log(` Port : ${port}`); + console.log(` URL : http://127.0.0.1:${port}`); +} + +export const normalizeHarnessEnvironmentArgs = (argv: string[]): string[] => { + const normalized = argv.slice(0, 2); + const args = argv.slice(2); + const environmentValues: string[] = []; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === undefined) { + continue; + } + + if (arg === "--env" || arg === "-e") { + const value = args[index + 1]; + if (value === undefined) { + normalized.push(arg); + continue; + } + + environmentValues.push(value); + index += 1; + continue; + } + + if (arg.startsWith("--env=")) { + environmentValues.push(arg.slice("--env=".length)); + continue; + } + + normalized.push(arg); + } + + if (environmentValues.length > 0) { + normalized.push(`--env=${environmentValues.join(environmentSeparator)}`); + } + + return normalized; +}; + +const harnessBuild = command({ + name: "build", + desc: "Build the current checkout as a named local Homarr image", + options: { + name: positional("name").required().desc("Local image tag"), + }, + handler: async ({ name }) => buildHarnessImage(name), +}); + +const harnessRun = command({ + name: "run", + aliases: ["start", "up"], + desc: "Run a named local image in writable demo mode", + options: runtimeOptions(), + handler: async (options) => startHarness(options), +}); + +const harnessSetup = command({ + name: "setup", + aliases: ["create"], + desc: "Build the current checkout and run its writable demo harness", + options: runtimeOptions(), + handler: async (options) => { + await buildHarnessImage(options.name); + await startHarness(options); + }, +}); + +const harnessPort = command({ + name: "port", + desc: "Show the published host port and URL for a harness", + options: { + name: positional("name").required().desc("Harness name"), + }, + handler: async ({ name }) => { + const port = await publishedPort(name); + console.log(`Port: ${port}`); + console.log(`URL: http://127.0.0.1:${port}`); + }, +}); + +const harnessStop = command({ + name: "stop", + aliases: ["down"], + desc: "Stop a local harness container and keep its data volume", + options: { + name: positional("name").required().desc("Harness name"), + }, + handler: async ({ name }) => { + const container = containerName(name); + await execCommand("docker", ["stop", container]); + console.log(`Stopped ${container}`); + }, +}); + +export const harnessRoot = command({ + name: "harness", + aliases: ["local-harness"], + desc: "Build and run a local branch in a browser-ready demo harness", + subcommands: [harnessBuild, harnessRun, harnessSetup, harnessPort, harnessStop], +}); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f1b0c658d..1aa483261 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,14 +1,42 @@ import { run } from "@drizzle-team/brocli"; +import type { Command } from "@drizzle-team/brocli"; -import { fixUsernames } from "./commands/fix-usernames"; -import { integrationsRoot } from "./commands/integrations"; -import { recreateAdmin } from "./commands/recreate-admin"; -import { resetPassword } from "./commands/reset-password"; -import { usersRoot } from "./commands/users"; +import { harnessRoot, normalizeHarnessEnvironmentArgs } from "./commands/harness"; -const commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin]; +const harnessCommandNames = new Set(["harness", "local-harness"]); -void run(commands, { - name: "homarr-cli", - version: "1.0.0", -}); +function isHarnessInvocation(argv: string[]): boolean { + return argv.slice(2).some((argument) => harnessCommandNames.has(argument)); +} + +async function main(): Promise { + const harnessInvocation = isHarnessInvocation(process.argv); + let commands: Command[]; + + if (harnessInvocation) { + commands = [harnessRoot]; + } else { + const [{ fixUsernames }, { integrationsRoot }, { recreateAdmin }, { resetPassword }, { usersRoot }] = + await Promise.all([ + import("./commands/fix-usernames"), + import("./commands/integrations"), + import("./commands/recreate-admin"), + import("./commands/reset-password"), + import("./commands/users"), + ]); + commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin, harnessRoot]; + } + + let argSource = process.argv; + if (harnessInvocation) { + argSource = normalizeHarnessEnvironmentArgs(process.argv); + } + + await run(commands, { + argSource, + name: "homarr-cli", + version: "1.0.0", + }); +} + +void main(); diff --git a/tools/homarr-dev/README.md b/tools/homarr-dev/README.md index 01dcbb686..039c132cd 100644 --- a/tools/homarr-dev/README.md +++ b/tools/homarr-dev/README.md @@ -56,52 +56,69 @@ homarr dash Start on the instance dashboard homarr build Build this checkout as homarr: homarr build --pr Build a PR locally from a temporary checkout homarr rebuild Rebuild from recorded provenance +homarr images List local images with build provenance +homarr data List instance data volumes +homarr prune Remove stopped instances homarr list Script-friendly instance list homarr logs Follow logs +homarr shell Open an interactive shell in a running instance +homarr ci [pr] [--watch] View or watch GitHub CI checks for a pull request homarr open Open in browser homarr stop Stop homarr restart Restart homarr remove Force-remove -homarr doctor Check Docker and optional CLI integrations +homarr doctor Check Docker, Engine API, and optional integrations ``` ## Development Browser ```text / Filter by PR number, title, author, branch, CI, image, state, or port -↑/↓ or j/k Select -Enter or Space Start the matching local build, otherwise pull remote; stop when running -R Build a remote PR locally in the background or rebuild a local image -p Pull a remote PR image and redeploy, preserving port and volume -m Toggle demo mode for the next launch +↑/↓ or j/k Select row +Enter or Space Start the available image; choose local or remote when both exist; stop when running +R Build locally in background (or rebuild local image) +p Pull remote PR image in background and start container +m Manage mode (chords: b rebuild, i delete image, d delete data, c remove container, p prune) +t Background tasks overlay (status, progress, cancellation) +Tab Cycle sidebar tab (logs / build / ci) +l Toggle sidebar visibility +L Pin logs to currently selected container +f Follow or pause logs +Page Up/Down Scroll logs +M Toggle demo mode for next launch +b Toggle bot PRs o Open PR on GitHub -a Open running app -l Toggle inline logs for the selected running PR -f Follow or pause inline logs -Page Up/Down Scroll inline logs -b Show or hide bot PRs -r Refresh -d Switch to the instance dashboard -q Quit; during a pull, cancel it +a Open running instance in browser +c Copy instance URL to clipboard +r Refresh data +1 / 2 / d Switch screen (development / instances) +? Help overlay +q Quit (cancels background tasks) ``` -Pull progress, layer completion, local build provenance, selected PR details, CI state, image availability, inline logs, and the final port remain inside the TUI. -Local PR images are folded into their remote PR row only when the recorded revision exactly matches the current GitHub head SHA. They are marked `◆ local` and preferred by Enter; outdated builds remain separate local rows. Navigation and the `d` dashboard toggle remain available while a local build runs. +Pull progress with live layer bytes, throughput, BuildKit step streaming, local build provenance, CI rollup badges, container health status, and live output streams are non-blocking. When a background pull completes, the sidebar automatically transitions to streaming the running instance's live logs. ## Instance Dashboard ```text / Filter by name, image, state, health, port, or URL -↑/↓ or j/k Select -l Toggle inline logs -f Follow or pause logs +↑/↓ or j/k Select row +Enter or Space Start / stop selected instance +s Stop instance +S Restart instance +x Remove container (keeps data) +m Manage mode for selected instance +t Background tasks overlay +Tab Cycle sidebar tab (logs / build / ci) +l Toggle sidebar visibility +L Pin logs +f Follow / pause logs Page Up/Down Scroll logs -s Stop -R Restart -x Remove with confirmation -o Open in browser -c Copy URL +o Open running instance in browser +c Copy URL to clipboard +R Rebuild from recorded provenance r Refresh -d Switch to the development browser +1 / 2 / d Switch screen +? Help overlay q Quit ``` diff --git a/tools/homarr-dev/internal/cli/build.go b/tools/homarr-dev/internal/cli/build.go new file mode 100644 index 000000000..a5254f2d3 --- /dev/null +++ b/tools/homarr-dev/internal/cli/build.go @@ -0,0 +1,74 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker" + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/run" +) + +func (a *App) buildCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "build [name]", + Short: "Build a local Homarr image from this checkout or a pull request", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + tag := "" + if len(args) > 0 { + tag = args[0] + } + if a.buildPR < 0 { + return fmt.Errorf("PR number must be positive") + } + if a.buildPR > 0 { + if tag == "" { + tag = fmt.Sprintf("pr-%d", a.buildPR) + } + return run.BuildPRImage(a.buildPR, tag) + } + if tag == "" { + return fmt.Errorf("image name is required when --pr is not set") + } + return run.BuildCurrentImage(tag, ".") + }, + } + cmd.Flags().IntVarP(&a.buildPR, "pr", "p", 0, "Build a pull request in a temporary checkout") + return cmd +} + +func (a *App) rebuildCommand() *cobra.Command { + return &cobra.Command{ + Use: "rebuild ", + Short: "Rebuild a local image from its recorded checkout or pull request", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + image, err := findLocalImage(cmd, args[0]) + if err != nil { + return err + } + return run.RebuildImage(image) + }, + } +} + +// findLocalImage resolves a tag or full reference to a local image, so both +// `homarr rebuild dev` and `homarr rebuild homarr:dev` work. +func findLocalImage(cmd *cobra.Command, name string) (docker.Image, error) { + images, err := docker.ListLocalImages(cmd.Context()) + if err != nil { + return docker.Image{}, err + } + tag := name + if strings.Contains(name, ":") { + _, tag = docker.SplitImageReference(name) + } + for _, image := range images { + if image.Tag == tag { + return image, nil + } + } + return docker.Image{}, fmt.Errorf("local image homarr:%s was not found", tag) +} diff --git a/tools/homarr-dev/internal/cli/cli.go b/tools/homarr-dev/internal/cli/cli.go new file mode 100644 index 000000000..7866da327 --- /dev/null +++ b/tools/homarr-dev/internal/cli/cli.go @@ -0,0 +1,197 @@ +// Package cli wires the command line surface. The interactive application +// lives in internal/tui; everything here is the scriptable path that does not +// need a terminal. +package cli + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/run" + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/tui" +) + +// App holds command state. The three function fields are seams: they let the +// command wiring be tested without opening a terminal, and they are the only +// mutable state in the package — the previous version reached for package-level +// variables, which meant tests had to mutate globals to observe dispatch. +type App struct { + demo bool + pr int + env []string + detach bool + buildPR int + + RunBrowser func() error + RunInstances func() error + Launch func(cmd *cobra.Command, args []string) error +} + +func NewApp() *App { + app := &App{RunBrowser: tui.RunDev, RunInstances: tui.RunDashboard} + app.Launch = app.launch + return app +} + +// Execute runs the CLI. +func Execute() error { + args := os.Args[1:] + if len(args) > 0 && args[0] == "--" { + args = args[1:] + } + root := NewApp().Root() + root.SetArgs(args) + return root.Execute() +} + +func (a *App) Root() *cobra.Command { + root := &cobra.Command{ + Use: "homarr", + Short: "Launch and manage Homarr Docker instances", + SilenceUsage: true, + Long: `Launch and manage Homarr Docker containers. + +Start a local image, a pull-request build from ghcr.io, browse open pull +requests, or manage running instances — all from one interactive terminal +application where builds and pulls run in the background. + +Examples: + homarr # interactive browser + homarr --pr 6390 # launch a pull-request image directly + homarr --pr 6390 -e FOO=bar # with environment overrides + homarr run dev # launch local homarr:dev + homarr run --detach dev # start in the background + homarr dash # open on the instances screen + homarr build feature # build this checkout as homarr:feature + homarr build --pr 6390 # build a pull request locally + homarr images # local images with provenance + homarr data # instance data volumes + homarr prune # remove stopped instances + homarr doctor # check Docker and optional integrations`, + Args: cobra.NoArgs, + RunE: a.runRoot, + } + + a.addRunFlags(root) + root.AddCommand( + a.runCommand(), + a.devCommand(), + a.dashCommand(), + a.buildCommand(), + a.rebuildCommand(), + a.listCommand(), + a.imagesCommand(), + a.dataCommand(), + a.pruneCommand(), + a.logsCommand(), + a.stopCommand(), + a.restartCommand(), + a.removeCommand(), + a.openCommand(), + a.shellCommand(), + a.ciCommand(), + a.doctorCommand(), + ) + return root +} + +func (a *App) addRunFlags(cmd *cobra.Command) { + cmd.Flags().BoolVarP(&a.demo, "demo", "m", false, "Demo mode (mock integrations)") + cmd.Flags().IntVarP(&a.pr, "pr", "p", 0, "Launch a pull-request image from ghcr.io") + cmd.Flags().StringArrayVarP(&a.env, "env", "e", nil, "Extra env vars (repeatable, KEY=VALUE)") + cmd.Flags().BoolVar(&a.detach, "detach", false, "Start in the background") +} + +// runRoot opens the browser unless a launch flag was given, in which case the +// bare `homarr` invocation is shorthand for `homarr run`. +func (a *App) runRoot(cmd *cobra.Command, args []string) error { + for _, name := range []string{"pr", "env", "demo", "detach"} { + if cmd.Flags().Changed(name) { + return a.Launch(cmd, args) + } + } + return a.RunBrowser() +} + +func (a *App) runCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "run [tag]", + Short: "Launch a local image or a pull-request image", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { return a.Launch(cmd, args) }, + } + a.addRunFlags(cmd) + return cmd +} + +func (a *App) devCommand() *cobra.Command { + return &cobra.Command{ + Use: "dev", + Short: "Browse pull requests and local development images", + Args: cobra.NoArgs, + RunE: func(*cobra.Command, []string) error { return a.RunBrowser() }, + } +} + +func (a *App) dashCommand() *cobra.Command { + return &cobra.Command{ + Use: "dash", + Aliases: []string{"instances"}, + Short: "Open the interactive application on the instances screen", + Args: cobra.NoArgs, + RunE: func(*cobra.Command, []string) error { return a.RunInstances() }, + } +} + +func (a *App) launch(cmd *cobra.Command, args []string) error { + tag := "" + if len(args) > 0 { + tag = args[0] + } + if a.pr < 0 { + return fmt.Errorf("PR number must be positive") + } + if a.pr > 0 && tag != "" { + return fmt.Errorf("tag and --pr cannot be used together") + } + if a.pr == 0 && tag == "" { + 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: a.pr, + Tag: tag, + Demo: a.demo, + Env: a.env, + FetchTitle: a.pr > 0, + }) + if err != nil { + return err + } + + fmt.Printf("Starting Homarr (%s)\n", plan.Label) + fmt.Printf(" Image : %s\n", plan.Image) + fmt.Printf(" Container : %s\n", plan.Name) + fmt.Printf(" Volume : %s\n", plan.Volume) + fmt.Printf(" Port : %d:7575\n", plan.HostPort) + fmt.Printf(" URL : http://localhost:%d\n", plan.HostPort) + if plan.PRNumber > 0 { + fmt.Printf(" PR : https://github.com/homarr-labs/homarr/pull/%d\n", plan.PRNumber) + } + fmt.Println() + + if a.detach { + if err := run.StartDetached(plan); err != nil { + return err + } + fmt.Printf("Started in background: http://localhost:%d\n", plan.HostPort) + return nil + } + return run.StartForeground(plan) +} diff --git a/tools/homarr-dev/internal/cli/cli_test.go b/tools/homarr-dev/internal/cli/cli_test.go new file mode 100644 index 000000000..e52d34464 --- /dev/null +++ b/tools/homarr-dev/internal/cli/cli_test.go @@ -0,0 +1,107 @@ +package cli + +import ( + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestRootCommandOpensBrowser(t *testing.T) { + want := errors.New("browser started") + app := NewApp() + app.RunBrowser = func() error { return want } + + if err := app.runRoot(&cobra.Command{}, nil); !errors.Is(err, want) { + t.Fatalf("root command error = %v, want %v", err, want) + } +} + +func TestRootCommandLaunchesWhenRunFlagIsProvided(t *testing.T) { + want := errors.New("PR launched") + app := NewApp() + app.Launch = func(*cobra.Command, []string) error { return want } + + cmd := &cobra.Command{} + cmd.Flags().Bool("pr", false, "") + if err := cmd.Flags().Set("pr", "true"); err != nil { + t.Fatal(err) + } + + if err := app.runRoot(cmd, nil); !errors.Is(err, want) { + t.Fatalf("root command error = %v, want %v", err, want) + } +} + +func TestLaunchRejectsTagWithPR(t *testing.T) { + app := NewApp() + // Registering the flags binds them to app.pr and resets it, so the command + // has to exist before the test sets the value it wants to assert on. + cmd := app.runCommand() + app.pr = 42 + + err := app.launch(cmd, []string{"feature"}) + if err == nil || !strings.Contains(err.Error(), "cannot be used together") { + t.Fatalf("error = %v", err) + } +} + +func TestLaunchRejectsNegativePR(t *testing.T) { + app := NewApp() + cmd := app.runCommand() + app.pr = -1 + + err := app.launch(cmd, nil) + if err == nil || !strings.Contains(err.Error(), "must be positive") { + t.Fatalf("error = %v", err) + } +} + +func TestLaunchRejectsMissingImage(t *testing.T) { + app := NewApp() + + err := app.launch(app.runCommand(), nil) + if err == nil || !strings.Contains(err.Error(), "provide a tag or --pr") { + t.Fatalf("error = %v", err) + } +} + +// The root command doubles as `run`, so both must accept the same launch flags. +func TestRootAndRunShareLaunchFlags(t *testing.T) { + app := NewApp() + root := app.Root() + run, _, err := root.Find([]string{"run"}) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"pr", "demo", "env", "detach"} { + if root.Flags().Lookup(name) == nil { + t.Fatalf("root command is missing the %q flag", name) + } + if run.Flags().Lookup(name) == nil { + t.Fatalf("run command is missing the %q flag", name) + } + } +} + +func TestCICommandRegisteredAndValidated(t *testing.T) { + app := NewApp() + root := app.Root() + ciCmd, _, err := root.Find([]string{"ci"}) + if err != nil { + t.Fatalf("failed to find 'ci' command: %v", err) + } + if ciCmd.Name() != "ci" { + t.Fatalf("expected command name 'ci', got %q", ciCmd.Name()) + } + if ciCmd.Flags().Lookup("watch") == nil { + t.Fatalf("expected --watch flag on 'ci' command") + } + + // Test invalid PR argument + err = ciCmd.RunE(ciCmd, []string{"invalid-num"}) + if err == nil || !strings.Contains(err.Error(), "invalid PR number") { + t.Fatalf("expected invalid PR number error, got: %v", err) + } +} diff --git a/tools/homarr-dev/internal/cli/doctor.go b/tools/homarr-dev/internal/cli/doctor.go new file mode 100644 index 000000000..97a3b2175 --- /dev/null +++ b/tools/homarr-dev/internal/cli/doctor.go @@ -0,0 +1,90 @@ +package cli + +import ( + "context" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker" + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/registry" + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui" +) + +func (a *App) doctorCommand() *cobra.Command { + return &cobra.Command{ + Use: "doctor", + Short: "Check Docker, the Engine API, GHCR and optional integrations", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, cancel := context.WithTimeout(cmd.Context(), 20*time.Second) + defer cancel() + + failed := false + if output, err := exec.CommandContext(ctx, "docker", "info", "--format", "{{.ServerVersion}}").CombinedOutput(); err != nil { + failed = true + fail("Docker daemon", strings.TrimSpace(string(output))) + } else { + pass("Docker daemon", strings.TrimSpace(string(output))) + } + + // The Engine API is what gives pulls byte-level progress. Losing it + // is not fatal — the CLI falls back to parsing `docker pull` — so + // this reports a downgrade rather than a failure. + host := docker.EngineHost() + if docker.EngineAvailable(ctx) { + pass("Engine API", host) + } else { + optional("Engine API unreachable at "+host, "pull progress falls back to coarse layer states") + } + + if tags, err := registry.PublishedTags(ctx, true); err != nil { + optional("GHCR tag listing unavailable", err.Error()) + } else { + pass("GHCR test images", fmt.Sprintf("%d published tags", len(tags))) + } + + if _, err := exec.LookPath("gh"); err != nil { + optional("GitHub CLI not found", "required for pull-request features") + } else if output, err := exec.CommandContext(ctx, "gh", "auth", "status").CombinedOutput(); err != nil { + optional("GitHub CLI is not authenticated", strings.TrimSpace(string(output))) + } else { + pass("GitHub CLI authentication", "") + } + + if _, err := exec.LookPath("homarr"); err != nil { + optional("homarr is not on PATH", "use `pnpm dev:cli -- ...` or run `pnpm dev:cli:install`") + } else { + pass("homarr is on PATH", "") + } + + if failed { + return fmt.Errorf("one or more checks failed") + } + return nil + }, + } +} + +func pass(label, detail string) { + if detail == "" { + fmt.Printf("%s %s\n", ui.OK.Render(ui.IconPass), label) + return + } + fmt.Printf("%s %s: %s\n", ui.OK.Render(ui.IconPass), label, detail) +} + +func fail(label, detail string) { + fmt.Printf("%s %s: %s\n", ui.Alert.Render(ui.IconFail), label, detail) +} + +func optional(label, detail string) { + if detail == "" { + fmt.Printf("%s %s (optional)\n", ui.Dim.Render(ui.IconNone), label) + return + } + fmt.Printf("%s %s (optional; %s)\n", ui.Dim.Render(ui.IconNone), label, detail) +} diff --git a/tools/homarr-dev/internal/cli/manage.go b/tools/homarr-dev/internal/cli/manage.go new file mode 100644 index 000000000..596840bd9 --- /dev/null +++ b/tools/homarr-dev/internal/cli/manage.go @@ -0,0 +1,329 @@ +package cli + +import ( + "fmt" + "os" + "strconv" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "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/platform" +) + +func (a *App) listCommand() *cobra.Command { + return &cobra.Command{ + Use: "list", + Aliases: []string{"ls", "ps"}, + Short: "List Homarr instances without opening the application", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + containers, err := docker.List() + if err != nil { + return err + } + if len(containers) == 0 { + fmt.Println("No Homarr instances found.") + return nil + } + writer := newTable("NAME", "STATE", "PORT", "IMAGE", "STATUS") + for _, container := range containers { + row(writer, container.Name, container.State, container.HostPort(), container.Image, container.Status) + } + return writer.Flush() + }, + } +} + +func (a *App) imagesCommand() *cobra.Command { + images := &cobra.Command{ + Use: "images", + Aliases: []string{"image"}, + Short: "List local Homarr images and their build provenance", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + found, err := docker.ListLocalImages(cmd.Context()) + if err != nil { + return err + } + if len(found) == 0 { + fmt.Println("No local homarr images found. Build one with `homarr build `.") + return nil + } + writer := newTable("IMAGE", "SIZE", "CREATED", "PR", "REVISION", "SOURCE") + for _, image := range found { + pr := "" + if image.PRNumber > 0 { + pr = fmt.Sprintf("#%d", image.PRNumber) + } + revision := image.Revision + if len(revision) > 12 { + revision = revision[:12] + } + source := image.Source + if source == "" { + source = "unknown" + } + row(writer, image.Reference(), image.Size, image.Created, pr, revision, source) + } + return writer.Flush() + }, + } + images.AddCommand(&cobra.Command{ + Use: "rm ...", + Aliases: []string{"remove", "delete"}, + Short: "Delete local Homarr images", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + for _, name := range args { + reference := name + if !strings.Contains(reference, ":") { + reference = "homarr:" + reference + } + if err := docker.RemoveImage(cmd.Context(), reference); err != nil { + return err + } + fmt.Println("removed " + reference) + } + return nil + }, + }) + return images +} + +func (a *App) dataCommand() *cobra.Command { + data := &cobra.Command{ + Use: "data", + Aliases: []string{"volumes", "volume"}, + Short: "List Homarr instance data volumes", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + volumes, err := docker.ListVolumes(cmd.Context(), true) + if err != nil { + return err + } + if len(volumes) == 0 { + fmt.Println("No Homarr data volumes found.") + return nil + } + writer := newTable("VOLUME", "SIZE", "IN USE", "BELONGS TO") + for _, volume := range volumes { + owner := volume.Tag() + if number := volume.PRNumber(); number > 0 { + owner = fmt.Sprintf("PR #%d", number) + } + inUse := "no" + if volume.InUse { + inUse = "yes" + } + row(writer, volume.Name, volume.Size, inUse, owner) + } + return writer.Flush() + }, + } + data.AddCommand(&cobra.Command{ + Use: "rm ...", + Aliases: []string{"remove", "delete"}, + Short: "Delete instance data, removing the owning container first", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + for _, name := range args { + // Docker refuses to delete an attached volume, and the data is + // going away regardless, so the container goes first. + _ = docker.Remove(strings.TrimSuffix(name, "_data")) + if err := docker.RemoveVolume(cmd.Context(), name); err != nil { + return err + } + fmt.Println("removed " + name) + } + return nil + }, + }) + return data +} + +func (a *App) pruneCommand() *cobra.Command { + var force bool + cmd := &cobra.Command{ + Use: "prune", + Short: "Remove stopped Homarr containers, keeping their data", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + containers, err := docker.List() + if err != nil { + return err + } + stopped := make([]docker.Container, 0, len(containers)) + for _, container := range containers { + if !container.Running() { + stopped = append(stopped, container) + } + } + if len(stopped) == 0 { + fmt.Println("No stopped Homarr containers.") + return nil + } + if !force { + fmt.Printf("Would remove %d stopped containers:\n", len(stopped)) + for _, container := range stopped { + fmt.Println(" " + container.Name) + } + fmt.Println("\nRe-run with --force to remove them.") + return nil + } + for _, container := range stopped { + if err := docker.Remove(container.Name); err != nil { + return err + } + fmt.Println("removed " + container.Name) + } + return nil + }, + } + cmd.Flags().BoolVarP(&force, "force", "f", false, "Actually remove the containers") + return cmd +} + +func (a *App) logsCommand() *cobra.Command { + return &cobra.Command{ + Use: "logs ", + Short: "Follow logs of a running instance", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { return docker.FollowLogs(args[0]) }, + } +} + +func (a *App) stopCommand() *cobra.Command { + return &cobra.Command{ + Use: "stop ", + Short: "Stop a running instance", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { return docker.Stop(args[0]) }, + } +} + +func (a *App) restartCommand() *cobra.Command { + return &cobra.Command{ + Use: "restart ", + Short: "Restart an instance", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { return docker.Restart(args[0]) }, + } +} + +func (a *App) removeCommand() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Force-remove an instance, keeping its data", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { return docker.Remove(args[0]) }, + } +} + +func (a *App) openCommand() *cobra.Command { + return &cobra.Command{ + Use: "open ", + Short: "Open a running instance in the browser", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + containers, err := docker.List() + if err != nil { + return err + } + for _, container := range containers { + 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("container %q was not found", args[0]) + }, + } +} + +func (a *App) shellCommand() *cobra.Command { + return &cobra.Command{ + Use: "shell [cmd...]", + Aliases: []string{"sh", "exec"}, + Short: "Open an interactive shell or run a command in a running Homarr container", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + shellArgs := []string{"sh"} + if len(args) > 1 { + shellArgs = args[1:] + } + return docker.ExecInteractive(cmd.Context(), args[0], shellArgs...) + }, + } +} + +func (a *App) ciCommand() *cobra.Command { + var watch bool + cmd := &cobra.Command{ + Use: "ci [pr]", + Aliases: []string{"checks", "check"}, + Short: "View or watch GitHub CI checks for a pull request", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var prNumber int + if len(args) > 0 { + num, err := strconv.Atoi(strings.TrimPrefix(args[0], "#")) + if err != nil || num <= 0 { + return fmt.Errorf("invalid PR number %q", args[0]) + } + prNumber = num + } else { + currentPR, err := gh.CurrentBranchPR(cmd.Context()) + if err != nil || currentPR == nil { + return fmt.Errorf("could not detect PR for current branch: specify a PR number with `homarr ci `") + } + prNumber = currentPR.Number + } + + if watch { + return gh.WatchPRChecks(cmd.Context(), prNumber) + } + + checks, err := gh.GetPRChecks(cmd.Context(), prNumber, true) + if err != nil { + return err + } + if len(checks) == 0 { + fmt.Printf("No CI checks found for PR #%d.\n", prNumber) + return nil + } + + writer := newTable("CHECK", "STATUS", "DURATION", "WORKFLOW", "LINK") + for _, check := range checks { + dur := check.Duration() + if dur == "" { + dur = "—" + } + row(writer, check.Name, check.State, dur, check.Workflow, check.Link) + } + return writer.Flush() + }, + } + cmd.Flags().BoolVarP(&watch, "watch", "w", false, "Watch CI checks until completion") + return cmd +} + +func newTable(headers ...string) *tabwriter.Writer { + writer := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) + row(writer, headers...) + return writer +} + +func row(writer *tabwriter.Writer, cells ...string) { + fmt.Fprintln(writer, strings.Join(cells, "\t")) +} diff --git a/tools/homarr-dev/internal/docker/api.go b/tools/homarr-dev/internal/docker/api.go new file mode 100644 index 000000000..b16a79491 --- /dev/null +++ b/tools/homarr-dev/internal/docker/api.go @@ -0,0 +1,187 @@ +package docker + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "strings" + "sync" + "time" +) + +// engineAPIVersion is the oldest Engine API version that supports every +// endpoint this CLI relies on. The daemon negotiates downwards on its own. +const engineAPIVersion = "v1.41" + +// PullMessage is one record of the JSON progress stream returned by the Docker +// Engine when an image is pulled. Unlike the plain-text output of `docker +// pull`, this stream carries per-layer byte counters even when stdout is not a +// terminal, which is what makes a real progress animation possible. +type PullMessage struct { + ID string `json:"id"` + Status string `json:"status"` + Detail struct { + Current int64 `json:"current"` + Total int64 `json:"total"` + } `json:"progressDetail"` + Error string `json:"error"` +} + +type engineEndpoint struct { + client *http.Client + base string + err error +} + +var engineOnce struct { + sync.Once + endpoint engineEndpoint +} + +// EngineHost reports the Docker endpoint this machine talks to, honouring +// DOCKER_HOST and the active `docker context` (OrbStack, Colima, and Docker +// Desktop all install their own socket). +func EngineHost() string { + if host := strings.TrimSpace(os.Getenv("DOCKER_HOST")); host != "" { + return host + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}").Output() + if err == nil { + if host := strings.TrimSpace(string(out)); host != "" { + return host + } + } + return "unix:///var/run/docker.sock" +} + +func engine() (*http.Client, string, error) { + engineOnce.Do(func() { engineOnce.endpoint = dialEngine(EngineHost()) }) + e := engineOnce.endpoint + return e.client, e.base, e.err +} + +func dialEngine(host string) engineEndpoint { + switch { + case strings.HasPrefix(host, "unix://"): + path := strings.TrimPrefix(host, "unix://") + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", path) + }, + } + return engineEndpoint{client: &http.Client{Transport: transport}, base: "http://docker/" + engineAPIVersion} + case strings.HasPrefix(host, "tcp://"), strings.HasPrefix(host, "http://"): + authority := host + for _, scheme := range []string{"tcp://", "http://"} { + authority = strings.TrimPrefix(authority, scheme) + } + return engineEndpoint{client: &http.Client{}, base: "http://" + authority + "/" + engineAPIVersion} + default: + return engineEndpoint{err: fmt.Errorf("unsupported Docker endpoint %q", host)} + } +} + +// SplitImageReference separates a reference into its repository and tag, +// tolerating registries that carry an explicit port. +func SplitImageReference(reference string) (repository, tag string) { + slash := strings.LastIndex(reference, "/") + colon := strings.LastIndex(reference, ":") + if colon > slash { + return reference[:colon], reference[colon+1:] + } + return reference, "latest" +} + +// PullStream starts an image pull against the Engine API and returns its +// progress records. The channel closes when the pull finishes; a message +// carrying Error describes a failure. Cancelling ctx aborts the pull. +func PullStream(ctx context.Context, image, platform string) (<-chan PullMessage, error) { + client, base, err := engine() + if err != nil { + return nil, err + } + repository, tag := SplitImageReference(image) + query := url.Values{"fromImage": {repository}, "tag": {tag}} + if platform != "" { + query.Set("platform", platform) + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/images/create?"+query.Encode(), nil) + if err != nil { + return nil, err + } + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("contact Docker engine: %w", err) + } + if response.StatusCode < 200 || response.StatusCode > 299 { + body, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + _ = response.Body.Close() + return nil, fmt.Errorf("pull %s: %s", image, engineErrorMessage(response.Status, body)) + } + + messages := make(chan PullMessage, 64) + go func() { + defer close(messages) + defer response.Body.Close() + decoder := json.NewDecoder(response.Body) + for { + var message PullMessage + if err := decoder.Decode(&message); err != nil { + if err != io.EOF && ctx.Err() == nil { + select { + case messages <- PullMessage{Error: err.Error()}: + case <-ctx.Done(): + } + } + return + } + select { + case messages <- message: + case <-ctx.Done(): + return + } + } + }() + return messages, nil +} + +func engineErrorMessage(status string, body []byte) string { + var payload struct { + Message string `json:"message"` + } + if err := json.Unmarshal(body, &payload); err == nil && payload.Message != "" { + return payload.Message + } + if trimmed := strings.TrimSpace(string(body)); trimmed != "" { + return trimmed + } + return status +} + +// EngineAvailable reports whether the Engine API answers, so callers can fall +// back to the `docker` binary instead of failing outright. +func EngineAvailable(ctx context.Context) bool { + client, base, err := engine() + if err != nil { + return false + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/_ping", nil) + if err != nil { + return false + } + response, err := client.Do(request) + if err != nil { + return false + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, response.Body) + return response.StatusCode >= 200 && response.StatusCode < 300 +} diff --git a/tools/homarr-dev/internal/docker/docker.go b/tools/homarr-dev/internal/docker/docker.go index da5f2fa92..0a65edfca 100644 --- a/tools/homarr-dev/internal/docker/docker.go +++ b/tools/homarr-dev/internal/docker/docker.go @@ -378,27 +378,29 @@ func IsPortConflict(err error) bool { strings.Contains(message, "failed programming external connectivity") } -func Stop(name string) error { - cmd := exec.Command("docker", "stop", name) - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) - } - return nil +func Stop(name string) error { return StopContext(context.Background(), name) } + +func Restart(name string) error { return RestartContext(context.Background(), name) } + +func Remove(name string) error { return RemoveContext(context.Background(), name) } + +// The context-aware variants let a background job be cancelled while the daemon +// is still thinking, which matters when Docker is wedged and the user wants the +// interface back. +func StopContext(ctx context.Context, name string) error { + return runContainerCommand(ctx, "stop", name) } -func Restart(name string) error { - cmd := exec.Command("docker", "restart", name) - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) - } - return nil +func RestartContext(ctx context.Context, name string) error { + return runContainerCommand(ctx, "restart", name) } -func Remove(name string) error { - cmd := exec.Command("docker", "rm", "-f", name) - out, err := cmd.CombinedOutput() +func RemoveContext(ctx context.Context, name string) error { + return runContainerCommand(ctx, "rm", "-f", name) +} + +func runContainerCommand(ctx context.Context, args ...string) error { + out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput() if err != nil { return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) } @@ -425,3 +427,15 @@ func FollowLogs(name string) error { cmd.Stderr = os.Stderr return cmd.Run() } + +func ExecInteractive(ctx context.Context, name string, command ...string) error { + if len(command) == 0 { + command = []string{"sh"} + } + args := append([]string{"exec", "-it", name}, command...) + cmd := exec.CommandContext(ctx, "docker", args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/tools/homarr-dev/internal/docker/images.go b/tools/homarr-dev/internal/docker/images.go index cb4f16e66..442a550a2 100644 --- a/tools/homarr-dev/internal/docker/images.go +++ b/tools/homarr-dev/internal/docker/images.go @@ -27,6 +27,8 @@ type Image struct { Source string Revision string PRNumber int + Size string + Created string } func (image Image) Reference() string { @@ -34,9 +36,11 @@ func (image Image) Reference() string { } type imageListRow struct { - ID string - Repository string - Tag string + ID string + Repository string + Tag string + Size string + CreatedSince string } func parseImageList(output []byte) ([]Image, error) { @@ -50,7 +54,7 @@ func parseImageList(output []byte) ([]Image, error) { return nil, fmt.Errorf("parse local Docker image: %w", err) } if row.Repository == "homarr" && row.Tag != "" { - images = append(images, Image{ID: row.ID, Tag: row.Tag}) + images = append(images, Image{ID: row.ID, Tag: row.Tag, Size: row.Size, Created: row.CreatedSince}) } } return images, nil @@ -86,22 +90,39 @@ func inspectImageLabels(ctx context.Context, images []Image) ([]map[string]strin args = append(args, image.Reference()) } out, err := exec.CommandContext(ctx, "docker", args...).Output() - if err != nil { - return nil, fmt.Errorf("inspect local Homarr images: %w", err) - } - lines := strings.Split(strings.TrimSpace(string(out)), "\n") - if len(lines) != len(images) { - return nil, fmt.Errorf("inspect returned %d images, expected %d", len(lines), len(images)) + if err == nil { + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + if len(lines) == len(images) { + labelsByImage := make([]map[string]string, len(images)) + valid := true + for index, line := range lines { + labelsByImage[index] = make(map[string]string) + if line == "null" || line == "" { + continue + } + if err := json.Unmarshal([]byte(line), &labelsByImage[index]); err != nil { + valid = false + break + } + } + if valid { + return labelsByImage, nil + } + } } + labelsByImage := make([]map[string]string, len(images)) - for index, line := range lines { + for index, image := range images { labelsByImage[index] = make(map[string]string) - if line == "null" { + singleOut, singleErr := exec.CommandContext(ctx, "docker", "image", "inspect", "--format", "{{json .Config.Labels}}", image.Reference()).Output() + if singleErr != nil { continue } - if err := json.Unmarshal([]byte(line), &labelsByImage[index]); err != nil { - return nil, fmt.Errorf("parse labels for %s: %w", images[index].Reference(), err) + trimmed := strings.TrimSpace(string(singleOut)) + if trimmed == "" || trimmed == "null" { + continue } + _ = json.Unmarshal([]byte(trimmed), &labelsByImage[index]) } return labelsByImage, nil } diff --git a/tools/homarr-dev/internal/docker/progress.go b/tools/homarr-dev/internal/docker/progress.go new file mode 100644 index 000000000..131e877bb --- /dev/null +++ b/tools/homarr-dev/internal/docker/progress.go @@ -0,0 +1,410 @@ +package docker + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/x/ansi" +) + +// Layer is the download and extraction state of a single image layer. +type Layer struct { + ID string + Status string + Current int64 + Total int64 +} + +// Fraction reports how far this layer is towards being usable, treating +// download and extraction as two halves of the work. Docker restarts the byte +// counters between the two phases, so they are weighted rather than summed. +func (l Layer) Fraction() float64 { + switch normalizeLayerStatus(l.Status) { + case "already exists", "pull complete": + return 1 + case "downloading": + return 0.5 * ratio(l.Current, l.Total) + case "verifying checksum", "download complete": + return 0.5 + case "extracting": + return 0.5 + 0.5*ratio(l.Current, l.Total) + default: + return 0 + } +} + +func (l Layer) Done() bool { + status := normalizeLayerStatus(l.Status) + return status == "pull complete" || status == "already exists" +} + +func ratio(current, total int64) float64 { + if total <= 0 { + return 0 + } + if current >= total { + return 1 + } + return float64(current) / float64(total) +} + +func normalizeLayerStatus(status string) string { + return strings.ToLower(strings.TrimSpace(status)) +} + +// PullTracker folds a pull progress stream into stable, ordered per-layer +// state so the UI can redraw it every frame without flicker. +type PullTracker struct { + order []string + layers map[string]*Layer + status string + started time.Time + sampleAt time.Time + sampled int64 + rate float64 +} + +func NewPullTracker() *PullTracker { + now := time.Now() + return &PullTracker{layers: make(map[string]*Layer), started: now, sampleAt: now} +} + +// Apply folds one Engine API record into the tracker. +func (t *PullTracker) Apply(message PullMessage) { + if message.ID == "" { + if message.Status != "" { + t.status = message.Status + } + return + } + // Digest and manifest records reuse the ID field but are not layers. + if strings.HasPrefix(message.ID, "sha256:") || len(message.ID) > 24 { + t.status = message.Status + return + } + layer, exists := t.layers[message.ID] + if !exists { + layer = &Layer{ID: message.ID} + t.layers[message.ID] = layer + t.order = append(t.order, message.ID) + } + if message.Status != "" { + layer.Status = message.Status + } + if message.Detail.Total > 0 { + layer.Total = message.Detail.Total + } + if message.Detail.Current > 0 || message.Detail.Total > 0 { + layer.Current = message.Detail.Current + } + if layer.Done() { + layer.Current = layer.Total + } + t.sample() +} + +var plainPullPattern = regexp.MustCompile(`^([0-9a-zA-Z_-]{4,64}):\s+(.+)$`) + +// ApplyLine folds a line of plain `docker pull` output into the tracker. It is +// the fallback used when the Engine API is unreachable; the resulting progress +// is coarser because the CLI omits byte counters when stdout is not a TTY. +func (t *PullTracker) ApplyLine(line string) { + line = strings.TrimSpace(ansi.Strip(line)) + if line == "" { + return + } + match := plainPullPattern.FindStringSubmatch(line) + if match == nil { + t.status = line + return + } + status, current, total := parsePlainProgress(match[2]) + t.Apply(PullMessage{ + ID: match[1], + Status: status, + Detail: struct { + Current int64 `json:"current"` + Total int64 `json:"total"` + }{Current: current, Total: total}, + }) +} + +var plainSizePattern = regexp.MustCompile(`([0-9.]+\s*[A-Za-z]*B)\s*/\s*([0-9.]+\s*[A-Za-z]*B)`) + +func parsePlainProgress(rest string) (status string, current, total int64) { + status = rest + if bracket := strings.Index(rest, "["); bracket >= 0 { + status = strings.TrimSpace(rest[:bracket]) + } + if match := plainSizePattern.FindStringSubmatch(rest); match != nil { + current = ParseSize(match[1]) + total = ParseSize(match[2]) + } + return status, current, total +} + +var sizeUnits = map[string]int64{ + "b": 1, + "kb": 1000, "mb": 1000 * 1000, "gb": 1000 * 1000 * 1000, "tb": 1000 * 1000 * 1000 * 1000, + "kib": 1024, "mib": 1024 * 1024, "gib": 1024 * 1024 * 1024, "tib": 1024 * 1024 * 1024 * 1024, +} + +// ParseSize converts a human readable Docker size such as "12.3MB" into bytes. +func ParseSize(value string) int64 { + value = strings.ToLower(strings.TrimSpace(value)) + index := 0 + for index < len(value) && (value[index] >= '0' && value[index] <= '9' || value[index] == '.') { + index++ + } + number, err := strconv.ParseFloat(strings.TrimSpace(value[:index]), 64) + if err != nil { + return 0 + } + unit := strings.TrimSpace(value[index:]) + if unit == "" { + return int64(number) + } + multiplier, known := sizeUnits[unit] + if !known { + return int64(number) + } + return int64(number * float64(multiplier)) +} + +// FormatSize renders a byte count the way the Docker CLI does. +func FormatSize(bytes int64) string { + const unit = 1000 + if bytes < unit { + return fmt.Sprintf("%dB", bytes) + } + value := float64(bytes) + for _, suffix := range []string{"kB", "MB", "GB", "TB"} { + value /= unit + if value < unit { + return fmt.Sprintf("%.1f%s", value, suffix) + } + } + return fmt.Sprintf("%.1fPB", value/unit) +} + +func (t *PullTracker) sample() { + now := time.Now() + elapsed := now.Sub(t.sampleAt) + if elapsed < 500*time.Millisecond { + return + } + current, _ := t.Bytes() + if delta := current - t.sampled; delta > 0 { + t.rate = float64(delta) / elapsed.Seconds() + } + t.sampled = current + t.sampleAt = now +} + +// Layers returns the layers in the order Docker announced them. +func (t *PullTracker) Layers() []Layer { + layers := make([]Layer, 0, len(t.order)) + for _, id := range t.order { + layers = append(layers, *t.layers[id]) + } + return layers +} + +// Bytes reports downloaded and total bytes across every layer that announced a +// size. +func (t *PullTracker) Bytes() (current, total int64) { + for _, layer := range t.layers { + if layer.Total <= 0 { + continue + } + total += layer.Total + if layer.Done() { + current += layer.Total + continue + } + current += min(layer.Current, layer.Total) + } + return current, total +} + +// Complete reports how many layers are fully pulled out of how many are known. +func (t *PullTracker) Complete() (complete, total int) { + for _, layer := range t.layers { + total++ + if layer.Done() { + complete++ + } + } + return complete, total +} + +// Percent is the overall progress of the pull in the range [0,1]. +func (t *PullTracker) Percent() float64 { + if len(t.order) == 0 { + return 0 + } + sum := 0.0 + for _, id := range t.order { + sum += t.layers[id].Fraction() + } + return sum / float64(len(t.order)) +} + +// Rate is the recent download throughput in bytes per second. +func (t *PullTracker) Rate() float64 { return t.rate } + +// Status is the most recent message that was not tied to a single layer. +func (t *PullTracker) Status() string { return t.status } + +// Elapsed reports how long the pull has been running. +func (t *PullTracker) Elapsed() time.Duration { return time.Since(t.started) } + +// BuildStep is one BuildKit vertex, such as a Dockerfile instruction. +type BuildStep struct { + ID string + Label string + Status string + Output []string + Done bool + Cached bool + Failed bool + Canceled bool + Elapsed string +} + +// BuildTracker folds `docker build --progress=plain` output into ordered steps. +type BuildTracker struct { + order []string + steps map[string]*BuildStep + total int + started time.Time + last string +} + +func NewBuildTracker() *BuildTracker { + return &BuildTracker{steps: make(map[string]*BuildStep), started: time.Now()} +} + +var ( + buildLinePattern = regexp.MustCompile(`^#(\d+)\s*(.*)$`) + buildStagePattern = regexp.MustCompile(`^\[[^\]]*?(\d+)/(\d+)\]`) + buildTimePattern = regexp.MustCompile(`^\d+\.\d+\s`) +) + +// ApplyLine folds one line of BuildKit plain output into the tracker. +func (t *BuildTracker) ApplyLine(line string) { + line = strings.TrimRight(line, "\r\n") + trimmed := strings.TrimSpace(ansi.Strip(line)) + if trimmed == "" { + return + } + match := buildLinePattern.FindStringSubmatch(trimmed) + if match == nil { + t.last = trimmed + return + } + id, rest := match[1], strings.TrimSpace(match[2]) + // BuildKit reserves #0 for its own preamble ("building with ... driver"). + // It never completes, so counting it would leave every finished build one + // step short of done. + if id == "0" { + t.last = rest + return + } + step, exists := t.steps[id] + if !exists { + step = &BuildStep{ID: id} + t.steps[id] = step + t.order = append(t.order, id) + } + + switch { + case strings.HasPrefix(rest, "DONE"): + step.Done = true + step.Status = "done" + step.Elapsed = strings.TrimSpace(strings.TrimPrefix(rest, "DONE")) + case rest == "CACHED": + step.Done, step.Cached, step.Status = true, true, "cached" + case strings.HasPrefix(rest, "ERROR"): + step.Failed, step.Done, step.Status = true, true, "failed" + step.Output = appendStepOutput(step.Output, rest) + t.last = rest + case strings.HasPrefix(rest, "CANCELED"): + step.Done, step.Status, step.Canceled = true, "canceled", true + case buildTimePattern.MatchString(rest): + // `#12 1.234 ` — timestamped output from inside the step. + _, output, _ := strings.Cut(rest, " ") + if output = strings.TrimSpace(output); output != "" { + step.Output = appendStepOutput(step.Output, output) + t.last = output + } + case step.Label == "": + step.Label = rest + if stage := buildStagePattern.FindStringSubmatch(rest); stage != nil { + if total, err := strconv.Atoi(stage[2]); err == nil && total > t.total { + t.total = total + } + } + default: + step.Output = appendStepOutput(step.Output, rest) + } +} + +func appendStepOutput(output []string, line string) []string { + const retained = 200 + output = append(output, line) + if len(output) > retained { + output = output[len(output)-retained:] + } + return output +} + +// Steps returns every announced step in declaration order. +func (t *BuildTracker) Steps() []BuildStep { + steps := make([]BuildStep, 0, len(t.order)) + for _, id := range t.order { + steps = append(steps, *t.steps[id]) + } + return steps +} + +// Active returns the step BuildKit is currently working on, if any. +func (t *BuildTracker) Active() (BuildStep, bool) { + for index := len(t.order) - 1; index >= 0; index-- { + if step := t.steps[t.order[index]]; !step.Done && step.Label != "" { + return *step, true + } + } + return BuildStep{}, false +} + +// Complete reports finished steps out of the best known total. The total grows +// as BuildKit announces stages, so it is never allowed to shrink below what has +// already been seen. A step BuildKit reported as canceled or failed is finished +// but not complete — counting it would report a killed build as fully built. +func (t *BuildTracker) Complete() (complete, total int) { + for _, step := range t.steps { + if step.Done && !step.Canceled && !step.Failed { + complete++ + } + } + return complete, max(t.total, len(t.order)) +} + +// Percent is the overall build progress in the range [0,1]. +func (t *BuildTracker) Percent() float64 { + complete, total := t.Complete() + if total == 0 { + return 0 + } + return ratio(int64(complete), int64(total)) +} + +// LastMessage is the most recent human readable line, used for error context. +func (t *BuildTracker) LastMessage() string { return t.last } + +// Elapsed reports how long the build has been running. +func (t *BuildTracker) Elapsed() time.Duration { return time.Since(t.started) } diff --git a/tools/homarr-dev/internal/docker/progress_test.go b/tools/homarr-dev/internal/docker/progress_test.go new file mode 100644 index 000000000..5545cf474 --- /dev/null +++ b/tools/homarr-dev/internal/docker/progress_test.go @@ -0,0 +1,163 @@ +package docker + +import ( + "testing" +) + +func TestLayerFraction(t *testing.T) { + cases := []struct { + status string + current int64 + total int64 + want float64 + }{ + {"pull complete", 0, 0, 1.0}, + {"already exists", 0, 0, 1.0}, + {"download complete", 100, 100, 0.5}, + {"verifying checksum", 100, 100, 0.5}, + {"downloading", 50, 100, 0.25}, + {"extracting", 50, 100, 0.75}, + {"waiting", 0, 100, 0.0}, + } + + for _, c := range cases { + layer := Layer{Status: c.status, Current: c.current, Total: c.total} + if got := layer.Fraction(); got != c.want { + t.Errorf("Layer{%q, %d, %d}.Fraction() = %v, want %v", c.status, c.current, c.total, got, c.want) + } + } +} + +func TestParseSizeAndFormatSize(t *testing.T) { + if got := ParseSize("12.3MB"); got != 12300000 { + t.Errorf("ParseSize(12.3MB) = %d, want 12300000", got) + } + if got := ParseSize("10MiB"); got != 10*1024*1024 { + t.Errorf("ParseSize(10MiB) = %d, want %d", got, 10*1024*1024) + } + if got := ParseSize("500B"); got != 500 { + t.Errorf("ParseSize(500B) = %d, want 500", got) + } + if got := ParseSize("invalid"); got != 0 { + t.Errorf("ParseSize(invalid) = %d, want 0", got) + } + + if got := FormatSize(500); got != "500B" { + t.Errorf("FormatSize(500) = %q, want 500B", got) + } + if got := FormatSize(1500000); got != "1.5MB" { + t.Errorf("FormatSize(1500000) = %q, want 1.5MB", got) + } + if got := FormatSize(2500000000); got != "2.5GB" { + t.Errorf("FormatSize(2500000000) = %q, want 2.5GB", got) + } +} + +func TestPullTrackerApplyAndProgress(t *testing.T) { + tracker := NewPullTracker() + tracker.Apply(PullMessage{ + ID: "layer1", + Status: "Downloading", + Detail: struct { + Current int64 `json:"current"` + Total int64 `json:"total"` + }{Current: 50, Total: 100}, + }) + tracker.Apply(PullMessage{ + ID: "layer2", + Status: "Extracting", + Detail: struct { + Current int64 `json:"current"` + Total int64 `json:"total"` + }{Current: 50, Total: 100}, + }) + + layers := tracker.Layers() + if len(layers) != 2 { + t.Fatalf("expected 2 layers, got %d", len(layers)) + } + if layers[0].ID != "layer1" || layers[1].ID != "layer2" { + t.Errorf("unexpected layers order: %v, %v", layers[0].ID, layers[1].ID) + } + + currentBytes, totalBytes := tracker.Bytes() + if currentBytes != 100 || totalBytes != 200 { + t.Errorf("Bytes() = %d, %d; want 100, 200", currentBytes, totalBytes) + } + + complete, total := tracker.Complete() + if complete != 0 || total != 2 { + t.Errorf("Complete() = %d, %d; want 0, 2", complete, total) + } + + if percent := tracker.Percent(); percent != 0.5 { + t.Errorf("Percent() = %v, want 0.5", percent) + } + + tracker.ApplyLine("c0ffee123456: Pull complete") + if len(tracker.Layers()) != 3 { + t.Fatalf("expected 3 layers after ApplyLine, got %d", len(tracker.Layers())) + } + complete, total = tracker.Complete() + if complete != 1 || total != 3 { + t.Errorf("Complete() = %d, %d; want 1, 3", complete, total) + } +} + +func TestBuildTrackerStepsAndProgress(t *testing.T) { + tracker := NewBuildTracker() + + tracker.ApplyLine("#0 building with \"default\" instance using docker driver") + if len(tracker.Steps()) != 0 { + t.Fatalf("expected step #0 to be skipped, got %d steps", len(tracker.Steps())) + } + + tracker.ApplyLine("#1 [internal] load build definition from Dockerfile") + tracker.ApplyLine("#1 transferring dockerfile: 3.42kB done") + tracker.ApplyLine("#1 DONE 0.0s") + + tracker.ApplyLine("#2 [1/3] FROM docker.io/library/alpine:3.20") + tracker.ApplyLine("#2 CACHED") + + tracker.ApplyLine("#3 [2/3] RUN echo step2") + tracker.ApplyLine("#3 0.123 hello from build") + + steps := tracker.Steps() + if len(steps) != 3 { + t.Fatalf("expected 3 steps, got %d", len(steps)) + } + + if !steps[0].Done { + t.Errorf("step #1 should be marked Done") + } + if !steps[1].Cached || !steps[1].Done { + t.Errorf("step #2 should be marked Cached and Done") + } + if steps[2].Done { + t.Errorf("step #3 should not be Done yet") + } + + active, running := tracker.Active() + if !running || active.ID != "3" { + t.Errorf("Active() = %+v, %v; want ID 3, true", active, running) + } + + complete, total := tracker.Complete() + if complete != 2 || total < 3 { + t.Errorf("Complete() = %d, %d; want 2, >=3", complete, total) + } + + if last := tracker.LastMessage(); last != "hello from build" { + t.Errorf("LastMessage() = %q, want 'hello from build'", last) + } + + tracker.ApplyLine("#3 CANCELED") + steps = tracker.Steps() + if !steps[2].Canceled { + t.Errorf("step #3 should be marked Canceled") + } + complete, _ = tracker.Complete() + if complete != 2 { + t.Errorf("Complete() after cancel = %d, want 2", complete) + } +} diff --git a/tools/homarr-dev/internal/docker/stream.go b/tools/homarr-dev/internal/docker/stream.go new file mode 100644 index 000000000..8ad2284b0 --- /dev/null +++ b/tools/homarr-dev/internal/docker/stream.go @@ -0,0 +1,102 @@ +package docker + +import ( + "bufio" + "context" + "fmt" + "io" + "os/exec" + "strings" +) + +// StreamLine is one line of output from a streamed command. Err is set only on +// the final line, which also has Done set. +type StreamLine struct { + Text string + Err error + Done bool +} + +// splitLines is a bufio.SplitFunc that treats both newline and carriage return +// as terminators so in-place progress updates arrive as discrete lines. +func splitLines(data []byte, atEOF bool) (advance int, token []byte, err error) { + for index, char := range data { + if char == '\n' || char == '\r' { + return index + 1, data[:index], nil + } + } + if atEOF && len(data) > 0 { + return len(data), data, nil + } + return 0, nil, nil +} + +// StreamCommand runs cmd with stdout and stderr merged and delivers its output +// line by line. The channel is closed after a final Done line carrying the exit +// status. Cancelling the command's context terminates the process. +func StreamCommand(cmd *exec.Cmd) (<-chan StreamLine, error) { + reader, writer := io.Pipe() + cmd.Stdout = writer + cmd.Stderr = writer + if err := cmd.Start(); err != nil { + _ = reader.Close() + _ = writer.Close() + return nil, err + } + + // Wait runs on its own goroutine and closes the write end, which is what + // gives the scanner an EOF. Waiting first and closing afterwards deadlocks: + // the scanner would block forever on a pipe this process still holds open. + // Wait only returns once every byte has been copied into the pipe, so no + // output can be lost to the close. + waited := make(chan error, 1) + go func() { + err := cmd.Wait() + _ = writer.Close() + waited <- err + }() + + lines := make(chan StreamLine, 256) + go func() { + defer close(lines) + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + scanner.Split(splitLines) + last := "" + for scanner.Scan() { + text := strings.TrimRight(scanner.Text(), "\r\n") + if strings.TrimSpace(text) == "" { + continue + } + last = text + lines <- StreamLine{Text: text} + } + _ = reader.Close() + err := <-waited + if err != nil && last != "" { + err = fmt.Errorf("%w: %s", err, strings.TrimSpace(last)) + } + lines <- StreamLine{Done: true, Err: err} + }() + return lines, nil +} + +// StreamContainerLogs follows a container's logs, replaying the last tail lines +// first. The stream ends when the container stops or ctx is cancelled. +func StreamContainerLogs(ctx context.Context, name string, tail int) (<-chan StreamLine, error) { + cmd := exec.CommandContext(ctx, "docker", "logs", "--follow", "--tail", fmt.Sprint(tail), name) + cmd.Cancel = func() error { return cmd.Process.Kill() } + return StreamCommand(cmd) +} + +// BuildCommandStreaming returns a build command configured for BuildKit's plain +// progress output, which is the only format that can be parsed line by line. +func BuildCommandStreaming(ctx context.Context, options BuildOptions) (*exec.Cmd, error) { + cmd, err := BuildCommand(ctx, options) + if err != nil { + return nil, err + } + cmd.Args = append(cmd.Args[:len(cmd.Args)-1], "--progress=plain", cmd.Args[len(cmd.Args)-1]) + cmd.Env = append(cmd.Environ(), "BUILDKIT_PROGRESS=plain", "DOCKER_BUILDKIT=1") + return cmd, nil +} diff --git a/tools/homarr-dev/internal/docker/volumes.go b/tools/homarr-dev/internal/docker/volumes.go new file mode 100644 index 000000000..bd986ac34 --- /dev/null +++ b/tools/homarr-dev/internal/docker/volumes.go @@ -0,0 +1,151 @@ +package docker + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +// Volume is a Docker volume that belongs to a Homarr instance. +type Volume struct { + Name string + Driver string + Mountpoint string + Size string + InUse bool +} + +// PRNumber returns the pull request a volume stores data for, or zero. +func (v Volume) PRNumber() int { + var number int + if _, err := fmt.Sscanf(v.Name, "homarr_pr_%d_data", &number); err == nil { + return number + } + return 0 +} + +// Tag returns the local image tag a volume stores data for, or an empty string. +func (v Volume) Tag() string { + if v.PRNumber() > 0 { + return "" + } + name := strings.TrimPrefix(v.Name, "homarr_") + if name == v.Name { + return "" + } + return strings.TrimSuffix(name, "_data") +} + +// ListVolumes returns the Homarr data volumes, optionally annotated with disk +// usage when includeSizes is true. +func ListVolumes(ctx context.Context, includeSizes bool) ([]Volume, error) { + out, err := exec.CommandContext(ctx, "docker", "volume", "ls", "--filter", "name=homarr", "--format", "{{json .}}").Output() + if err != nil { + return nil, fmt.Errorf("list Homarr volumes: %w", err) + } + volumes, err := parseVolumeList(out) + if err != nil { + return nil, err + } + var sizes map[string]string + if includeSizes { + sizes = volumeSizes(ctx) + } + inUse := volumesInUse(ctx) + for index := range volumes { + if sizes != nil { + if size, known := sizes[volumes[index].Name]; known { + volumes[index].Size = size + } + } + volumes[index].InUse = inUse[volumes[index].Name] + } + return volumes, nil +} + +func parseVolumeList(output []byte) ([]Volume, error) { + volumes := make([]Volume, 0) + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + if line == "" { + continue + } + var row struct { + Name string + Driver string + Mountpoint string + Size string + } + if err := json.Unmarshal([]byte(line), &row); err != nil { + return nil, fmt.Errorf("parse Docker volume: %w", err) + } + // buildx keeps its cache in volumes that match the same name filter. + if !strings.HasPrefix(row.Name, "homarr_") && row.Name != "homarr" { + continue + } + size := row.Size + if size == "N/A" { + size = "" + } + volumes = append(volumes, Volume{Name: row.Name, Driver: row.Driver, Mountpoint: row.Mountpoint, Size: size}) + } + return volumes, nil +} + +// volumeSizes reads reclaimable space per volume. `docker system df -v` is slow +// and optional, so failures degrade to unknown sizes rather than an error. +func volumeSizes(ctx context.Context) map[string]string { + sizes := make(map[string]string) + out, err := exec.CommandContext(ctx, "docker", "system", "df", "-v", "--format", "{{json .Volumes}}").Output() + if err != nil { + return sizes + } + var rows []struct { + Name string + Size string + } + if err := json.Unmarshal(out, &rows); err != nil { + return sizes + } + for _, row := range rows { + if row.Size != "" && row.Size != "N/A" { + sizes[row.Name] = row.Size + } + } + return sizes +} + +func volumesInUse(ctx context.Context) map[string]bool { + inUse := make(map[string]bool) + out, err := exec.CommandContext(ctx, "docker", "ps", "-a", "--filter", "name=homarr", "--format", "{{.Mounts}}").Output() + if err != nil { + return inUse + } + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + for _, mount := range strings.Split(line, ",") { + if mount = strings.TrimSpace(mount); mount != "" { + inUse[mount] = true + } + } + } + return inUse +} + +// RemoveVolume deletes a volume and the instance data it holds. +func RemoveVolume(ctx context.Context, name string) error { + out, err := exec.CommandContext(ctx, "docker", "volume", "rm", name).CombinedOutput() + if err != nil { + return fmt.Errorf("remove volume %s: %w: %s", name, err, strings.TrimSpace(string(out))) + } + return nil +} + +// RemoveImage force-deletes a local image, including any tags pointing at it. +func RemoveImage(ctx context.Context, reference string) error { + out, err := exec.CommandContext(ctx, "docker", "image", "rm", "-f", reference).CombinedOutput() + if err != nil { + return fmt.Errorf("remove image %s: %w: %s", reference, err, strings.TrimSpace(string(out))) + } + return nil +} diff --git a/tools/homarr-dev/internal/gh/gh.go b/tools/homarr-dev/internal/gh/gh.go index c4e61e6fe..2c47af88c 100644 --- a/tools/homarr-dev/internal/gh/gh.go +++ b/tools/homarr-dev/internal/gh/gh.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "os" "os/exec" "strings" "sync" @@ -197,3 +198,151 @@ func GetPR(ctx context.Context, number int) (*PR, error) { UpdatedAt: r.UpdatedAt, }, nil } + +type Check struct { + Name string `json:"name"` + State string `json:"state"` + Bucket string `json:"bucket"` + Workflow string `json:"workflow"` + Link string `json:"link"` + StartedAt string `json:"startedAt"` + CompletedAt string `json:"completedAt"` + Description string `json:"description"` + Event string `json:"event"` +} + +var checkBucketKeys = map[string]string{ + "pass": "pass", + "fail": "fail", + "pending": "pending", + "skipping": "skipping", + "cancel": "cancel", +} + +func (c Check) BucketKey() string { + if key, found := checkBucketKeys[strings.ToLower(c.Bucket)]; found { + return key + } + return "pending" +} + +func (c Check) IsPending() bool { + return c.Bucket == "pending" || c.State == "PENDING" || c.State == "IN_PROGRESS" || c.State == "STARTING" +} + +func (c Check) IsSuccess() bool { + return c.Bucket == "pass" || c.State == "SUCCESS" +} + +func (c Check) IsFailure() bool { + return c.Bucket == "fail" || c.State == "FAILURE" || c.State == "ERROR" +} + +func (c Check) IsSkipped() bool { + return c.Bucket == "skipping" || c.Bucket == "cancel" || c.State == "SKIPPED" || c.State == "CANCELLED" +} + +func (c Check) Duration() string { + if c.StartedAt == "" { + return "" + } + start, err := time.Parse(time.RFC3339, c.StartedAt) + if err != nil || start.IsZero() { + return "" + } + end := time.Now() + if c.CompletedAt != "" { + if tEnd, err := time.Parse(time.RFC3339, c.CompletedAt); err == nil && !tEnd.IsZero() { + end = tEnd + } + } + d := end.Sub(start).Round(time.Second) + if d < 0 { + return "" + } + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60) + } + return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60) +} + +var prChecksCache = struct { + sync.Mutex + entries map[int]cachedChecks +}{entries: make(map[int]cachedChecks)} + +type cachedChecks struct { + checks []Check + fetchedAt time.Time +} + +func GetPRChecks(ctx context.Context, number int, refresh bool) ([]Check, error) { + if number <= 0 { + return nil, fmt.Errorf("invalid PR number: %d", number) + } + prChecksCache.Lock() + entry, found := prChecksCache.entries[number] + prChecksCache.Unlock() + if !refresh && found && time.Since(entry.fetchedAt) < 15*time.Second { + return append([]Check(nil), entry.checks...), nil + } + + cmd := exec.CommandContext(ctx, "gh", "pr", "checks", fmt.Sprint(number), + "--repo", Repo, + "--json", "name,state,bucket,workflow,link,startedAt,completedAt,description,event", + ) + out, err := cmd.CombinedOutput() + if err != nil && len(out) == 0 { + return nil, commandError(err, out) + } + + var checks []Check + if jsonErr := json.Unmarshal(out, &checks); jsonErr != nil { + if err != nil { + return nil, commandError(err, out) + } + return nil, fmt.Errorf("parse checks JSON: %w", jsonErr) + } + + prChecksCache.Lock() + prChecksCache.entries[number] = cachedChecks{checks: checks, fetchedAt: time.Now()} + prChecksCache.Unlock() + + return checks, nil +} + +func WatchPRChecks(ctx context.Context, number int) error { + cmd := exec.CommandContext(ctx, "gh", "pr", "checks", fmt.Sprint(number), "--repo", Repo, "--watch") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func CurrentBranchPR(ctx context.Context) (*PR, error) { + out, err := exec.CommandContext(ctx, "gh", "pr", "view", + "--repo", Repo, + "--json", "number,title,author,headRefName,headRefOid,updatedAt,isDraft,statusCheckRollup", + ).CombinedOutput() + if err != nil { + return nil, commandError(err, out) + } + + var r rawPR + if err := json.Unmarshal(out, &r); err != nil { + return nil, err + } + return &PR{ + Number: r.Number, + Title: r.Title, + Author: r.Author.Login, + HeadRef: r.HeadRefName, + HeadSHA: r.HeadRefOID, + CIState: rollupState(r.StatusCheckRollup), + IsDraft: r.IsDraft, + UpdatedAt: r.UpdatedAt, + }, nil +} diff --git a/tools/homarr-dev/internal/gh/gh_test.go b/tools/homarr-dev/internal/gh/gh_test.go index 64e14e20c..be7a66a4a 100644 --- a/tools/homarr-dev/internal/gh/gh_test.go +++ b/tools/homarr-dev/internal/gh/gh_test.go @@ -89,3 +89,47 @@ func TestListPRsExcludesBotsIntegration(t *testing.T) { } } } + +func TestCheckHelpersAndDuration(t *testing.T) { + c1 := Check{ + Name: "Test Job", + State: "SUCCESS", + Bucket: "pass", + StartedAt: "2026-08-21T10:00:00Z", + CompletedAt: "2026-08-21T10:05:30Z", + } + if !c1.IsSuccess() || c1.IsFailure() || c1.IsPending() || c1.IsSkipped() { + t.Fatalf("unexpected state helpers for c1: success=%v, fail=%v, pend=%v, skip=%v", c1.IsSuccess(), c1.IsFailure(), c1.IsPending(), c1.IsSkipped()) + } + if d := c1.Duration(); d != "5m30s" { + t.Fatalf("Duration() = %q, want 5m30s", d) + } + + c2 := Check{ + Name: "Fail Job", + State: "FAILURE", + Bucket: "fail", + } + if !c2.IsFailure() { + t.Fatalf("expected c2 to be failure") + } + if c2.Duration() != "" { + t.Fatalf("expected empty duration for missing timestamps") + } + + c3 := Check{ + Name: "Pending Job", + Bucket: "pending", + } + if !c3.IsPending() { + t.Fatalf("expected c3 to be pending") + } + + c4 := Check{ + Name: "Skipped Job", + Bucket: "skipping", + } + if !c4.IsSkipped() { + t.Fatalf("expected c4 to be skipped") + } +} diff --git a/tools/homarr-dev/internal/logs/registry.go b/tools/homarr-dev/internal/logs/registry.go new file mode 100644 index 000000000..0eedc699b --- /dev/null +++ b/tools/homarr-dev/internal/logs/registry.go @@ -0,0 +1,241 @@ +// Package logs keeps live `docker logs --follow` streams for running +// containers so the sidebar can show output as it happens instead of polling. +package logs + +import ( + "context" + "strings" + "sync" + "time" + + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker" +) + +// retainedLines bounds memory per container. Homarr is chatty at boot, so the +// buffer has to be deep enough to still contain the startup banner once the +// app settles. +const retainedLines = 2000 + +// maxStreams bounds how many containers are followed at once. Each stream is a +// `docker logs --follow` process plus a buffer, which is cheap for the handful +// of instances a developer normally runs but not for thirty. The least recently +// viewed stream is dropped when the cap is reached; re-attaching replays the +// tail, so nothing is lost beyond the scrollback. +const maxStreams = 6 + +// State describes what a stream is currently doing. +type State int + +const ( + StateStarting State = iota + StateStreaming + StateStopped + StateFailed +) + +// Stream is the retained output of one container. +type Stream struct { + mu sync.Mutex + name string + lines []string + state State + err error + cancel context.CancelFunc + since time.Time + used time.Time +} + +// Registry owns every active log stream and notifies a single listener when new +// output arrives. +type Registry struct { + mu sync.Mutex + streams map[string]*Stream + notify chan struct{} +} + +func NewRegistry() *Registry { + return &Registry{streams: make(map[string]*Stream), notify: make(chan struct{}, 1)} +} + +// Changed is a coalescing wake-up channel, mirroring task.Manager. A container +// logging a thousand lines a second still costs one redraw per frame. +func (r *Registry) Changed() <-chan struct{} { return r.notify } + +func (r *Registry) touch() { + select { + case r.notify <- struct{}{}: + default: + } +} + +// Attach starts following a container's logs if it is not already being +// followed. It returns immediately; output arrives on the Changed channel. +func (r *Registry) Attach(name string) { + if name == "" { + return + } + r.mu.Lock() + if _, exists := r.streams[name]; exists { + r.mu.Unlock() + return + } + now := time.Now() + ctx, cancel := context.WithCancel(context.Background()) + stream := &Stream{name: name, state: StateStarting, cancel: cancel, since: now, used: now} + r.streams[name] = stream + evicted := r.evictLocked() + r.mu.Unlock() + + for _, stale := range evicted { + stale.cancel() + } + go r.follow(ctx, stream) + r.touch() +} + +// evictLocked drops the least recently viewed streams above the cap. The caller +// holds the lock and cancels the returned streams outside it. +func (r *Registry) evictLocked() []*Stream { + evicted := make([]*Stream, 0) + for len(r.streams) > maxStreams { + var oldest *Stream + for _, stream := range r.streams { + if oldest == nil || stream.used.Before(oldest.used) { + oldest = stream + } + } + if oldest == nil { + break + } + delete(r.streams, oldest.name) + evicted = append(evicted, oldest) + } + return evicted +} + +func (r *Registry) follow(ctx context.Context, stream *Stream) { + lines, err := docker.StreamContainerLogs(ctx, stream.name, 400) + if err != nil { + stream.set(StateFailed, err) + r.touch() + return + } + stream.set(StateStreaming, nil) + r.touch() + for line := range lines { + if line.Done { + if ctx.Err() != nil { + stream.set(StateStopped, nil) + } else if line.Err != nil { + stream.set(StateFailed, line.Err) + } else { + stream.set(StateStopped, nil) + } + r.touch() + return + } + stream.append(line.Text) + r.touch() + } +} + +func (s *Stream) append(line string) { + s.mu.Lock() + defer s.mu.Unlock() + s.lines = append(s.lines, line) + if len(s.lines) > retainedLines { + s.lines = s.lines[len(s.lines)-retainedLines:] + } +} + +func (s *Stream) set(state State, err error) { + s.mu.Lock() + defer s.mu.Unlock() + s.state, s.err = state, err +} + +// Detach stops following a container and drops its buffer. +func (r *Registry) Detach(name string) { + r.mu.Lock() + stream, exists := r.streams[name] + delete(r.streams, name) + r.mu.Unlock() + if exists && stream.cancel != nil { + stream.cancel() + } + r.touch() +} + +// Retain stops streams for containers that no longer exist, so a removed +// instance does not keep a dead `docker logs` process around. +func (r *Registry) Retain(names []string) { + live := make(map[string]bool, len(names)) + for _, name := range names { + live[name] = true + } + r.mu.Lock() + stale := make([]*Stream, 0) + for name, stream := range r.streams { + if !live[name] { + stale = append(stale, stream) + delete(r.streams, name) + } + } + r.mu.Unlock() + for _, stream := range stale { + if stream.cancel != nil { + stream.cancel() + } + } + if len(stale) > 0 { + r.touch() + } +} + +// Close stops every stream. +func (r *Registry) Close() { r.Retain(nil) } + +// Snapshot is an immutable view of one stream. +type Snapshot struct { + Name string + Lines []string + State State + Err error +} + +// Text joins the retained lines for rendering into a viewport. +func (s Snapshot) Text() string { return strings.Join(s.Lines, "\n") } + +// Snapshot returns the retained output for a container. The second result is +// false when the container is not being followed. +func (r *Registry) Snapshot(name string) (Snapshot, bool) { + r.mu.Lock() + stream, exists := r.streams[name] + if exists { + // Viewing a stream is what keeps it alive under the cap. + stream.used = time.Now() + } + r.mu.Unlock() + if !exists { + return Snapshot{}, false + } + stream.mu.Lock() + defer stream.mu.Unlock() + return Snapshot{ + Name: stream.name, + Lines: append([]string(nil), stream.lines...), + State: stream.state, + Err: stream.err, + }, true +} + +// Attached lists the containers currently being followed. +func (r *Registry) Attached() []string { + r.mu.Lock() + defer r.mu.Unlock() + names := make([]string, 0, len(r.streams)) + for name := range r.streams { + names = append(names, name) + } + return names +} diff --git a/tools/homarr-dev/internal/logs/registry_test.go b/tools/homarr-dev/internal/logs/registry_test.go new file mode 100644 index 000000000..be0c2891e --- /dev/null +++ b/tools/homarr-dev/internal/logs/registry_test.go @@ -0,0 +1,96 @@ +package logs + +import ( + "fmt" + "testing" + "time" +) + +func TestRegistryAttachAndSnapshot(t *testing.T) { + reg := NewRegistry() + defer reg.Close() + + snap, attached := reg.Snapshot("nonexistent") + if attached { + t.Fatalf("expected attached=false for unattached container") + } + + reg.Attach("test-container") + snap, attached = reg.Snapshot("test-container") + if !attached { + t.Fatalf("expected attached=true after Attach") + } + if snap.State != StateStarting && snap.State != StateStreaming { + t.Fatalf("expected StateStarting or StateStreaming, got %v", snap.State) + } + + reg.Detach("test-container") + snap, attached = reg.Snapshot("test-container") + if attached { + t.Fatalf("expected attached=false after Detach") + } +} + +func TestRegistryChangedNotification(t *testing.T) { + reg := NewRegistry() + defer reg.Close() + + ch := reg.Changed() + if ch == nil { + t.Fatal("expected non-nil Changed channel") + } + + reg.touch() + select { + case <-ch: + case <-time.After(50 * time.Millisecond): + t.Fatal("expected notification on Changed channel") + } +} + +func TestStreamLineBufferingAndBounds(t *testing.T) { + s := &Stream{ + name: "test", + since: time.Now(), + used: time.Now(), + } + + for i := 0; i < retainedLines+50; i++ { + s.append(fmt.Sprintf("line %d", i)) + } + + s.mu.Lock() + lines := make([]string, len(s.lines)) + copy(lines, s.lines) + s.mu.Unlock() + + if len(lines) != retainedLines { + t.Fatalf("expected %d retained lines, got %d", retainedLines, len(lines)) + } + + if lines[0] != "line 50" { + t.Errorf("first retained line = %q, want 'line 50'", lines[0]) + } +} + +func TestRegistryRetainAndEviction(t *testing.T) { + reg := NewRegistry() + defer reg.Close() + + reg.Attach("c1") + reg.Attach("c2") + reg.Attach("c3") + + // Retain only c1 and c3 + reg.Retain([]string{"c1", "c3"}) + + if _, attached := reg.Snapshot("c2"); attached { + t.Errorf("c2 should have been dropped by Retain") + } + if _, attached := reg.Snapshot("c1"); !attached { + t.Errorf("c1 should still be attached") + } + if _, attached := reg.Snapshot("c3"); !attached { + t.Errorf("c3 should still be attached") + } +} diff --git a/tools/homarr-dev/internal/platform/open.go b/tools/homarr-dev/internal/platform/open.go index 3be91d562..70d80d5ff 100644 --- a/tools/homarr-dev/internal/platform/open.go +++ b/tools/homarr-dev/internal/platform/open.go @@ -14,15 +14,16 @@ func OpenURL(url string) error { return exec.Command(name, args...).Start() } -func openCommand(goos, url string) (string, []string, error) { - switch goos { - case "darwin": - return "open", []string{url}, nil - case "linux": - return "xdg-open", []string{url}, nil - case "windows": - return "rundll32", []string{"url.dll,FileProtocolHandler", url}, nil - default: - return "", nil, fmt.Errorf("opening URLs is not supported on %s", goos) - } +var platformOpeners = map[string]func(string) (string, []string){ + "darwin": func(u string) (string, []string) { return "open", []string{u} }, + "linux": func(u string) (string, []string) { return "xdg-open", []string{u} }, + "windows": func(u string) (string, []string) { return "rundll32", []string{"url.dll,FileProtocolHandler", u} }, +} + +func openCommand(goos, url string) (string, []string, error) { + if opener, supported := platformOpeners[goos]; supported { + name, args := opener(url) + return name, args, nil + } + return "", nil, fmt.Errorf("opening URLs is not supported on %s", goos) } diff --git a/tools/homarr-dev/internal/registry/ghcr.go b/tools/homarr-dev/internal/registry/ghcr.go new file mode 100644 index 000000000..bda9c4920 --- /dev/null +++ b/tools/homarr-dev/internal/registry/ghcr.go @@ -0,0 +1,165 @@ +// Package registry queries GHCR for the pull-request test images Homarr's CI +// publishes. +package registry + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os/exec" + "strconv" + "strings" + "sync" + "time" +) + +// TestImageRepository is where CI pushes one image per open pull request. +const TestImageRepository = "homarr-labs/homarr-test" + +// ImageReference returns the GHCR reference for a pull request's test image. +func ImageReference(pr int) string { + return fmt.Sprintf("ghcr.io/%s:pr-%d", TestImageRepository, pr) +} + +const ( + tagCacheTTL = 2 * time.Minute + httpTimeout = 20 * time.Second +) + +var tagCache struct { + sync.Mutex + tags map[string]bool + fetchedAt time.Time +} + +var client = &http.Client{Timeout: httpTimeout} + +// PublishedTags lists every tag in the test image repository in a single +// request. Checking availability one pull request at a time with `docker +// manifest inspect` costs one network round trip per row, which dominates +// startup on a busy repository; the registry's own tag listing answers the same +// question for every row at once. +func PublishedTags(ctx context.Context, refresh bool) (map[string]bool, error) { + tagCache.Lock() + cached, fetchedAt := tagCache.tags, tagCache.fetchedAt + tagCache.Unlock() + if !refresh && cached != nil && time.Since(fetchedAt) < tagCacheTTL { + return cached, nil + } + + token, err := anonymousToken(ctx) + if err != nil { + return nil, err + } + tags, err := listTags(ctx, token) + if err != nil { + if cached != nil { + return cached, err + } + return nil, err + } + + tagCache.Lock() + tagCache.tags, tagCache.fetchedAt = tags, time.Now() + tagCache.Unlock() + return tags, nil +} + +// HasPRImage reports whether CI has published an image for a pull request. +func HasPRImage(tags map[string]bool, pr int) bool { + return tags["pr-"+strconv.Itoa(pr)] +} + +func anonymousToken(ctx context.Context) (string, error) { + url := fmt.Sprintf("https://ghcr.io/token?scope=repository:%s:pull&service=ghcr.io", TestImageRepository) + request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + response, err := client.Do(request) + if err != nil { + return "", fmt.Errorf("request GHCR token: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("request GHCR token: %s", response.Status) + } + var payload struct { + Token string `json:"token"` + } + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + return "", fmt.Errorf("decode GHCR token: %w", err) + } + return payload.Token, nil +} + +func listTags(ctx context.Context, token string) (map[string]bool, error) { + tags := make(map[string]bool) + next := fmt.Sprintf("https://ghcr.io/v2/%s/tags/list?n=1000", TestImageRepository) + for page := 0; next != "" && page < 20; page++ { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil) + if err != nil { + return nil, err + } + request.Header.Set("Authorization", "Bearer "+token) + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("list GHCR tags: %w", err) + } + if response.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(response.Body, 2048)) + _ = response.Body.Close() + return nil, fmt.Errorf("list GHCR tags: %s: %s", response.Status, strings.TrimSpace(string(body))) + } + var payload struct { + Tags []string `json:"tags"` + } + err = json.NewDecoder(response.Body).Decode(&payload) + linkHeader := response.Header.Get("Link") + _ = response.Body.Close() + if err != nil { + return nil, fmt.Errorf("decode GHCR tags: %w", err) + } + for _, tag := range payload.Tags { + tags[tag] = true + } + next = nextPageURL(linkHeader) + } + return tags, nil +} + +// nextPageURL follows the RFC 5988 Link header the registry uses for paging. +func nextPageURL(link string) string { + for _, part := range strings.Split(link, ",") { + if !strings.Contains(part, `rel="next"`) { + continue + } + start := strings.Index(part, "<") + end := strings.Index(part, ">") + if start < 0 || end <= start { + continue + } + target := part[start+1 : end] + if strings.HasPrefix(target, "/") { + return "https://ghcr.io" + target + } + return target + } + return "" +} + +// ManifestExists falls back to the Docker CLI for a single reference, which +// also works for private repositories where the anonymous listing is refused. +func ManifestExists(ctx context.Context, reference string) (bool, error) { + out, err := exec.CommandContext(ctx, "docker", "manifest", "inspect", reference).CombinedOutput() + if err == nil { + return true, nil + } + message := strings.ToLower(string(out)) + if strings.Contains(message, "manifest unknown") || strings.Contains(message, "no such manifest") { + return false, nil + } + return false, fmt.Errorf("check %s: %w: %s", reference, err, strings.TrimSpace(string(out))) +} diff --git a/tools/homarr-dev/internal/registry/probe_test.go b/tools/homarr-dev/internal/registry/probe_test.go new file mode 100644 index 000000000..ce987e551 --- /dev/null +++ b/tools/homarr-dev/internal/registry/probe_test.go @@ -0,0 +1,32 @@ +package registry + +import ( + "context" + "os" + "testing" + "time" +) + +func TestPublishedTagsIntegration(t *testing.T) { + if testing.Short() || os.Getenv("HOMARR_INTEGRATION_TEST") != "1" { + t.Skip("skipping GHCR integration test; set HOMARR_INTEGRATION_TEST=1 to run") + } + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + start := time.Now() + tags, err := PublishedTags(ctx, true) + if err != nil { + t.Skipf("GHCR unreachable: %v", err) + } + t.Logf("fetched %d tags in %s", len(tags), time.Since(start)) + if len(tags) == 0 { + t.Fatal("expected at least one published tag") + } + found := 0 + for tag := range tags { + if found < 5 { + t.Log(" ", tag) + found++ + } + } +} diff --git a/tools/homarr-dev/internal/run/build.go b/tools/homarr-dev/internal/run/build.go index de78fd291..0ce596dd2 100644 --- a/tools/homarr-dev/internal/run/build.go +++ b/tools/homarr-dev/internal/run/build.go @@ -7,11 +7,9 @@ import ( "os" "os/exec" "path/filepath" - "strconv" "strings" "github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker" - "github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh" ) func BuildCurrentImage(tag, directory string) error { @@ -19,11 +17,11 @@ func BuildCurrentImage(tag, directory string) error { } func buildCurrentImage(ctx context.Context, tag, directory string, stdout, stderr io.Writer) error { - root, revision, err := checkoutDetails(ctx, directory) + options, err := CurrentBuildOptions(ctx, tag, directory) if err != nil { return err } - return docker.BuildContext(ctx, docker.BuildOptions{Context: root, Tag: tag, Source: root, Revision: revision}, stdout, stderr) + return docker.BuildContext(ctx, options, stdout, stderr) } func BuildPRImage(number int, tag string) error { @@ -31,44 +29,18 @@ func BuildPRImage(number int, tag string) error { } func buildPRImage(ctx context.Context, number int, tag string, stdout, stderr io.Writer) error { - if number <= 0 { - return fmt.Errorf("PR number must be positive") - } - temporaryRoot, err := os.MkdirTemp("", "homarr-pr-*") - if err != nil { - return fmt.Errorf("create temporary checkout: %w", err) - } - defer os.RemoveAll(temporaryRoot) - - checkout := filepath.Join(temporaryRoot, "homarr") - clone := exec.CommandContext(ctx, "gh", prCloneArgs(checkout)...) - clone.Stdout = stdout - clone.Stderr = stderr - if err := clone.Run(); err != nil { - return fmt.Errorf("clone %s: %w", gh.Repo, err) - } - command := exec.CommandContext(ctx, "gh", "pr", "checkout", strconv.Itoa(number), "--detach") - command.Dir = checkout - command.Stdout = stdout - command.Stderr = stderr - if err := command.Run(); err != nil { - return fmt.Errorf("checkout PR #%d: %w", number, err) - } - _, revision, err := checkoutDetails(ctx, checkout) + options, cleanup, err := PRBuildOptions(ctx, number, tag, func(line string) { + _, _ = fmt.Fprintln(stdout, line) + }) if err != nil { return err } - return docker.BuildContext(ctx, docker.BuildOptions{ - Context: checkout, - Tag: tag, - Source: fmt.Sprintf("https://github.com/%s/pull/%d", gh.Repo, number), - Revision: revision, - PRNumber: number, - }, stdout, stderr) + defer cleanup() + return docker.BuildContext(ctx, options, stdout, stderr) } -func prCloneArgs(checkout string) []string { - return []string{"repo", "clone", gh.Repo, checkout, "--", "--depth=1", "--filter=blob:none"} +func prFetchArgs(repoURL, refSpec string) []string { + return []string{"fetch", "--depth=1", repoURL, refSpec} } func RebuildImage(image docker.Image) error { @@ -89,7 +61,16 @@ func RebuildImageContext(ctx context.Context, image docker.Image, stdout, stderr } func checkoutDetails(ctx context.Context, directory string) (root string, revision string, err error) { - rootOutput, err := exec.CommandContext(ctx, "git", "-C", directory, "rev-parse", "--show-toplevel").CombinedOutput() + cmdRoot := exec.CommandContext(ctx, "git", "-C", directory, "rev-parse", "--show-toplevel") + cmdRoot.Env = append( + os.Environ(), + "MISE_TRUSTED_CONFIG_PATHS=*", + "MISE_QUIET=1", + "MISE_SILENT=1", + "MISE_YES=1", + "MISE_OVERRIDE_CONFIG_FILENAMES=", + ) + rootOutput, err := cmdRoot.CombinedOutput() if err != nil { return "", "", fmt.Errorf("find Homarr checkout: %w: %s", err, strings.TrimSpace(string(rootOutput))) } @@ -97,7 +78,16 @@ func checkoutDetails(ctx context.Context, directory string) (root string, revisi if _, err := os.Stat(filepath.Join(root, "Dockerfile")); err != nil { return "", "", fmt.Errorf("%s is not a Homarr checkout", root) } - revisionOutput, err := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "HEAD").CombinedOutput() + cmdRev := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "HEAD") + cmdRev.Env = append( + os.Environ(), + "MISE_TRUSTED_CONFIG_PATHS=*", + "MISE_QUIET=1", + "MISE_SILENT=1", + "MISE_YES=1", + "MISE_OVERRIDE_CONFIG_FILENAMES=", + ) + revisionOutput, err := cmdRev.CombinedOutput() if err != nil { return "", "", fmt.Errorf("read checkout revision: %w: %s", err, strings.TrimSpace(string(revisionOutput))) } diff --git a/tools/homarr-dev/internal/run/prepare.go b/tools/homarr-dev/internal/run/prepare.go new file mode 100644 index 000000000..178f8c18f --- /dev/null +++ b/tools/homarr-dev/internal/run/prepare.go @@ -0,0 +1,128 @@ +package run + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker" + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh" +) + +// Cleanup releases a temporary checkout. It is always safe to call. +type Cleanup func() + +func noCleanup() {} + +// CurrentBuildOptions describes a build of the checkout that contains +// directory, recording the source and revision on the image so it can be +// rebuilt later without the user remembering where it came from. +func CurrentBuildOptions(ctx context.Context, tag, directory string) (docker.BuildOptions, error) { + root, revision, err := checkoutDetails(ctx, directory) + if err != nil { + return docker.BuildOptions{}, err + } + return docker.BuildOptions{Context: root, Tag: tag, Source: root, Revision: revision}, nil +} + +// PRBuildOptions clones a pull request into a temporary checkout and describes +// a build of it. The returned Cleanup must run once the build has finished. +// +// Progress is reported through log rather than written to stdout, because the +// caller is a background job rendering inside a TUI, not a shell. +func PRBuildOptions(ctx context.Context, number int, tag string, log func(string)) (docker.BuildOptions, Cleanup, error) { + if number <= 0 { + return docker.BuildOptions{}, noCleanup, fmt.Errorf("PR number must be positive") + } + if log == nil { + log = func(string) {} + } + temporaryRoot, err := os.MkdirTemp("", "homarr-pr-*") + if err != nil { + return docker.BuildOptions{}, noCleanup, fmt.Errorf("create temporary checkout: %w", err) + } + cleanup := func() { _ = os.RemoveAll(temporaryRoot) } + + checkout := filepath.Join(temporaryRoot, "homarr") + if err := os.MkdirAll(checkout, 0o755); err != nil { + cleanup() + return docker.BuildOptions{}, noCleanup, fmt.Errorf("create checkout directory: %w", err) + } + + repoURL := fmt.Sprintf("https://github.com/%s.git", gh.Repo) + branchName := fmt.Sprintf("pr-%d", number) + refSpec := fmt.Sprintf("refs/pull/%d/head:%s", number, branchName) + + log(fmt.Sprintf("fetching PR #%d (shallow) from %s", number, gh.Repo)) + initCmd := exec.CommandContext(ctx, "git", "init", checkout) + if err := runLogged(initCmd, log); err != nil { + cleanup() + return docker.BuildOptions{}, noCleanup, fmt.Errorf("init git repository: %w", err) + } + + fetchCmd := exec.CommandContext(ctx, "git", "-C", checkout, "fetch", "--depth", "1", repoURL, refSpec) + if err := runLogged(fetchCmd, log); err != nil { + cleanup() + return docker.BuildOptions{}, noCleanup, fmt.Errorf("fetch PR #%d: %w", number, err) + } + + checkoutCmd := exec.CommandContext(ctx, "git", "-C", checkout, "checkout", branchName) + if err := runLogged(checkoutCmd, log); err != nil { + cleanup() + return docker.BuildOptions{}, noCleanup, fmt.Errorf("checkout PR #%d: %w", number, err) + } + + _, revision, err := checkoutDetails(ctx, checkout) + if err != nil { + cleanup() + return docker.BuildOptions{}, noCleanup, err + } + log("building " + revision[:min(len(revision), 12)]) + return docker.BuildOptions{ + Context: checkout, + Tag: tag, + Source: fmt.Sprintf("https://github.com/%s/pull/%d", gh.Repo, number), + Revision: revision, + PRNumber: number, + }, cleanup, nil +} + +// RebuildOptions describes a rebuild of an existing local image from the +// provenance recorded on it, whether that is a pull request or a checkout. +func RebuildOptions(ctx context.Context, image docker.Image, log func(string)) (docker.BuildOptions, Cleanup, error) { + if image.PRNumber > 0 { + return PRBuildOptions(ctx, image.PRNumber, image.Tag, log) + } + if image.Source == "" { + return docker.BuildOptions{}, noCleanup, fmt.Errorf("homarr:%s has no build source metadata", image.Tag) + } + if info, err := os.Stat(image.Source); err != nil || !info.IsDir() { + return docker.BuildOptions{}, noCleanup, fmt.Errorf("build source %s is no longer available", image.Source) + } + options, err := CurrentBuildOptions(ctx, image.Tag, image.Source) + return options, noCleanup, err +} + +func runLogged(command *exec.Cmd, log func(string)) error { + command.Env = append( + os.Environ(), + "MISE_TRUSTED_CONFIG_PATHS=*", + "MISE_QUIET=1", + "MISE_SILENT=1", + "MISE_YES=1", + "MISE_OVERRIDE_CONFIG_FILENAMES=", + ) + lines, err := docker.StreamCommand(command) + if err != nil { + return err + } + for line := range lines { + if line.Done { + return line.Err + } + log(line.Text) + } + return nil +} diff --git a/tools/homarr-dev/internal/run/run_test.go b/tools/homarr-dev/internal/run/run_test.go index 5b3681705..b4fb3cf83 100644 --- a/tools/homarr-dev/internal/run/run_test.go +++ b/tools/homarr-dev/internal/run/run_test.go @@ -19,10 +19,10 @@ func TestPRPlanUsesAMD64(t *testing.T) { } } -func TestPRCloneIsShallow(t *testing.T) { - args := prCloneArgs("/tmp/checkout") - if !slices.Contains(args, "--depth=1") || !slices.Contains(args, "--filter=blob:none") { - t.Fatalf("clone args = %v", args) +func TestPRFetchIsShallow(t *testing.T) { + args := prFetchArgs("https://github.com/homarr-labs/homarr.git", "refs/pull/1/head:pr-1") + if !slices.Contains(args, "--depth=1") { + t.Fatalf("fetch args = %v", args) } } diff --git a/tools/homarr-dev/internal/task/build_probe_test.go b/tools/homarr-dev/internal/task/build_probe_test.go new file mode 100644 index 000000000..359ceea23 --- /dev/null +++ b/tools/homarr-dev/internal/task/build_probe_test.go @@ -0,0 +1,59 @@ +package task + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker" +) + +// Exercises the real BuildKit streaming and parsing path against a small +// Dockerfile, so the step tracker is validated on genuine output. +func TestBuildImageStreamsRealSteps(t *testing.T) { + if testing.Short() || os.Getenv("HOMARR_INTEGRATION_TEST") != "1" { + t.Skip("skipping docker build probe test; set HOMARR_INTEGRATION_TEST=1 to run") + } + dir := t.TempDir() + dockerfile := `FROM alpine:3.20 AS base +RUN echo "hello from step two" && sleep 1 +FROM base AS final +RUN echo "third step" > /out.txt +RUN cat /out.txt +` + if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil { + t.Fatal(err) + } + + manager := NewManager() + task := manager.Build("build homarr:cli-probe", "homarr:cli-probe", docker.BuildOptions{ + Context: dir, Tag: "cli-probe", Source: dir, Revision: "probe", + }) + t.Cleanup(func() { _ = docker.RemoveImage(context.Background(), "homarr:cli-probe") }) + + for range 600 { + <-manager.Changed() + if snapshot, _ := manager.Snapshot(task.ID()); snapshot.State.Done() { + t.Logf("state=%s percent=%.2f detail=%q err=%v", snapshot.State, snapshot.Percent, snapshot.Detail, snapshot.Err) + for _, step := range snapshot.Steps { + t.Logf(" #%-3s done=%-5v %-46s %s", step.ID, step.Done, truncate(step.Label, 46), step.Note) + } + if snapshot.State != StateSucceeded { + t.Fatalf("build did not succeed: %v", snapshot.Err) + } + if len(snapshot.Steps) < 4 { + t.Fatalf("expected several build steps, got %d", len(snapshot.Steps)) + } + return + } + } + t.Fatal("build did not finish") +} + +func truncate(value string, width int) string { + if len(value) <= width { + return value + } + return value[:width] +} diff --git a/tools/homarr-dev/internal/task/jobs.go b/tools/homarr-dev/internal/task/jobs.go new file mode 100644 index 000000000..2ac9dc4ce --- /dev/null +++ b/tools/homarr-dev/internal/task/jobs.go @@ -0,0 +1,237 @@ +package task + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker" +) + +// Pull downloads an image, preferring the Docker Engine API because it reports +// per-layer byte progress even when the CLI would not. If the socket is +// unreachable the plain `docker pull` output is parsed instead, which still +// yields per-layer state, just without byte counters. +func (m *Manager) Pull(title, target, image, platform string) *Task { + return m.Start(KindPull, title, target, func(ctx context.Context, report *Reporter) error { + return PullImage(ctx, report, image, platform) + }) +} + +// PullImage is the body of a pull, exported so callers can compose it with +// follow-up work — pulling and then starting a container reads better as one +// job than as two. +func PullImage(ctx context.Context, report *Reporter, image, platform string) error { + tracker := docker.NewPullTracker() + publish := throttled(report, func() { + report.SetSteps(pullSteps(tracker)) + report.SetPercent(tracker.Percent()) + report.SetDetail(pullDetail(tracker)) + }) + + messages, err := docker.PullStream(ctx, image, platform) + if err != nil { + report.Log("engine API unavailable (" + err.Error() + "), falling back to docker pull") + return pullWithCLI(ctx, report, tracker, publish, image, platform) + } + for message := range messages { + if message.Error != "" { + return fmt.Errorf("pull %s: %s", image, message.Error) + } + tracker.Apply(message) + if message.ID == "" && message.Status != "" { + report.Log(message.Status) + } + publish(false) + } + if ctx.Err() != nil { + return ctx.Err() + } + publish(true) + return nil +} + +func pullWithCLI(ctx context.Context, report *Reporter, tracker *docker.PullTracker, publish func(bool), image, platform string) error { + command := docker.PullCommandContext(ctx, image, platform) + command.Cancel = func() error { return command.Process.Kill() } + lines, err := docker.StreamCommand(command) + if err != nil { + return err + } + for line := range lines { + if line.Done { + if line.Err != nil { + return line.Err + } + break + } + tracker.ApplyLine(line.Text) + publish(false) + } + if ctx.Err() != nil { + return ctx.Err() + } + publish(true) + return nil +} + +func pullSteps(tracker *docker.PullTracker) []Step { + layers := tracker.Layers() + steps := make([]Step, 0, len(layers)) + for _, layer := range layers { + note := "" + if layer.Total > 0 && !layer.Done() { + note = docker.FormatSize(layer.Current) + "/" + docker.FormatSize(layer.Total) + } else if layer.Total > 0 { + note = docker.FormatSize(layer.Total) + } + steps = append(steps, Step{ + ID: layer.ID, + Label: layer.ID, + Status: layer.Status, + Current: layer.Current, + Total: layer.Total, + Percent: layer.Fraction(), + Done: layer.Done(), + Note: note, + }) + } + return steps +} + +func pullDetail(tracker *docker.PullTracker) string { + complete, total := tracker.Complete() + parts := []string{fmt.Sprintf("%d/%d layers", complete, total)} + if current, size := tracker.Bytes(); size > 0 { + parts = append(parts, docker.FormatSize(current)+"/"+docker.FormatSize(size)) + } + if rate := tracker.Rate(); rate > 0 { + parts = append(parts, docker.FormatSize(int64(rate))+"/s") + } + return strings.Join(parts, " · ") +} + +// Build compiles an image, streaming BuildKit's plain progress so the UI can +// show which Dockerfile step is running and what it is printing. +func (m *Manager) Build(title, target string, options docker.BuildOptions) *Task { + return m.Start(KindBuild, title, target, func(ctx context.Context, report *Reporter) error { + return BuildImage(ctx, report, options) + }) +} + +// BuildFrom runs a caller-supplied preparation step (cloning a pull request, +// for example) before building. Preparation output is reported the same way as +// build output so the job reads as one continuous stream. +func (m *Manager) BuildFrom(title, target string, prepare func(ctx context.Context, report *Reporter) (docker.BuildOptions, error)) *Task { + return m.Start(KindBuild, title, target, func(ctx context.Context, report *Reporter) error { + options, err := prepare(ctx, report) + if err != nil { + return err + } + return BuildImage(ctx, report, options) + }) +} + +// BuildImage is the body of a build, exported for the same reason as PullImage. +func BuildImage(ctx context.Context, report *Reporter, options docker.BuildOptions) error { + command, err := docker.BuildCommandStreaming(ctx, options) + if err != nil { + return err + } + command.Cancel = func() error { return command.Process.Kill() } + lines, err := docker.StreamCommand(command) + if err != nil { + return err + } + + tracker := docker.NewBuildTracker() + publish := throttled(report, func() { + report.SetSteps(buildSteps(tracker)) + report.SetPercent(tracker.Percent()) + report.SetDetail(buildDetail(tracker)) + }) + for line := range lines { + if line.Done { + if line.Err != nil { + if detail := tracker.LastMessage(); detail != "" && !strings.Contains(line.Err.Error(), detail) { + return fmt.Errorf("%w: %s", line.Err, detail) + } + return line.Err + } + break + } + tracker.ApplyLine(line.Text) + report.Log(line.Text) + publish(false) + } + if ctx.Err() != nil { + return ctx.Err() + } + publish(true) + return nil +} + +func buildSteps(tracker *docker.BuildTracker) []Step { + steps := tracker.Steps() + converted := make([]Step, 0, len(steps)) + for _, step := range steps { + label := step.Label + if label == "" { + label = "#" + step.ID + } + percent := 0.0 + if step.Done && !step.Canceled && !step.Failed { + percent = 1 + } + note := step.Elapsed + if step.Cached { + note = "cached" + } + converted = append(converted, Step{ + ID: step.ID, + Label: label, + Status: step.Status, + Percent: percent, + Done: step.Done && !step.Canceled, + Failed: step.Failed, + Note: note, + }) + } + return converted +} + +func buildDetail(tracker *docker.BuildTracker) string { + complete, total := tracker.Complete() + detail := fmt.Sprintf("%d/%d steps", complete, total) + if active, running := tracker.Active(); running { + detail += " · " + active.Label + } + return detail +} + +// Action runs a short operation such as stopping a container. It still goes +// through the manager so a slow daemon can never freeze the interface. +func (m *Manager) Action(title, target string, body func(ctx context.Context) error) *Task { + return m.Start(KindAction, title, target, func(ctx context.Context, report *Reporter) error { + return body(ctx) + }) +} + +// throttled limits how often a progress publisher runs. Docker emits progress +// far faster than a terminal can redraw, and every publish copies slices, so +// coalescing here keeps the cost proportional to the frame rate instead of to +// the daemon's chattiness. Passing force publishes regardless, which the caller +// does once at the end so the final state is never dropped. +func throttled(report *Reporter, publish func()) func(force bool) { + const interval = 80 * time.Millisecond + last := time.Time{} + return func(force bool) { + now := time.Now() + if !force && now.Sub(last) < interval { + return + } + last = now + publish() + } +} diff --git a/tools/homarr-dev/internal/task/task.go b/tools/homarr-dev/internal/task/task.go new file mode 100644 index 000000000..65512c611 --- /dev/null +++ b/tools/homarr-dev/internal/task/task.go @@ -0,0 +1,360 @@ +// Package task runs the CLI's slow work — image pulls, local builds, container +// actions — on background goroutines and exposes it as immutable snapshots. +// +// Nothing in this package blocks the caller. A Manager owns every running job, +// coalesces their updates into a single wake-up channel, and hands the UI a +// consistent view whenever it asks for one. That separation is what keeps the +// terminal responsive while a fifteen minute Docker build streams output. +package task + +import ( + "context" + "sync" + "time" +) + +// Kind classifies a job so the UI can pick an icon and a detail renderer. +type Kind int + +const ( + KindPull Kind = iota + KindBuild + KindAction +) + +var kindNames = map[Kind]string{ + KindPull: "pull", + KindBuild: "build", + KindAction: "action", +} + +func (k Kind) String() string { + if name, found := kindNames[k]; found { + return name + } + return "action" +} + +// State is the lifecycle stage of a job. +type State int + +const ( + StateRunning State = iota + StateSucceeded + StateFailed + StateCanceled +) + +func (s State) Done() bool { return s != StateRunning } + +var stateNames = map[State]string{ + StateRunning: "running", + StateSucceeded: "succeeded", + StateFailed: "failed", + StateCanceled: "canceled", +} + +func (s State) String() string { + if name, found := stateNames[s]; found { + return name + } + return "canceled" +} + +// Step is one unit of progress inside a job: a Docker layer during a pull, or a +// BuildKit vertex during a build. +type Step struct { + ID string + Label string + Status string + Current int64 + Total int64 + Percent float64 + Done bool + Failed bool + Note string +} + +// Snapshot is an immutable view of a job. The UI only ever sees snapshots, so +// it can render without holding a lock or racing the worker goroutine. +type Snapshot struct { + ID int + Kind Kind + Title string + Target string + Detail string + State State + Err error + Percent float64 + Steps []Step + Lines []string + Started time.Time + Ended time.Time + Cancellable bool +} + +// Elapsed reports how long the job ran, or has been running. +func (s Snapshot) Elapsed() time.Duration { + if s.Ended.IsZero() { + return time.Since(s.Started) + } + return s.Ended.Sub(s.Started) +} + +// Reporter is the handle a job body uses to publish progress. Every method is +// safe to call from any goroutine and never blocks on the UI. +type Reporter struct { + task *Task +} + +// Log appends a line of output. Only the most recent lines are retained. +func (r *Reporter) Log(line string) { + r.task.mutate(func(t *Task) { t.lines = appendLine(t.lines, line) }) +} + +// SetSteps replaces the job's progress breakdown. +func (r *Reporter) SetSteps(steps []Step) { + r.task.mutate(func(t *Task) { t.steps = steps }) +} + +// SetPercent records overall progress in the range [0,1]. +func (r *Reporter) SetPercent(percent float64) { + r.task.mutate(func(t *Task) { t.percent = clamp(percent) }) +} + +// SetDetail replaces the single-line summary shown next to the job. +func (r *Reporter) SetDetail(detail string) { + r.task.mutate(func(t *Task) { t.detail = detail }) +} + +// Lines returns the retained output, newest last. +func (r *Reporter) Lines() []string { + r.task.mu.Lock() + defer r.task.mu.Unlock() + return append([]string(nil), r.task.lines...) +} + +// Body is the work a job performs. Returning an error fails the job; returning +// ctx.Err() after cancellation marks it canceled instead. +type Body func(ctx context.Context, report *Reporter) error + +// Task is a single background job. +type Task struct { + mu sync.Mutex + manager *Manager + + id int + kind Kind + title string + target string + detail string + state State + err error + percent float64 + steps []Step + lines []string + started time.Time + ended time.Time + cancel context.CancelFunc +} + +const retainedLines = 500 + +func appendLine(lines []string, line string) []string { + lines = append(lines, line) + if len(lines) > retainedLines { + lines = lines[len(lines)-retainedLines:] + } + return lines +} + +func clamp(value float64) float64 { + if value < 0 { + return 0 + } + if value > 1 { + return 1 + } + return value +} + +func (t *Task) mutate(apply func(*Task)) { + t.mu.Lock() + apply(t) + t.mu.Unlock() + t.manager.touch() +} + +func (t *Task) snapshot() Snapshot { + t.mu.Lock() + defer t.mu.Unlock() + return Snapshot{ + ID: t.id, + Kind: t.kind, + Title: t.title, + Target: t.target, + Detail: t.detail, + State: t.state, + Err: t.err, + Percent: t.percent, + Steps: append([]Step(nil), t.steps...), + Lines: append([]string(nil), t.lines...), + Started: t.started, + Ended: t.ended, + Cancellable: t.cancel != nil && t.state == StateRunning, + } +} + +// ID returns the job's identifier, stable for the lifetime of the process. +func (t *Task) ID() int { return t.id } + +// Manager owns every background job and notifies a single listener when any of +// them changes. +type Manager struct { + mu sync.Mutex + tasks []*Task + nextID int + notify chan struct{} +} + +func NewManager() *Manager { + return &Manager{notify: make(chan struct{}, 1)} +} + +// Changed is a coalescing wake-up channel. It receives at most one pending +// notification, so a job that emits thousands of progress updates per second +// still costs the UI a single redraw per frame. +func (m *Manager) Changed() <-chan struct{} { return m.notify } + +func (m *Manager) touch() { + select { + case m.notify <- struct{}{}: + default: + } +} + +// Start launches a job and returns immediately. +func (m *Manager) Start(kind Kind, title, target string, body Body) *Task { + ctx, cancel := context.WithCancel(context.Background()) + m.mu.Lock() + m.nextID++ + task := &Task{ + manager: m, + id: m.nextID, + kind: kind, + title: title, + target: target, + state: StateRunning, + started: time.Now(), + cancel: cancel, + } + m.tasks = append(m.tasks, task) + m.mu.Unlock() + + go func() { + err := body(ctx, &Reporter{task: task}) + task.mu.Lock() + task.ended = time.Now() + task.cancel = nil + switch { + case ctx.Err() != nil: + task.state = StateCanceled + case err != nil: + task.state, task.err = StateFailed, err + default: + task.state, task.percent = StateSucceeded, 1 + } + task.mu.Unlock() + cancel() + m.touch() + }() + + m.touch() + return task +} + +// Snapshots returns every job, oldest first. +func (m *Manager) Snapshots() []Snapshot { + m.mu.Lock() + tasks := append([]*Task(nil), m.tasks...) + m.mu.Unlock() + snapshots := make([]Snapshot, 0, len(tasks)) + for _, task := range tasks { + snapshots = append(snapshots, task.snapshot()) + } + return snapshots +} + +// Snapshot returns a single job by identifier. +func (m *Manager) Snapshot(id int) (Snapshot, bool) { + m.mu.Lock() + defer m.mu.Unlock() + for _, task := range m.tasks { + if task.id == id { + return task.snapshot(), true + } + } + return Snapshot{}, false +} + +// Cancel asks a running job to stop. +func (m *Manager) Cancel(id int) { + m.mu.Lock() + defer m.mu.Unlock() + for _, task := range m.tasks { + if task.id != id { + continue + } + task.mu.Lock() + cancel := task.cancel + task.mu.Unlock() + if cancel != nil { + cancel() + } + } +} + +// CancelAll stops every running job, used when the program exits. +func (m *Manager) CancelAll() { + for _, snapshot := range m.Snapshots() { + if snapshot.State == StateRunning { + m.Cancel(snapshot.ID) + } + } +} + +// Running counts jobs that have not finished. +func (m *Manager) Running() int { + count := 0 + for _, snapshot := range m.Snapshots() { + if snapshot.State == StateRunning { + count++ + } + } + return count +} + +// RunningFor reports whether a job is already working on the given target, so +// the UI can refuse to start a duplicate build or pull. +func (m *Manager) RunningFor(target string) (Snapshot, bool) { + for _, snapshot := range m.Snapshots() { + if snapshot.State == StateRunning && snapshot.Target == target { + return snapshot, true + } + } + return Snapshot{}, false +} + +// Prune drops finished jobs, keeping the history short. +func (m *Manager) Prune() { + m.mu.Lock() + kept := m.tasks[:0] + for _, task := range m.tasks { + if task.snapshot().State == StateRunning { + kept = append(kept, task) + } + } + m.tasks = kept + m.mu.Unlock() + m.touch() +} diff --git a/tools/homarr-dev/internal/tui/actions.go b/tools/homarr-dev/internal/tui/actions.go new file mode 100644 index 000000000..fb675383a --- /dev/null +++ b/tools/homarr-dev/internal/tui/actions.go @@ -0,0 +1,410 @@ +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.container.Name + "_data", + 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 + 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) +} diff --git a/tools/homarr-dev/internal/tui/app.go b/tools/homarr-dev/internal/tui/app.go deleted file mode 100644 index 1af605250..000000000 --- a/tools/homarr-dev/internal/tui/app.go +++ /dev/null @@ -1,122 +0,0 @@ -package tui - -import ( - "charm.land/bubbles/v2/spinner" - tea "charm.land/bubbletea/v2" -) - -type appModel struct { - dev prsModel - dashboard dashModel - showDashboard bool - width int - height int -} - -func newAppModel(showDashboard bool) appModel { - return appModel{dev: newPRsModel(), dashboard: newDashModel(), showDashboard: showDashboard} -} - -func (m appModel) Init() tea.Cmd { - if m.showDashboard { - return m.dashboard.Init() - } - return m.dev.Init() -} - -func (m appModel) canSwitch() bool { - if m.showDashboard { - return !m.dashboard.filtering - } - return !m.dev.filtering && !m.dev.pulling -} - -func (m appModel) switchScreen() (tea.Model, tea.Cmd) { - m.showDashboard = !m.showDashboard - commands := make([]tea.Cmd, 0, 2) - if m.width > 0 && m.height > 0 { - resize := tea.WindowSizeMsg{Width: m.width, Height: m.height} - if m.showDashboard { - updated, command := m.dashboard.Update(resize) - m.dashboard = updated.(dashModel) - commands = append(commands, command) - } else { - updated, command := m.dev.Update(resize) - m.dev = updated.(prsModel) - commands = append(commands, command) - } - } - if m.showDashboard { - commands = append(commands, m.dashboard.Init()) - } else { - commands = append(commands, m.dev.Init()) - } - return m, tea.Batch(commands...) -} - -func (m appModel) updateDev(msg tea.Msg) (appModel, tea.Cmd) { - updated, command := m.dev.Update(msg) - m.dev = updated.(prsModel) - return m, command -} - -func (m appModel) updateDashboard(msg tea.Msg) (appModel, tea.Cmd) { - updated, command := m.dashboard.Update(msg) - m.dashboard = updated.(dashModel) - return m, command -} - -func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - if window, ok := msg.(tea.WindowSizeMsg); ok { - m.width = window.Width - m.height = window.Height - m, devCommand := m.updateDev(msg) - m, dashboardCommand := m.updateDashboard(msg) - return m, tea.Batch(devCommand, dashboardCommand) - } - if key, ok := msg.(tea.KeyPressMsg); ok { - if key.String() == "d" && m.canSwitch() { - return m.switchScreen() - } - if m.showDashboard { - return m.updateDashboard(msg) - } - return m.updateDev(msg) - } - - switch msg.(type) { - case prsLoadedMsg, imageCheckedMsg, prActionMsg, rebuildDoneMsg, runningRefreshedMsg, pullReadyMsg, pullEvent, spinner.TickMsg: - return m.updateDev(msg) - case tickMsg, actionMsg, rowsMsg: - if m.showDashboard { - return m.updateDashboard(msg) - } - return m, nil - case prLogTickMsg: - if !m.showDashboard { - return m.updateDev(msg) - } - return m, nil - case logsMsg: - m, devCommand := m.updateDev(msg) - m, dashboardCommand := m.updateDashboard(msg) - return m, tea.Batch(devCommand, dashboardCommand) - } - if m.showDashboard { - return m.updateDashboard(msg) - } - return m.updateDev(msg) -} - -func (m appModel) View() tea.View { - if m.showDashboard { - return m.dashboard.View() - } - return m.dev.View() -} - -func runApp(showDashboard bool) error { - program := tea.NewProgram(newAppModel(showDashboard)) - _, err := program.Run() - return err -} diff --git a/tools/homarr-dev/internal/tui/app_test.go b/tools/homarr-dev/internal/tui/app_test.go deleted file mode 100644 index 1112ebb29..000000000 --- a/tools/homarr-dev/internal/tui/app_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package tui - -import ( - "testing" - - tea "charm.land/bubbletea/v2" - - "github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh" -) - -func TestAppTogglePreservesDevelopmentState(t *testing.T) { - m := newAppModel(false) - m.dev.rows = []prRow{{kind: "remote", pr: gh.PR{Number: 6502}}} - - updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"})) - dashboard := updated.(appModel) - if !dashboard.showDashboard { - t.Fatal("d did not open the instance dashboard") - } - updated, _ = dashboard.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"})) - development := updated.(appModel) - if development.showDashboard || len(development.dev.rows) != 1 || development.dev.rows[0].pr.Number != 6502 { - t.Fatalf("development state was not preserved: %#v", development.dev.rows) - } -} - -func TestAppToggleDoesNotInterceptFilterInput(t *testing.T) { - m := newAppModel(false) - m.dev.filtering = true - m.dev.filter.Focus() - - updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"})) - got := updated.(appModel) - if got.showDashboard || got.dev.filter.Value() != "d" { - t.Fatalf("filter input was intercepted: dashboard=%v filter=%q", got.showDashboard, got.dev.filter.Value()) - } -} - -func TestAppCanOpenInstancesDuringBuild(t *testing.T) { - m := newAppModel(false) - m.dev.rebuilding = true - - updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"})) - if !updated.(appModel).showDashboard { - t.Fatal("background build blocked dashboard navigation") - } -} diff --git a/tools/homarr-dev/internal/tui/dashboard.go b/tools/homarr-dev/internal/tui/dashboard.go deleted file mode 100644 index 5cfe2d496..000000000 --- a/tools/homarr-dev/internal/tui/dashboard.go +++ /dev/null @@ -1,415 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "os/exec" - "strings" - "time" - - "charm.land/bubbles/v2/table" - "charm.land/bubbles/v2/textinput" - "charm.land/bubbles/v2/viewport" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" - - "github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker" - "github.com/homarr-labs/homarr/tools/homarr-dev/internal/platform" -) - -var ( - titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")) - helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - logHeadSt = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) - runningLegend = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("● running") - stoppedLegend = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("○ stopped") -) - -type dashModel struct { - table table.Model - allRows []table.Row - filter textinput.Model - filtering bool - logs viewport.Model - showLogs bool - follow bool - status string - confirmRemove string - width int - height int -} - -type tickMsg time.Time -type actionMsg string -type rowsMsg []table.Row -type logsMsg struct { - name string - content string -} - -func tick() tea.Cmd { - return tea.Tick(3*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) -} - -func fetchLogs(name string) tea.Cmd { - return func() tea.Msg { - if name == "" { - return logsMsg{} - } - 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()} - } - return logsMsg{name: name, content: string(out)} - } -} - -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)) - for _, c := range containers { - port := c.HostPort() - url := "" - if port != "" && c.Running() { - url = "http://localhost:" + port - } - state := "● " + c.State - if !c.Running() { - state = "○ " + c.State - } - img := strings.TrimPrefix(c.Image, "ghcr.io/homarr-labs/") - rows = append(rows, table.Row{c.Name, state, img, port, url, c.Status}) - } - return rows -} - -func dashboardColumns(width int) []table.Column { - nameWidth := 26 - imageWidth := 30 - urlWidth := 24 - if width < 100 { - nameWidth = 22 - imageWidth = max(width-54, 18) - urlWidth = 0 - } - return []table.Column{ - {Title: "NAME", Width: nameWidth}, - {Title: "STATE", Width: 14}, - {Title: "IMAGE", Width: imageWidth}, - {Title: "PORT", Width: 6}, - {Title: "URL", Width: urlWidth}, - {Title: "STATUS", Width: 0}, - } -} - -func newDashModel() dashModel { - t := table.New( - table.WithColumns(dashboardColumns(104)), - table.WithFocused(true), - table.WithHeight(10), - table.WithWidth(104), - ) - s := table.DefaultStyles() - s.Header = s.Header.Bold(true) - s.Selected = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("57")) - t.SetStyles(s) - logs := viewport.New(viewport.WithWidth(104), viewport.WithHeight(12)) - logs.SoftWrap = true - filter := textinput.New() - filter.Prompt = "/ " - filter.Placeholder = "filter name, image, port, state…" - filter.SetWidth(50) - styles := textinput.DefaultDarkStyles() - 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) - return dashModel{table: t, filter: filter, logs: logs, showLogs: true, follow: true, width: 104, height: 30} -} - -func (m *dashModel) applyFilter() { - query := strings.ToLower(strings.TrimSpace(m.filter.Value())) - if query == "" { - m.table.SetRows(m.allRows) - return - } - rows := make([]table.Row, 0, len(m.allRows)) - for _, row := range m.allRows { - if strings.Contains(strings.ToLower(strings.Join(row, " ")), query) { - rows = append(rows, row) - } - } - m.table.SetRows(rows) -} - -func (m dashModel) selected() string { - row := m.table.SelectedRow() - if len(row) == 0 { - return "" - } - 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(), loadRows(), fetchLogs(m.selected())) -} - -func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m.table.SetColumns(dashboardColumns(msg.Width)) - m.table.SetWidth(msg.Width) - m.logs.SetWidth(msg.Width) - m.filter.SetWidth(max(min(msg.Width-4, 60), 10)) - m.layout() - return m, nil - case tickMsg: - 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 - } - m.logs.SetContent(strings.TrimRight(msg.content, "\n")) - if m.follow { - m.logs.GotoBottom() - } - return m, nil - case actionMsg: - m.status = string(msg) - m.confirmRemove = "" - return m, loadRows() - case tea.KeyPressMsg: - if m.filtering { - switch msg.Key().Code { - case tea.KeyEscape: - m.filtering = false - m.filter.Reset() - m.filter.Blur() - m.applyFilter() - return m, fetchLogs(m.selected()) - case tea.KeyEnter: - m.filtering = false - m.filter.Blur() - return m, fetchLogs(m.selected()) - } - var cmd tea.Cmd - m.filter, cmd = m.filter.Update(msg) - m.applyFilter() - return m, tea.Batch(cmd, fetchLogs(m.selected())) - } - name := m.selected() - switch msg.Key().Code { - case tea.KeyUp, tea.KeyKpUp: - m.table.MoveUp(1) - return m, fetchLogs(m.selected()) - case tea.KeyDown, tea.KeyKpDown: - m.table.MoveDown(1) - return m, fetchLogs(m.selected()) - case tea.KeyPgUp, tea.KeyKpPgUp: - m.follow = false - m.logs.PageUp() - return m, nil - case tea.KeyPgDown, tea.KeyKpPgDown: - m.logs.PageDown() - m.follow = m.logs.AtBottom() - return m, nil - } - switch msg.String() { - case "q", "ctrl+c", "esc": - return m, tea.Quit - case "l": - m.showLogs = !m.showLogs - m.layout() - if m.showLogs { - return m, fetchLogs(name) - } - return m, nil - case "/": - m.filtering = true - return m, m.filter.Focus() - case "f": - m.follow = !m.follow - if m.follow { - m.logs.GotoBottom() - } - return m, nil - case "s": - if name != "" { - m.status = "stopping " + name + "…" - return m, func() tea.Msg { - if err := docker.Stop(name); err != nil { - return actionMsg("stop failed: " + err.Error()) - } - return actionMsg("stopped " + name) - } - } - case "x": - if name != "" { - if m.confirmRemove != name { - m.confirmRemove = name - m.status = "press x again to remove " + name - return m, nil - } - m.status = "removing " + name + "…" - return m, func() tea.Msg { - if err := docker.Remove(name); err != nil { - return actionMsg("remove failed: " + err.Error()) - } - return actionMsg("removed " + name) - } - } - case "R": - if name != "" { - m.status = "restarting " + name + "…" - return m, func() tea.Msg { - if err := docker.Restart(name); err != nil { - return actionMsg("restart failed: " + err.Error()) - } - return actionMsg("restarted " + name) - } - } - case "o": - if name != "" { - if port := m.selectedPort(); port != "" { - if err := platform.OpenURL("http://localhost:" + port); err != nil { - m.status = "open failed: " + err.Error() - } - } - } - case "c": - if name != "" { - if port := m.selectedPort(); port != "" { - url := "http://localhost:" + port - if err := platform.CopyText(url); err != nil { - m.status = "copy failed: " + err.Error() - } else { - m.status = "copied " + url - } - } - } - case "r": - return m, loadRows() - case "k": - m.table.MoveUp(1) - return m, fetchLogs(m.selected()) - case "j": - m.table.MoveDown(1) - return m, fetchLogs(m.selected()) - } - } - var cmd tea.Cmd - m.table, cmd = m.table.Update(msg) - return m, cmd -} - -func (m *dashModel) layout() { - available := m.height - 11 - if m.filtering || m.filter.Value() != "" { - available -= 2 - } - if !m.showLogs { - m.table.SetHeight(max(available, 5)) - return - } - tableH := min(12, max(available/2, 5)) - m.table.SetHeight(tableH) - logsH := available - tableH - if logsH < 3 { - logsH = 3 - } - m.logs.SetHeight(logsH) -} - -func (m dashModel) selectedDetailView() string { - row := m.table.SelectedRow() - if len(row) < 6 { - return "" - } - title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")).Render(row[0]) - meta := row[2] + " · " + row[1] - if row[3] != "" { - meta += " · port " + row[3] - } - if row[4] != "" { - meta += " · " + row[4] - } - if row[5] != "" { - meta += " · " + row[5] - } - return title + "\n" + helpStyle.Render(meta) -} - -func (m dashModel) View() tea.View { - var b strings.Builder - running := 0 - for _, row := range m.allRows { - if len(row) > 1 && strings.Contains(row[1], "running") { - running++ - } - } - b.WriteString(titleStyle.Render("🦞 homarr instances") + " " + helpStyle.Render(fmt.Sprintf("%d total · %d running · %d shown", len(m.allRows), running, len(m.table.Rows())))) - b.WriteString("\n\n") - if m.filtering || m.filter.Value() != "" { - b.WriteString(m.filter.View() + "\n\n") - } - if len(m.table.Rows()) == 0 { - message := "no homarr containers — start one with `homarr run ` or `homarr run --pr N`" - if len(m.allRows) > 0 { - message = "no instances match " + m.filter.Value() - } - b.WriteString(helpStyle.Render(" " + message + "\n")) - } else { - b.WriteString(m.table.View()) - b.WriteString("\n") - } - if detail := m.selectedDetailView(); detail != "" { - b.WriteString("\n" + detail + "\n") - } - if m.showLogs && len(m.table.Rows()) > 0 { - mode := "paused" - if m.follow { - mode = "following" - } - b.WriteString("\n" + logHeadSt.Render("── logs: "+m.selected()+" ("+mode+") ──") + "\n") - b.WriteString(m.logs.View()) - } - if m.status != "" { - statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42")) - lower := strings.ToLower(m.status) - if strings.Contains(lower, "fail") || strings.Contains(lower, "error") { - statusStyle = statusStyle.Foreground(lipgloss.Color("196")) - } else if strings.Contains(lower, "press x") { - statusStyle = statusStyle.Foreground(lipgloss.Color("214")) - } - b.WriteString("\n" + statusStyle.Render(m.status)) - } - b.WriteString("\n\n" + helpStyle.Render("/ filter · ↑/↓ select · l logs · f follow · pgup/pgdn scroll · s stop · R restart · x remove · o open · c copy URL · r refresh · d development · q quit")) - b.WriteString("\n" + helpStyle.Render(runningLegend+" · "+stoppedLegend)) - v := tea.NewView(b.String()) - v.AltScreen = true - v.WindowTitle = "🦞 Homarr instances" - return v -} - -func RunDashboard() error { - return runApp(true) -} diff --git a/tools/homarr-dev/internal/tui/dashboard_test.go b/tools/homarr-dev/internal/tui/dashboard_test.go deleted file mode 100644 index 4456dc117..000000000 --- a/tools/homarr-dev/internal/tui/dashboard_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package tui - -import ( - "strings" - "testing" - - "charm.land/bubbles/v2/table" - tea "charm.land/bubbletea/v2" -) - -func TestDashboardRendersRowsAndLogs(t *testing.T) { - m := newDashModel() - m.table.SetRows([]table.Row{{ - "homarr_pr_6441", - "running", - "homarr-test:pr-6441", - "7575", - "http://localhost:7575", - "Up 2 minutes", - }}) - m.logs.SetWidth(104) - m.logs.SetHeight(5) - m.logs.SetContent("server ready") - - got := m.View().Content - for _, want := range []string{"homarr_pr_6441", "server ready"} { - if !strings.Contains(got, want) { - t.Fatalf("dashboard omitted %q: %q", want, got) - } - } - if !m.View().AltScreen { - t.Fatal("dashboard must use the alternate screen") - } -} - -func TestDashboardArrowNavigation(t *testing.T) { - m := newDashModel() - m.table.SetRows([]table.Row{ - {"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})) - got := updated.(dashModel).selected() - if got != "homarr_second" { - t.Fatalf("down arrow selected %q, want homarr_second", got) - } -} - -func TestDashboardIgnoresStaleLogs(t *testing.T) { - m := newDashModel() - 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") - - updated, _ := m.Update(logsMsg{name: "homarr_old", content: "stale logs"}) - got := updated.(dashModel).View().Content - if strings.Contains(got, "stale logs") || !strings.Contains(got, "current logs") { - t.Fatalf("stale log result replaced current content: %q", got) - } -} diff --git a/tools/homarr-dev/internal/tui/data.go b/tools/homarr-dev/internal/tui/data.go new file mode 100644 index 000000000..6359fb341 --- /dev/null +++ b/tools/homarr-dev/internal/tui/data.go @@ -0,0 +1,155 @@ +package tui + +import ( + "context" + "sync" + "time" + + tea "charm.land/bubbletea/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/logs" + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/registry" + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/task" +) + +// loadedMsg carries a full refresh. Every source is fetched concurrently and +// each failure is reported separately, so a missing GitHub CLI degrades the +// pull-request list without hiding local images. +type loadedMsg struct { + generation int + prs []gh.PR + images []docker.Image + containers []docker.Container + volumes []docker.Volume + tags map[string]bool + prErr error + imageErr error + tagErr error +} + +// containersMsg is the cheap periodic refresh; it skips GitHub and the registry. +type containersMsg struct { + generation int + containers []docker.Container + volumes []docker.Volume +} + +type ( + tasksChangedMsg struct{} + logsChangedMsg struct{} + ciChecksMsg struct { + pr int + checks []gh.Check + err error + } + pollCIChecksMsg struct { + pr int + } + frameMsg time.Time + refreshMsg time.Time + statusMsg struct { + text string + level statusLevel + } +) + +const ( + loadTimeout = 45 * time.Second + refreshEvery = 3 * time.Second + frameEvery = 90 * time.Millisecond + prListingLimit = 60 +) + +// loadAll fetches every data source in parallel and reports them as one +// message, so the table is only rebuilt once per refresh. +func loadAll(generation int, includeBots, refresh bool) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), loadTimeout) + defer cancel() + + var ( + group sync.WaitGroup + message = loadedMsg{generation: generation} + ) + group.Add(3) + go func() { + defer group.Done() + if refresh { + message.prs, message.prErr = gh.RefreshPRs(ctx, prListingLimit, includeBots) + return + } + message.prs, message.prErr = gh.ListPRs(ctx, prListingLimit, includeBots) + }() + go func() { + defer group.Done() + message.images, message.imageErr = docker.ListLocalImages(ctx) + }() + go func() { + defer group.Done() + message.tags, message.tagErr = registry.PublishedTags(ctx, refresh) + }() + group.Wait() + + message.containers, _ = docker.List() + message.volumes, _ = docker.ListVolumes(ctx, true) + return message + } +} + +// loadContainers is the lightweight poll that keeps running state current +// without paying for GitHub, registry or disk-usage round trips. +func loadContainers(generation int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + containers, _ := docker.List() + volumes, _ := docker.ListVolumes(ctx, false) + return containersMsg{generation: generation, containers: containers, volumes: volumes} + } +} + +// waitForTasks parks a goroutine on the task manager's coalescing wake-up +// channel. Exactly one of these is in flight at a time; each wake-up re-arms +// itself, which is what lets background work drive redraws without polling. +func waitForTasks(manager *task.Manager) tea.Cmd { + return func() tea.Msg { + <-manager.Changed() + return tasksChangedMsg{} + } +} + +// waitForLogs mirrors waitForTasks for the live container log streams. +func waitForLogs(registry *logs.Registry) tea.Cmd { + return func() tea.Msg { + <-registry.Changed() + return logsChangedMsg{} + } +} + +func tickRefresh() tea.Cmd { + return tea.Tick(refreshEvery, func(now time.Time) tea.Msg { return refreshMsg(now) }) +} + +// tickFrame drives spinner and progress-bar animation. It only runs while +// something is actually moving, so an idle CLI costs no wake-ups at all. +func tickFrame() tea.Cmd { + return tea.Tick(frameEvery, func(now time.Time) tea.Msg { return frameMsg(now) }) +} + +func notify(text string, level statusLevel) tea.Cmd { + return func() tea.Msg { return statusMsg{text: text, level: level} } +} + +func fetchCIChecks(pr int, refresh bool) tea.Cmd { + if pr <= 0 { + return nil + } + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + checks, err := gh.GetPRChecks(ctx, pr, refresh) + return ciChecksMsg{pr: pr, checks: checks, err: err} + } +} diff --git a/tools/homarr-dev/internal/tui/dev.go b/tools/homarr-dev/internal/tui/dev.go deleted file mode 100644 index 6a41d9d9a..000000000 --- a/tools/homarr-dev/internal/tui/dev.go +++ /dev/null @@ -1,1158 +0,0 @@ -package tui - -import ( - "bufio" - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "time" - - "charm.land/bubbles/v2/progress" - "charm.land/bubbles/v2/spinner" - "charm.land/bubbles/v2/table" - "charm.land/bubbles/v2/textinput" - "charm.land/bubbles/v2/viewport" - tea "charm.land/bubbletea/v2" - "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/platform" - "github.com/homarr-labs/homarr/tools/homarr-dev/internal/run" -) - -const ( - ciOK = "✓" - ciFail = "✗" - ciPend = "●" - ciNone = "–" - imgYes = "✓" - imgNo = "…" - imgCheck = "?" - imgError = "!" - imgLocal = "◆" - runMark = "▶" - idleMark = " " -) - -var ( - legendCIYes = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render(ciOK) - legendCINo = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render(ciFail) - legendCIPend = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render(ciPend) - legendNone = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(ciNone) - legendImageYes = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render(imgYes) - legendImageNo = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(imgNo) - legendLocal = lipgloss.NewStyle().Foreground(lipgloss.Color("81")).Render(imgLocal) -) - -type prRow struct { - kind string - pr gh.PR - local docker.Image - imageState string - running bool - port string -} - -func (row prRow) hasCurrentLocalImage() bool { - return row.kind == "remote" && row.local.Tag != "" && row.pr.HeadSHA != "" && row.local.Revision == row.pr.HeadSHA -} - -type prsModel struct { - table table.Model - filter textinput.Model - filtering bool - pullView viewport.Model - pulling bool - pullLines []string - pullLayers map[string]string - pullProgress progress.Model - pullCanceled bool - pullEvents <-chan pullEvent - pullPlan *run.Plan - pullCancel context.CancelFunc - rebuilding bool - rebuildCancel context.CancelFunc - logs viewport.Model - showLogs bool - followLogs bool - logsName string - spinner spinner.Model - rows []prRow - loading bool - status string - includeBots bool - demo bool - width int - height int - imageQueue []int - imageActive int - imageGen int -} - -type prsLoadedMsg struct { - prs []gh.PR - images []docker.Image - prErr error - imgErr error - refresh bool -} -type imageCheckedMsg struct { - number int - exists bool - err error - gen int -} -type prActionMsg struct { - text string -} -type rebuildDoneMsg struct { - image docker.Image - err error - canceled bool - action string -} -type runningRefreshedMsg struct { - rows []prRow - generation int -} -type pullReadyMsg struct{} -type prLogTickMsg struct{} -type pullEvent struct { - line string - done bool - err error -} - -type outputTail struct { - data []byte -} - -func (tail *outputTail) Write(data []byte) (int, error) { - const limit = 32 * 1024 - tail.data = append(tail.data, data...) - if len(tail.data) > limit { - tail.data = tail.data[len(tail.data)-limit:] - } - return len(data), nil -} - -func (tail *outputTail) lastLine() string { - lines := strings.FieldsFunc(string(tail.data), func(char rune) bool { return char == '\n' || char == '\r' }) - if len(lines) == 0 { - return "" - } - return strings.TrimSpace(lines[len(lines)-1]) -} - -func splitDockerProgress(data []byte, atEOF bool) (advance int, token []byte, err error) { - for i, b := range data { - if b == '\n' || b == '\r' { - return i + 1, data[:i], nil - } - } - if atEOF && len(data) > 0 { - return len(data), data, nil - } - return 0, nil, nil -} - -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 { - return pullEvent{done: true, err: err} - } - cmd.Stdout = writer - cmd.Stderr = writer - if err := cmd.Start(); err != nil { - _ = reader.Close() - _ = writer.Close() - return pullEvent{done: true, err: err} - } - _ = writer.Close() - go func() { - defer close(events) - defer reader.Close() - scanner := bufio.NewScanner(reader) - scanner.Split(splitDockerProgress) - lastLine := "" - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line != "" { - lastLine = line - if ctx.Err() == nil && len(events) < cap(events)-1 { - events <- pullEvent{line: line} - } - } - } - err := cmd.Wait() - if err == nil { - err = scanner.Err() - } else { - err = commandErrorWithDetail(err, lastLine) - } - events <- pullEvent{done: true, err: err} - }() - return pullReadyMsg{} - } -} - -func commandErrorWithDetail(err error, lastLine string) error { - if lastLine == "" { - return err - } - return fmt.Errorf("%w: %s", err, lastLine) -} - -func waitPull(events <-chan pullEvent) tea.Cmd { - return func() tea.Msg { - event, ok := <-events - if !ok { - return nil - } - return event - } -} - -func prLogTick() tea.Cmd { - return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return prLogTickMsg{} }) -} - -func loadDev(includeBots, refresh bool) tea.Cmd { - return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - var prs []gh.PR - var prErr error - if refresh { - prs, prErr = gh.RefreshPRs(ctx, 50, includeBots) - } else { - prs, prErr = gh.ListPRs(ctx, 50, includeBots) - } - images, imgErr := docker.ListLocalImages(ctx) - return prsLoadedMsg{prs: prs, images: images, prErr: prErr, imgErr: imgErr, refresh: refresh} - } -} - -func checkImage(number, generation int) tea.Cmd { - return func() tea.Msg { - img := fmt.Sprintf("ghcr.io/homarr-labs/homarr-test:pr-%d", number) - exists, err := docker.CheckImage(img) - return imageCheckedMsg{number: number, exists: exists, err: err, gen: generation} - } -} - -func (m *prsModel) startImageChecks() tea.Cmd { - const concurrency = 6 - cmds := make([]tea.Cmd, 0, concurrency) - for m.imageActive < concurrency && len(m.imageQueue) > 0 { - number := m.imageQueue[0] - m.imageQueue = m.imageQueue[1:] - m.imageActive++ - cmds = append(cmds, checkImage(number, m.imageGen)) - } - return tea.Batch(cmds...) -} - -func ciIcon(state string) string { - switch state { - case "SUCCESS": - return ciOK - case "FAILURE": - return ciFail - case "PENDING": - return ciPend - default: - return ciNone - } -} - -func buildTableRows(rows []prRow) []table.Row { - out := make([]table.Row, 0, len(rows)) - for _, r := range rows { - mark := idleMark - port := "" - if r.running { - mark = runMark - port = r.port - } - img := imgCheck - switch r.imageState { - case "yes": - img = imgYes - case "no": - img = imgNo - case "error": - img = imgError - } - if r.hasCurrentLocalImage() { - img = imgLocal - } - source := "remote" - ref := fmt.Sprint(r.pr.Number) - title := r.pr.Title - author := r.pr.Author - ci := ciIcon(r.pr.CIState) - if r.kind == "local" { - source = "local" - ref = r.local.Tag - title = r.local.Reference() - author = "untracked build" - if r.local.Source != "" { - author = filepath.Base(r.local.Source) - } - ci = ciNone - } else if r.pr.IsDraft { - title = "DRAFT · " + title - } - if len([]rune(title)) > 44 { - title = string([]rune(title)[:43]) + "…" - } - out = append(out, table.Row{ - mark, - source, - ref, - title, - author, - ci, - img, - port, - }) - } - return out -} - -func developmentRows(prs []gh.PR, images []docker.Image, imageStates map[int]string) ([]prRow, []int) { - rows := make([]prRow, 0, len(prs)+len(images)) - prIndexes := make(map[int]int, len(prs)) - queue := make([]int, 0, len(prs)) - for _, pr := range prs { - imageState := imageStates[pr.Number] - prIndexes[pr.Number] = len(rows) - rows = append(rows, prRow{kind: "remote", pr: pr, imageState: imageState}) - } - for _, image := range images { - if index, exists := prIndexes[image.PRNumber]; exists && image.Revision != "" && image.Revision == rows[index].pr.HeadSHA { - if rows[index].local.Tag == "" { - rows[index].local = image - } - continue - } - rows = append(rows, prRow{kind: "local", local: image, imageState: "yes"}) - } - for index := range rows { - if rows[index].kind != "remote" || rows[index].imageState != "" { - continue - } - if rows[index].hasCurrentLocalImage() { - rows[index].imageState = "unchecked" - continue - } - rows[index].imageState = "checking" - queue = append(queue, rows[index].pr.Number) - } - return rows, queue -} - -func prColumns(width int) []table.Column { - authorWidth := 16 - portWidth := 6 - if width < 90 { - authorWidth = 0 - } - if width < 72 { - portWidth = 0 - } - titleWidth := max(min(width-60, 45), 18) - return []table.Column{ - {Title: "", Width: 2}, - {Title: "SOURCE", Width: 8}, - {Title: "REF", Width: 12}, - {Title: "TITLE", Width: titleWidth}, - {Title: "AUTHOR", Width: authorWidth}, - {Title: "CI", Width: 3}, - {Title: "IMAGE", Width: 5}, - {Title: "PORT", Width: portWidth}, - } -} - -func newPRsModel() prsModel { - t := table.New( - table.WithColumns(prColumns(110)), - table.WithFocused(true), - table.WithHeight(18), - table.WithWidth(110), - ) - s := table.DefaultStyles() - s.Header = s.Header.Bold(true) - s.Selected = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("57")) - t.SetStyles(s) - - sp := spinner.New() - sp.Spinner = spinner.Dot - filter := textinput.New() - filter.Prompt = "/ " - filter.Placeholder = "filter source, PR, image, title, author, branch, CI, port…" - filter.SetWidth(65) - filterStyles := textinput.DefaultDarkStyles() - filterStyles.Focused.Prompt = filterStyles.Focused.Prompt.Foreground(lipgloss.Color("212")).Bold(true) - filterStyles.Focused.Text = filterStyles.Focused.Text.Foreground(lipgloss.Color("229")) - filter.SetStyles(filterStyles) - pullView := viewport.New(viewport.WithWidth(110), viewport.WithHeight(7)) - pullView.SoftWrap = true - pullView.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("250")) - pullProgress := progress.New(progress.WithWidth(60), progress.WithColors(lipgloss.Color("212"))) - logs := viewport.New(viewport.WithWidth(110), viewport.WithHeight(7)) - logs.SoftWrap = true - logs.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("250")) - - return prsModel{table: t, filter: filter, pullView: pullView, pullLayers: make(map[string]string), pullProgress: pullProgress, logs: logs, followLogs: true, spinner: sp, loading: true, status: "loading development images…", width: 110, height: 32} -} - -func (m *prsModel) layout() { - reserved := 12 - if m.filtering || m.filter.Value() != "" { - reserved += 2 - } - if m.pulling { - reserved += 9 - } - if m.showLogs { - reserved += 9 - } - m.table.SetHeight(max(m.height-reserved, 6)) - m.pullView.SetWidth(max(m.width-2, 20)) - m.pullView.SetHeight(7) - m.pullProgress.SetWidth(max(min(m.width-12, 70), 20)) - m.logs.SetWidth(max(m.width-2, 20)) - m.logs.SetHeight(7) -} - -func (m *prsModel) applyFilter() { - rendered := buildTableRows(m.rows) - query := strings.ToLower(strings.TrimSpace(m.filter.Value())) - if query == "" { - m.table.SetRows(rendered) - return - } - rows := make([]table.Row, 0, len(rendered)) - for i, row := range m.rows { - terms := []string{ - row.kind, - fmt.Sprint(row.pr.Number), - fmt.Sprintf("#%d", row.pr.Number), - fmt.Sprintf("pr-%d", row.pr.Number), - row.pr.Title, - row.pr.Author, - row.pr.HeadRef, - row.pr.CIState, - row.imageState, - row.port, - row.local.Tag, - row.local.Source, - row.local.Revision, - } - if row.pr.IsDraft { - terms = append(terms, "draft") - } - if row.running { - terms = append(terms, "running started") - } - if strings.Contains(strings.ToLower(strings.Join(terms, " ")), query) { - rows = append(rows, rendered[i]) - } - } - m.table.SetRows(rows) -} - -func (m prsModel) selectedRow() *prRow { - selected := m.table.SelectedRow() - if len(selected) < 3 { - return nil - } - for i := range m.rows { - if m.rows[i].kind == selected[1] && ((selected[1] == "remote" && fmt.Sprint(m.rows[i].pr.Number) == selected[2]) || (selected[1] == "local" && m.rows[i].local.Tag == selected[2])) { - return &m.rows[i] - } - } - return nil -} - -func (m prsModel) selectedPR() *prRow { - row := m.selectedRow() - if row == nil || row.kind != "remote" { - return nil - } - return row -} - -func localBuildForRow(row prRow) (docker.Image, string, string) { - if row.kind == "remote" { - image := docker.Image{Tag: fmt.Sprintf("pr-%d", row.pr.Number), PRNumber: row.pr.Number} - return image, "built", fmt.Sprintf("building PR #%d locally as %s…", row.pr.Number, image.Reference()) - } - return row.local, "rebuilt", "rebuilding " + row.local.Reference() + "…" -} - -func localPRPlan(row prRow, demo bool, findPort func(int) int) (*run.Plan, error) { - plan, err := run.BuildPlan(run.Options{Context: context.Background(), Tag: row.local.Tag, Demo: demo, FindPort: findPort}) - if err != nil { - return nil, err - } - plan.Name = fmt.Sprintf("homarr_pr_%d", row.pr.Number) - plan.Volume = fmt.Sprintf("homarr_pr_%d_data", row.pr.Number) - plan.Label = fmt.Sprintf("PR #%d · local %s", row.pr.Number, truncateText(row.pr.HeadSHA, 12)) - plan.PRNumber = row.pr.Number - return plan, nil -} - -func (m *prsModel) refreshSelectedLogs() tea.Cmd { - row := m.selectedRow() - if row == nil || !row.running { - m.logsName = "" - m.logs.SetContent("selected image is not running") - return nil - } - name := fmt.Sprintf("homarr_pr_%d", row.pr.Number) - if row.kind == "local" { - name = "homarr_" + row.local.Tag - } - if name != m.logsName { - m.logsName = name - m.logs.SetContent("loading logs…") - } - return fetchLogs(name) -} - -func (m *prsModel) beginPull(row prRow) tea.Cmd { - 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 - } - if row.running && row.port != "" { - if port, err := strconv.Atoi(row.port); err == nil { - plan.HostPort = port - } - } - ctx, cancel := context.WithCancel(context.Background()) - events := make(chan pullEvent, 128) - m.pulling = true - m.pullPlan = plan - m.pullCancel = cancel - m.pullEvents = events - m.pullLines = nil - m.pullLayers = make(map[string]string) - m.pullCanceled = false - m.pullView.SetContent("") - m.showLogs = false - m.logsName = "" - if row.running { - m.status = fmt.Sprintf("pulling latest image and redeploying PR #%d…", row.pr.Number) - } else { - m.status = fmt.Sprintf("pulling latest image for PR #%d…", row.pr.Number) - } - m.layout() - return tea.Batch(startPull(ctx, docker.PullCommandContext(ctx, plan.Image, plan.Platform), events), m.spinner.Tick) -} - -func (m *prsModel) appendPullLine(line string) { - const retainedLines = 40 - m.pullLines = append(m.pullLines, line) - if len(m.pullLines) > retainedLines { - m.pullLines = m.pullLines[len(m.pullLines)-retainedLines:] - } - m.pullView.SetContent(strings.Join(m.pullLines, "\n")) - m.pullView.GotoBottom() - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 { - layer := strings.TrimSpace(parts[0]) - if len(layer) == 12 { - m.pullLayers[layer] = strings.TrimSpace(parts[1]) - } - } -} - -func (m prsModel) pullCompletion() (complete, total int, percent float64) { - for _, state := range m.pullLayers { - total++ - lower := strings.ToLower(state) - if strings.Contains(lower, "pull complete") || strings.Contains(lower, "already exists") { - complete++ - } - } - if total > 0 { - percent = float64(complete) / float64(total) - } - return complete, total, percent -} - -func (m prsModel) Init() tea.Cmd { - return tea.Batch(loadDev(m.includeBots, false), m.spinner.Tick) -} - -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 - } - return runningRefreshedMsg{rows: updated, generation: generation} - } -} - -func (m prsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m.table.SetColumns(prColumns(msg.Width)) - m.table.SetWidth(msg.Width) - m.filter.SetWidth(max(min(msg.Width-4, 75), 10)) - m.layout() - return m, nil - case prsLoadedMsg: - if msg.prErr != nil && msg.imgErr != nil { - m.loading = false - m.status = "failed to load development images: " + msg.prErr.Error() + "; " + msg.imgErr.Error() - return m, nil - } - m.loading = false - m.status = fmt.Sprintf("%d remote PRs · %d local images", len(msg.prs), len(msg.images)) - if msg.prErr != nil { - m.status = "remote PRs unavailable: " + msg.prErr.Error() - } else if msg.imgErr != nil { - m.status = "local images unavailable: " + msg.imgErr.Error() - } - imageStates := make(map[int]string, len(m.rows)) - if !msg.refresh || msg.prErr != nil { - for _, row := range m.rows { - if row.kind == "remote" && (row.imageState == "yes" || row.imageState == "no") { - imageStates[row.pr.Number] = row.imageState - } - } - } - m.rows, m.imageQueue = developmentRows(msg.prs, msg.images, imageStates) - m.imageGen++ - m.imageActive = 0 - m.applyFilter() - return m, tea.Batch(m.startImageChecks(), refreshRunning(m.rows, m.imageGen)) - case imageCheckedMsg: - if msg.gen != m.imageGen { - return m, nil - } - m.imageActive-- - for i, r := range m.rows { - if r.pr.Number == msg.number { - if msg.err != nil { - m.rows[i].imageState = "error" - m.status = msg.err.Error() - } else if msg.exists { - m.rows[i].imageState = "yes" - } else { - m.rows[i].imageState = "no" - } - } - } - m.applyFilter() - return m, m.startImageChecks() - case prActionMsg: - m.status = msg.text - 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 - action := msg.action - if action == "" { - action = "rebuilt" - } - if msg.canceled { - m.status = action + " canceled" - } else if msg.err != nil { - m.status = action + " failed: " + msg.err.Error() - } else { - m.status = action + " " + msg.image.Reference() - } - return m, tea.Batch(loadDev(m.includeBots, true), m.spinner.Tick) - case logsMsg: - if !m.showLogs || msg.name == "" || msg.name != m.logsName { - return m, nil - } - m.logs.SetContent(strings.TrimRight(msg.content, "\n")) - if m.followLogs { - m.logs.GotoBottom() - } - return m, nil - case prLogTickMsg: - if !m.showLogs { - return m, nil - } - return m, tea.Batch(m.refreshSelectedLogs(), prLogTick()) - case pullReadyMsg: - return m, waitPull(m.pullEvents) - case pullEvent: - if !msg.done { - m.appendPullLine(msg.line) - return m, waitPull(m.pullEvents) - } - m.pulling = false - m.pullEvents = nil - if m.pullCancel != nil { - m.pullCancel() - } - m.pullCancel = nil - m.layout() - if msg.err != nil { - if m.pullCanceled { - m.status = "pull canceled" - } else { - m.status = "pull failed: " + msg.err.Error() - } - m.pullCanceled = false - m.pullPlan = nil - return m, nil - } - 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" - } - } - m.applyFilter() - m.status = fmt.Sprintf("starting PR #%d…", plan.PRNumber) - return m, func() tea.Msg { - plan.Pull = false - if err := run.StartDetached(plan); err != nil { - return prActionMsg{"start failed: " + err.Error()} - } - return prActionMsg{fmt.Sprintf("started PR #%d → http://localhost:%d", plan.PRNumber, plan.HostPort)} - } - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - if m.loading || m.pulling || m.rebuilding { - return m, cmd - } - return m, nil - case tea.KeyPressMsg: - if m.rebuilding { - switch msg.String() { - case "q", "ctrl+c", "esc": - if m.rebuildCancel != nil { - m.rebuildCancel() - } - m.status = "canceling rebuild…" - return m, nil - } - } - if m.pulling { - switch msg.String() { - case "q", "ctrl+c", "esc": - if m.pullCancel != nil { - m.pullCanceled = true - m.pullCancel() - } - m.status = "canceling pull…" - } - return m, nil - } - if m.filtering { - switch msg.Key().Code { - case tea.KeyEscape: - m.filtering = false - m.filter.Reset() - m.filter.Blur() - m.applyFilter() - m.layout() - return m, nil - case tea.KeyEnter: - m.filtering = false - m.filter.Blur() - m.layout() - return m, nil - } - var cmd tea.Cmd - m.filter, cmd = m.filter.Update(msg) - m.applyFilter() - return m, cmd - } - switch msg.Key().Code { - case tea.KeyUp, tea.KeyKpUp: - m.table.MoveUp(1) - return m, m.refreshSelectedLogs() - case tea.KeyDown, tea.KeyKpDown: - m.table.MoveDown(1) - return m, m.refreshSelectedLogs() - case tea.KeyPgUp, tea.KeyKpPgUp: - if m.showLogs { - m.followLogs = false - m.logs.PageUp() - } - return m, nil - case tea.KeyPgDown, tea.KeyKpPgDown: - if m.showLogs { - m.logs.PageDown() - m.followLogs = m.logs.AtBottom() - } - return m, nil - } - switch msg.String() { - case "q", "ctrl+c", "esc": - return m, tea.Quit - case "enter", "space": - row := m.selectedRow() - if row == nil { - return m, nil - } - name := fmt.Sprintf("homarr_pr_%d", row.pr.Number) - label := fmt.Sprintf("PR #%d", row.pr.Number) - if row.kind == "local" { - name = "homarr_" + row.local.Tag - label = row.local.Reference() - } - if row.running { - return m, func() tea.Msg { - if err := docker.Stop(name); err != nil { - return prActionMsg{"stop failed: " + err.Error()} - } - return prActionMsg{"stopped " + label} - } - } - if row.kind == "remote" { - if row.hasCurrentLocalImage() { - plan, err := localPRPlan(*row, m.demo, nil) - if err != nil { - m.status = err.Error() - return m, nil - } - m.status = fmt.Sprintf("starting PR #%d from local build…", row.pr.Number) - return m, func() tea.Msg { - if err := run.StartDetached(plan); err != nil { - return prActionMsg{"start failed: " + err.Error()} - } - return prActionMsg{fmt.Sprintf("started PR #%d from local build → http://localhost:%d", row.pr.Number, plan.HostPort)} - } - } - if row.imageState == "checking" { - m.status = fmt.Sprintf("PR #%d image check is still in progress", row.pr.Number) - return m, nil - } - return m, m.beginPull(*row) - } - 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 - } - return m, func() tea.Msg { - if err := run.StartDetached(plan); err != nil { - return prActionMsg{"start failed: " + err.Error()} - } - return prActionMsg{fmt.Sprintf("started %s → http://localhost:%d", label, plan.HostPort)} - } - case "r": - m.loading = true - m.status = "refreshing…" - return m, tea.Batch(loadDev(m.includeBots, true), m.spinner.Tick) - case "/": - m.filtering = true - m.layout() - return m, m.filter.Focus() - case "o": - row := m.selectedPR() - if row != nil { - if err := platform.OpenURL(fmt.Sprintf("https://github.com/homarr-labs/homarr/pull/%d", row.pr.Number)); err != nil { - m.status = "open failed: " + err.Error() - } - } - case "a": - row := m.selectedRow() - if row != nil && row.running && row.port != "" { - if err := platform.OpenURL("http://localhost:" + row.port); err != nil { - m.status = "open failed: " + err.Error() - } - } else { - m.status = "selected image is not running" - } - case "l": - m.showLogs = !m.showLogs - m.layout() - if m.showLogs { - m.followLogs = true - return m, tea.Batch(m.refreshSelectedLogs(), prLogTick()) - } - return m, nil - case "f": - if m.showLogs { - m.followLogs = !m.followLogs - if m.followLogs { - m.logs.GotoBottom() - } - } else { - m.status = "open logs with l first" - } - case "p": - row := m.selectedPR() - if row != nil { - return m, m.beginPull(*row) - } else { - m.status = "pull is only available for remote PR images" - } - case "R": - if m.rebuilding { - m.status = "a local build is already running" - return m, nil - } - row := m.selectedRow() - if row == nil { - m.status = "select an image to build" - return m, nil - } - image, action, status := localBuildForRow(*row) - ctx, cancel := context.WithCancel(context.Background()) - m.rebuilding = true - m.rebuildCancel = cancel - m.status = status - rebuild := func() tea.Msg { - output := &outputTail{} - err := run.RebuildImageContext(ctx, image, output, output) - if err != nil { - err = commandErrorWithDetail(err, output.lastLine()) - } - return rebuildDoneMsg{image: image, err: err, canceled: ctx.Err() != nil, action: action} - } - return m, tea.Batch(rebuild, m.spinner.Tick) - case "m": - m.demo = !m.demo - if m.demo { - m.status = "demo mode enabled for the next launch" - } else { - m.status = "demo mode disabled" - } - case "k": - m.table.MoveUp(1) - return m, m.refreshSelectedLogs() - case "j": - m.table.MoveDown(1) - return m, m.refreshSelectedLogs() - case "b": - m.includeBots = !m.includeBots - m.loading = true - if m.includeBots { - m.status = "refreshing (bots shown)…" - } else { - m.status = "refreshing (bots hidden)…" - } - return m, tea.Batch(loadDev(m.includeBots, false), m.spinner.Tick) - } - } - var cmd tea.Cmd - m.table, cmd = m.table.Update(msg) - return m, cmd -} - -func coloredCI(state string) string { - switch state { - case "SUCCESS": - return lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("✓ success") - case "FAILURE": - return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("✗ failed") - case "PENDING": - return lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render("● pending") - default: - return lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("– no checks") - } -} - -func coloredImage(state string) string { - switch state { - case "yes": - return lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("✓ available") - case "no": - return lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("… not built") - case "error": - return lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render("! check failed") - default: - return lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Render("? checking") - } -} - -func (m prsModel) selectedDetailView() string { - row := m.selectedRow() - if row == nil { - return "" - } - if row.kind == "local" { - source := row.local.Source - if source == "" { - source = "unknown source · rebuild unavailable" - } - meta := "local · " + source - if row.local.Revision != "" { - meta += " · " + truncateText(row.local.Revision, 12) - } - if row.local.PRNumber > 0 { - meta += fmt.Sprintf(" · PR #%d", row.local.PRNumber) - } - if row.running { - meta += " · " + lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("running on :"+row.port) - } - title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")).Render(row.local.Reference()) - return title + "\n" + meta - } - branch := row.pr.HeadRef - if branch == "" { - branch = "unknown branch" - } - runSource := "run remote" - if row.hasCurrentLocalImage() { - runSource = "run local " + truncateText(row.local.Revision, 12) - } - meta := "@" + row.pr.Author + " · " + branch + " · CI " + coloredCI(row.pr.CIState) + " · " + runSource - if !row.hasCurrentLocalImage() { - meta += " · GHCR " + coloredImage(row.imageState) - } - if row.running { - meta += " · " + lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("running on :"+row.port) - } - title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")).Render(fmt.Sprintf("#%d %s", row.pr.Number, row.pr.Title)) - return title + "\n" + meta -} - -func truncateText(value string, length int) string { - runes := []rune(value) - if len(runes) <= length { - return value - } - return string(runes[:length]) -} - -func (m prsModel) View() tea.View { - var b strings.Builder - header := titleStyle.Render("🦞 homarr development") - if m.loading || m.rebuilding { - header += " " + m.spinner.View() - } - running := 0 - remote := 0 - local := 0 - for _, row := range m.rows { - if row.kind == "local" { - local++ - } else { - remote++ - if row.hasCurrentLocalImage() { - local++ - } - } - if row.running { - running++ - } - } - header += " " + helpStyle.Render(fmt.Sprintf("%d remote · %d local · %d running · %d shown", remote, local, running, len(m.table.Rows()))) - if m.demo { - header += " " + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render("DEMO") - } - b.WriteString(header + "\n\n") - if m.filtering || m.filter.Value() != "" { - b.WriteString(m.filter.View() + "\n\n") - } - if len(m.table.Rows()) > 0 { - b.WriteString(m.table.View() + "\n") - } else if len(m.rows) > 0 { - b.WriteString(helpStyle.Render("no development images match "+m.filter.Value()) + "\n") - } - if detail := m.selectedDetailView(); detail != "" { - b.WriteString("\n" + detail + "\n") - } - if m.pulling { - label := "Pulling image" - if m.pullPlan != nil { - label = fmt.Sprintf("Pulling PR #%d", m.pullPlan.PRNumber) - } - b.WriteString("\n" + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("214")).Render(m.spinner.View()+" "+label) + "\n") - complete, total, percent := m.pullCompletion() - if total > 0 { - b.WriteString(m.pullProgress.ViewAs(percent) + " " + helpStyle.Render(fmt.Sprintf("%d/%d layers", complete, total)) + "\n") - } - b.WriteString(m.pullView.View() + "\n") - b.WriteString(helpStyle.Render("q/esc cancel download") + "\n") - } - if m.showLogs { - mode := "paused" - if m.followLogs { - mode = "following" - } - name := m.logsName - if name == "" { - name = "selected image" - } - b.WriteString("\n" + logHeadSt.Render("── logs: "+name+" ("+mode+") ──") + "\n") - b.WriteString(m.logs.View() + "\n") - } - if m.status != "" { - statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42")) - lower := strings.ToLower(m.status) - if strings.Contains(lower, "fail") || strings.Contains(lower, "error") || strings.Contains(lower, "cannot") { - statusStyle = statusStyle.Foreground(lipgloss.Color("196")) - } else if m.pulling || m.rebuilding || strings.Contains(lower, "starting") || strings.Contains(lower, "refreshing") { - statusStyle = statusStyle.Foreground(lipgloss.Color("214")) - } - b.WriteString("\n" + statusStyle.Render(m.status)) - } - botState := "bots hidden" - if m.includeBots { - botState = "bots shown" - } - if !m.pulling { - b.WriteString("\n\n" + helpStyle.Render("/ filter · ↑/↓ select · enter start/stop · R build locally · p pull remote · m demo · o PR · a app")) - b.WriteString("\n" + helpStyle.Render("b "+botState+" · r refresh · d instances · q quit")) - b.WriteString("\n" + helpStyle.Render("l toggle logs · f follow · pgup/pgdn scroll")) - if m.rebuilding { - b.WriteString("\n" + helpStyle.Render("local build running in background · q/esc cancel build")) - } - } - b.WriteString("\n" + helpStyle.Render("SOURCE: local Docker / remote GHCR · CI: "+legendCIYes+" pass "+legendCINo+" fail "+legendCIPend+" pending "+legendNone+" none · IMAGE: "+legendLocal+" local "+legendImageYes+" remote "+legendImageNo+" not built")) - v := tea.NewView(b.String()) - v.AltScreen = true - v.WindowTitle = "🦞 Homarr Development" - return v -} - -func RunDev() error { - return runApp(false) -} diff --git a/tools/homarr-dev/internal/tui/dev_test.go b/tools/homarr-dev/internal/tui/dev_test.go deleted file mode 100644 index ecd8d85bf..000000000 --- a/tools/homarr-dev/internal/tui/dev_test.go +++ /dev/null @@ -1,228 +0,0 @@ -package tui - -import ( - "errors" - "strings" - "testing" - - tea "charm.land/bubbletea/v2" - - "github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker" - "github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh" -) - -func TestPRTableRendersRows(t *testing.T) { - m := newPRsModel() - m.rows = []prRow{{ - kind: "remote", - pr: gh.PR{Number: 6441, Title: "Human PR", Author: "ajnart", CIState: "SUCCESS"}, - imageState: "yes", - }} - m.table.SetRows(buildTableRows(m.rows)) - - if got := m.View().Content; !strings.Contains(got, "Human PR") { - t.Fatalf("table view omitted rows: %q", got) - } - if !m.View().AltScreen { - t.Fatal("development browser must use the alternate screen") - } -} - -func TestPRTableArrowNavigation(t *testing.T) { - m := newPRsModel() - m.rows = []prRow{ - {kind: "remote", pr: gh.PR{Number: 1, Title: "First"}}, - {kind: "remote", pr: gh.PR{Number: 2, Title: "Second"}}, - } - m.table.SetRows(buildTableRows(m.rows)) - - updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown})) - got := updated.(prsModel).table.SelectedRow() - if len(got) < 3 || got[2] != "2" { - t.Fatalf("down arrow selected %v, want PR 2", got) - } -} - -func TestPRImageChecksAreBounded(t *testing.T) { - m := newPRsModel() - prs := make([]gh.PR, 20) - for i := range prs { - prs[i] = gh.PR{Number: i + 1, Title: "PR"} - } - - updated, cmd := m.Update(prsLoadedMsg{prs: prs}) - got := updated.(prsModel) - if cmd == nil { - t.Fatal("expected image check commands") - } - if got.imageActive != 6 || len(got.imageQueue) != 14 { - t.Fatalf("active=%d queued=%d, want active=6 queued=14", got.imageActive, len(got.imageQueue)) - } -} - -func TestPRLoadedReusesImageChecksUnlessRefreshIsForced(t *testing.T) { - m := newPRsModel() - m.rows = []prRow{{kind: "remote", pr: gh.PR{Number: 1}, imageState: "yes"}} - - updated, _ := m.Update(prsLoadedMsg{prs: []gh.PR{{Number: 1}, {Number: 2}}}) - cached := updated.(prsModel) - if cached.rows[0].imageState != "yes" || len(cached.imageQueue) != 0 || cached.imageActive != 1 { - t.Fatalf("cached states were not reused: rows=%#v queue=%v active=%d", cached.rows, cached.imageQueue, cached.imageActive) - } - - updated, _ = cached.Update(prsLoadedMsg{prs: []gh.PR{{Number: 1}, {Number: 2}}, refresh: true}) - refreshed := updated.(prsModel) - if refreshed.rows[0].imageState != "checking" || refreshed.imageActive != 2 { - t.Fatalf("forced refresh reused image states: rows=%#v active=%d", refreshed.rows, refreshed.imageActive) - } -} - -func TestDevelopmentRowsFoldCurrentLocalPRImage(t *testing.T) { - prs := []gh.PR{{Number: 6502, HeadSHA: "current"}} - images := []docker.Image{ - {Tag: "pr-6502", PRNumber: 6502, Revision: "current"}, - {Tag: "pr-6502-stale", PRNumber: 6502, Revision: "stale"}, - } - - rows, queue := developmentRows(prs, images, nil) - if len(rows) != 2 || !rows[0].hasCurrentLocalImage() || rows[0].local.Tag != "pr-6502" { - t.Fatalf("current image was not folded into PR row: %#v", rows) - } - if rows[1].kind != "local" || rows[1].local.Tag != "pr-6502-stale" { - t.Fatalf("stale image should remain separate: %#v", rows[1]) - } - if got := buildTableRows(rows)[0][6]; got != imgLocal { - t.Fatalf("image marker = %q, want %q", got, imgLocal) - } - if len(queue) != 0 { - t.Fatalf("current local image queued unnecessary registry checks: %v", queue) - } -} - -func TestLocalPRPlanUsesPRContainerIdentity(t *testing.T) { - row := prRow{ - kind: "remote", - pr: gh.PR{Number: 6502, HeadSHA: "abcdef1234567890"}, - local: docker.Image{Tag: "pr-6502", PRNumber: 6502, Revision: "abcdef1234567890"}, - } - plan, err := localPRPlan(row, false, func(int) int { return 7575 }) - if err != nil { - t.Fatal(err) - } - if plan.Image != "homarr:pr-6502" || plan.Name != "homarr_pr_6502" || plan.Volume != "homarr_pr_6502_data" || plan.Pull || plan.PRNumber != 6502 { - t.Fatalf("unexpected local PR plan: %#v", plan) - } -} - -func TestRemotePRBuildUsesTemporaryPRWorkflow(t *testing.T) { - image, action, status := localBuildForRow(prRow{kind: "remote", pr: gh.PR{Number: 6441}}) - if image.Tag != "pr-6441" || image.PRNumber != 6441 || action != "built" || !strings.Contains(status, "building PR #6441 locally") { - t.Fatalf("image=%#v action=%q status=%q", image, action, status) - } -} - -func TestPRImageCheckInProgressDoesNotLaunch(t *testing.T) { - m := newPRsModel() - m.rows = []prRow{{kind: "remote", pr: gh.PR{Number: 6441, Title: "Pending image"}, imageState: "checking"}} - m.table.SetRows(buildTableRows(m.rows)) - - updated, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) - got := updated.(prsModel) - if cmd != nil { - t.Fatal("unavailable image returned a launch command") - } - if !strings.Contains(got.status, "still in progress") { - t.Fatalf("status = %q, want launch rejection", got.status) - } -} - -func TestPullErrorIncludesLastDockerMessage(t *testing.T) { - err := commandErrorWithDetail(errors.New("exit status 1"), "manifest unknown") - if !strings.Contains(err.Error(), "manifest unknown") { - t.Fatalf("pull error = %v", err) - } -} - -func TestOutputTailRetainsBuildFailure(t *testing.T) { - tail := &outputTail{} - _, _ = tail.Write([]byte("building\r\nERROR: Dockerfile not found\n")) - if got := tail.lastLine(); got != "ERROR: Dockerfile not found" { - t.Fatalf("last line = %q", got) - } -} - -func TestDevTableRendersLocalImageSource(t *testing.T) { - m := newPRsModel() - m.rows = []prRow{{ - kind: "local", - local: docker.Image{Tag: "feature", Source: "/tmp/homarr", Revision: "abcdef1234567890"}, - imageState: "yes", - }} - m.table.SetRows(buildTableRows(m.rows)) - - content := m.View().Content - for _, want := range []string{"local", "homarr:feature", "/tmp/homarr", "abcdef123456"} { - if !strings.Contains(content, want) { - t.Fatalf("development view omitted %q: %q", want, content) - } - } -} - -func TestRebuildCompletionRestoresLoadingState(t *testing.T) { - m := newPRsModel() - m.rebuilding = true - image := docker.Image{Tag: "feature"} - - updated, cmd := m.Update(rebuildDoneMsg{image: image}) - got := updated.(prsModel) - if got.rebuilding || !got.loading || got.status != "rebuilt homarr:feature" || cmd == nil { - t.Fatalf("unexpected rebuild state: rebuilding=%v loading=%v status=%q", got.rebuilding, got.loading, got.status) - } -} - -func TestRebuildCanBeCanceled(t *testing.T) { - m := newPRsModel() - canceled := false - m.rebuilding = true - m.rebuildCancel = func() { canceled = true } - - updated, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: 'q'})) - got := updated.(prsModel) - if !canceled || cmd != nil || got.status != "canceling rebuild…" { - t.Fatalf("cancel state: canceled=%v status=%q", canceled, got.status) - } -} - -func TestNavigationRemainsAvailableDuringBuild(t *testing.T) { - m := newPRsModel() - m.rebuilding = true - m.rows = []prRow{ - {kind: "remote", pr: gh.PR{Number: 1}}, - {kind: "remote", pr: gh.PR{Number: 2}}, - } - m.table.SetRows(buildTableRows(m.rows)) - - updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown})) - selected := updated.(prsModel).table.SelectedRow() - if len(selected) < 3 || selected[2] != "2" { - t.Fatalf("build blocked navigation: %v", selected) - } -} - -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) - } -} diff --git a/tools/homarr-dev/internal/tui/harness_test.go b/tools/homarr-dev/internal/tui/harness_test.go new file mode 100644 index 000000000..bd40c47ba --- /dev/null +++ b/tools/homarr-dev/internal/tui/harness_test.go @@ -0,0 +1,270 @@ +package tui + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/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/task" +) + +func sample() (Model, tea.Cmd) { + m := New(false) + updated, _ := m.Update(tea.WindowSizeMsg{Width: 140, Height: 40}) + m = updated.(Model) + loaded := loadedMsg{ + prs: []gh.PR{ + {Number: 6646, Title: "feat: rework the developer CLI from the ground up", Author: "ajnart", HeadRef: "excited-pelican", HeadSHA: "abc123", CIState: "SUCCESS"}, + {Number: 6612, Title: "fix(board): keep the item modal aligned", Author: "meierschlurf", HeadRef: "fix/modal", HeadSHA: "def456", CIState: "PENDING"}, + {Number: 6503, Title: "chore(deps): bump everything", Author: "renovate[bot]", HeadRef: "renovate/all", HeadSHA: "ghi789", CIState: "FAILURE", IsDraft: true}, + }, + images: []docker.Image{ + {ID: "1", Tag: "pr-6646", PRNumber: 6646, Revision: "abc123", Size: "492MB", Created: "33 minutes ago", Source: "https://github.com/homarr-labs/homarr/pull/6646"}, + {ID: "2", Tag: "dev", Revision: "zzz999", Size: "481MB", Created: "2 days ago", Source: "/Users/ajnart/homarr"}, + }, + containers: []docker.Container{ + {ID: "c1", Name: "homarr_pr_6612", Image: "ghcr.io/homarr-labs/homarr-test:pr-6612", State: "running", Status: "Up 45 hours (healthy)", Ports: "0.0.0.0:7576->7575/tcp"}, + {ID: "c2", Name: "homarr_dev", Image: "homarr:dev", State: "exited", Status: "Exited (0) 3 weeks ago"}, + }, + volumes: []docker.Volume{{Name: "homarr_pr_6612_data", Size: "6.8MB"}, {Name: "homarr_dev_data", Size: "12MB"}}, + tags: map[string]bool{"pr-6612": true, "pr-6503": true}, + } + next, cmd := m.Update(loaded) + return next.(Model), cmd +} + +func TestRenderDev(t *testing.T) { + m, _ := sample() + fmt.Println("=========== DEV ===========") + fmt.Println(m.View().Content) +} + +func TestRenderInstances(t *testing.T) { + m, _ := sample() + next, _ := m.switchScreen(screenInstances) + fmt.Println("=========== INSTANCES ===========") + fmt.Println(next.View().Content) +} + +func TestDebugSelection(t *testing.T) { + m, _ := sample() + t.Logf("devFiltered=%d cursor=%d rows=%d", len(m.devFiltered), m.devTable.Cursor(), len(m.devTable.Rows())) + row, found := m.selectedDev() + t.Logf("selectedDev found=%v key=%q", found, row.key()) + t.Logf("detailVisible=%v", m.detailVisible()) + t.Logf("detail=%q", m.detail()) + t.Logf("bodyHeight=%d sidebarCols=%d height=%d", m.bodyHeight, m.sidebarCols, m.height) +} + +func fakePullTask(m Model) Model { + m.tasks.Start(task.KindPull, "pull and start PR #6612", "homarr_pr_6612", func(ctx context.Context, report *task.Reporter) error { + report.SetDetail("4/6 layers · 128.4MB/492.1MB · 12.4MB/s") + report.SetPercent(0.58) + report.SetSteps([]task.Step{ + {ID: "a1b2c3d4e5f6", Label: "a1b2c3d4e5f6", Status: "Pull complete", Percent: 1, Done: true, Note: "48.2MB"}, + {ID: "b2c3d4e5f6a7", Label: "b2c3d4e5f6a7", Status: "Already exists", Percent: 1, Done: true, Note: "12.1MB"}, + {ID: "c3d4e5f6a7b8", Label: "c3d4e5f6a7b8", Status: "Extracting", Percent: 0.72, Note: "88.4MB/122.0MB"}, + {ID: "d4e5f6a7b8c9", Label: "d4e5f6a7b8c9", Status: "Downloading", Percent: 0.21, Note: "18.2MB/86.5MB"}, + {ID: "e5f6a7b8c9d0", Label: "e5f6a7b8c9d0", Status: "Waiting", Percent: 0}, + }) + <-ctx.Done() + return ctx.Err() + }) + time.Sleep(60 * time.Millisecond) + next, _ := m.applyTasks() + next.focusTask = 1 + next.sidebar.source = sourceTask + next.relayout() + next.refreshSidebar() + return next +} + +func TestRenderPullProgress(t *testing.T) { + m, _ := sample() + m = fakePullTask(m) + fmt.Println("=========== PULL ===========") + fmt.Println(m.View().Content) + m.tasks.CancelAll() +} + +func TestRenderManageAndConfirm(t *testing.T) { + m, _ := sample() + updated, _ := m.handleKey(tea.KeyPressMsg{Code: 'm', Text: "m"}) + manage := updated.(Model) + fmt.Println("=========== MANAGE ===========") + fmt.Println(manage.View().Content) + + updated, _ = manage.handleKey(tea.KeyPressMsg{Code: 'd', Text: "d"}) + fmt.Println("=========== CONFIRM ===========") + fmt.Println(updated.(Model).View().Content) +} + +func TestRenderTaskOverlayAndHelp(t *testing.T) { + m, _ := sample() + m = fakePullTask(m) + updated, _ := m.handleKey(tea.KeyPressMsg{Code: 't', Text: "t"}) + fmt.Println("=========== TASKS ===========") + fmt.Println(updated.(Model).View().Content) + m.tasks.CancelAll() + + m2, _ := sample() + updated, _ = m2.handleKey(tea.KeyPressMsg{Code: '?', Text: "?"}) + fmt.Println("=========== HELP ===========") + fmt.Println(updated.(Model).View().Content) +} + +func TestRenderNarrow(t *testing.T) { + m, _ := sample() + updated, _ := m.Update(tea.WindowSizeMsg{Width: 84, Height: 30}) + fmt.Println("=========== NARROW (stacked sidebar) ===========") + fmt.Println(updated.(Model).View().Content) +} + +func TestSidebarTaskAutoSwitchToLogsOnSuccess(t *testing.T) { + m, _ := sample() + started := m.tasks.Start(task.KindPull, "pull PR #6612", "homarr_pr_6612", func(ctx context.Context, report *task.Reporter) error { + return nil + }) + next, _ := m.focusOn(started.ID(), "pull PR #6612") + if next.sidebar.source != sourceTask { + t.Fatalf("expected sidebar source to be sourceTask while task is focused, got %v", next.sidebar.source) + } + + time.Sleep(50 * time.Millisecond) + next, _ = next.applyTasks() + + if next.sidebar.source != sourceLogs { + t.Fatalf("expected sidebar source to auto-switch to sourceLogs on task success, got %v", next.sidebar.source) + } + if next.focusTask != 0 { + t.Fatalf("expected focusTask to reset to 0 on task success, got %d", next.focusTask) + } +} + +func TestSidebarTaskAutoSwitchToLogsOnCancel(t *testing.T) { + m, _ := sample() + started := m.tasks.Start(task.KindBuild, "build homarr:dev", "homarr:dev", func(ctx context.Context, report *task.Reporter) error { + <-ctx.Done() + return ctx.Err() + }) + next, _ := m.focusOn(started.ID(), "build homarr:dev") + if next.sidebar.source != sourceTask { + t.Fatalf("expected sidebar source to be sourceTask, got %v", next.sidebar.source) + } + + m.tasks.Cancel(started.ID()) + time.Sleep(50 * time.Millisecond) + next, _ = next.applyTasks() + + if next.sidebar.source != sourceLogs { + t.Fatalf("expected sidebar source to auto-switch to sourceLogs on task cancel, got %v", next.sidebar.source) + } +} + +func TestInstanceScreenActionsAndManageChords(t *testing.T) { + m, _ := sample() + next, _ := m.switchScreen(screenInstances) + if next.screen != screenInstances { + t.Fatalf("expected screenInstances, got %v", next.screen) + } + + // Test manage mode entry + updated, _ := next.handleKey(tea.KeyPressMsg{Code: 'm', Text: "m"}) + manage := updated.(Model) + if manage.mode != modeManage { + t.Fatalf("expected modeManage, got %v", manage.mode) + } + + // Press Esc to return to normal mode + updated, _ = manage.handleKey(tea.KeyPressMsg{Code: tea.KeyEscape}) + normal := updated.(Model) + if normal.mode != modeNormal { + t.Fatalf("expected modeNormal after Esc, got %v", normal.mode) + } +} + +func TestSidebarRelevanceOnNavigationAndPinning(t *testing.T) { + m, _ := sample() + + // Row 0 is PR 6646 (not running, no container) + if m.sidebarRelevant() { + t.Fatalf("expected sidebar to NOT be relevant for row without container") + } + + // Move down to Row 1 (PR 6612, which is running) + moved, _ := m.moveSelection(1) + if !moved.sidebarRelevant() { + t.Fatalf("expected sidebar to BE relevant for running container") + } + if moved.logTarget() != "homarr_pr_6612" { + t.Fatalf("expected logTarget=homarr_pr_6612, got %q", moved.logTarget()) + } + + // Move down to Row 2 (PR 6503, no container) + moved2, _ := moved.moveSelection(1) + if moved2.sidebarRelevant() { + t.Fatalf("expected sidebar to NOT be relevant for row without container") + } + + // Pin while on Row 1 + pinned, _ := moved.togglePin() + if pinned.sidebar.pinned != "homarr_pr_6612" { + t.Fatalf("expected pinned container to be homarr_pr_6612, got %q", pinned.sidebar.pinned) + } + + // Move to Row 2 while pinned -> sidebar remains relevant! + movedPinned, _ := pinned.moveSelection(1) + if !movedPinned.sidebarRelevant() { + t.Fatalf("expected sidebar to remain relevant when a valid container is pinned") + } +} + +func TestSidebarTabCycleAndCIRendering(t *testing.T) { + m, _ := sample() + + // Initial source is sourceLogs + if m.sidebar.source != sourceLogs { + t.Fatalf("expected sourceLogs, got %v", m.sidebar.source) + } + + // Press Tab (no active tasks) -> cycles to sourceCI + tabbed, _ := m.handleKey(tea.KeyPressMsg{Code: tea.KeyTab}) + mTabbed := tabbed.(Model) + if mTabbed.sidebar.source != sourceCI { + t.Fatalf("expected sourceCI after Tab, got %v", mTabbed.sidebar.source) + } + if !mTabbed.sidebarRelevant() { + t.Fatalf("expected sidebar to be relevant on PR row when in sourceCI mode") + } + + // Supply mock CI checks for PR 6646 + updated, _ := mTabbed.Update(ciChecksMsg{ + pr: 6646, + checks: []gh.Check{ + {Name: "Fast gate", Bucket: "pass", State: "SUCCESS", StartedAt: "2026-08-21T10:00:00Z", CompletedAt: "2026-08-21T10:12:00Z"}, + {Name: "Build amd64", Bucket: "pass", State: "SUCCESS", StartedAt: "2026-08-21T10:12:00Z", CompletedAt: "2026-08-21T10:17:00Z"}, + {Name: "E2E tests", Bucket: "pending", State: "PENDING", StartedAt: "2026-08-21T10:17:00Z"}, + {Name: "publish", Bucket: "skipping", State: "SKIPPED"}, + }, + }) + mCI := updated.(Model) + if mCI.sidebar.title != "ci · PR #6646" { + t.Fatalf("unexpected title: %q", mCI.sidebar.title) + } + if !strings.Contains(mCI.sidebar.subtitle, "2 pass") || !strings.Contains(mCI.sidebar.subtitle, "1 pending") || !strings.Contains(mCI.sidebar.subtitle, "1 skipped") { + t.Fatalf("unexpected subtitle: %q", mCI.sidebar.subtitle) + } + + // Press Tab again -> cycles back to sourceLogs + tabbedAgain, _ := mCI.handleKey(tea.KeyPressMsg{Code: tea.KeyTab}) + mLogs := tabbedAgain.(Model) + if mLogs.sidebar.source != sourceLogs { + t.Fatalf("expected sourceLogs after Tab, got %v", mLogs.sidebar.source) + } +} diff --git a/tools/homarr-dev/internal/tui/keys.go b/tools/homarr-dev/internal/tui/keys.go new file mode 100644 index 000000000..61f91f8f5 --- /dev/null +++ b/tools/homarr-dev/internal/tui/keys.go @@ -0,0 +1,415 @@ +package tui + +import ( + "fmt" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/table" + tea "charm.land/bubbletea/v2" + + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/task" +) + +// handleKey routes by mode. Modes are what let one letter mean several things +// without ambiguity: `d` switches screens normally and deletes data once manage +// mode is armed, and neither reading can leak into the other. +func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch m.mode { + case modeFilter: + return m.handleFilterKey(msg) + case modeConfirm: + return m.handleConfirmKey(msg) + case modeImageSelect: + return m.handleImageSelectKey(msg) + case modeManage: + return m.handleManageKey(msg) + case modeHelp: + return m.handleOverlayKey(msg) + case modeTasks: + return m.handleTasksKey(msg) + default: + return m.handleNormalKey(msg) + } +} + +func (m Model) handleImageSelectKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Cancel): + m.mode = modeNormal + m.imageSelection = imageSelection{} + m.status, m.statusLevel = "launch canceled", levelInfo + m.relayout() + return m, nil + + case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.Down): + if m.imageSelection.choice == imageChoiceLocal { + m.imageSelection.choice = imageChoiceRemote + } else { + m.imageSelection.choice = imageChoiceLocal + } + return m, nil + + case key.Matches(msg, m.keys.Enter): + selection := m.imageSelection + row, found := m.selectedDev() + m.mode = modeNormal + m.imageSelection = imageSelection{} + if !found || row.key() != selection.rowKey { + m.status, m.statusLevel = "image selection expired; choose a row again", levelWarn + m.relayout() + return m, nil + } + m.relayout() + if selection.choice == imageChoiceRemote { + return m.deployRemotePR(row) + } + return m.startLocalPR(row) + } + return m, nil +} + +func (m Model) handleNormalKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Quit): + m.tasks.CancelAll() + m.logs.Close() + return m, tea.Quit + + case key.Matches(msg, m.keys.Help): + m.mode = modeHelp + m.relayout() + return m, nil + + case key.Matches(msg, m.keys.Tasks): + m.mode = modeTasks + m.taskList = m.tasks.Snapshots() + m.taskCursor = max(len(m.taskList)-1, 0) + m.relayout() + return m, nil + + case key.Matches(msg, m.keys.Manage): + subject, found := m.currentTarget() + if !found { + m.status, m.statusLevel = "nothing selected to manage", levelWarn + return m, nil + } + m.mode = modeManage + m.status, m.statusLevel = "manage "+subject.label, levelInfo + m.relayout() + return m, nil + + case key.Matches(msg, m.keys.Filter): + m.mode = modeFilter + m.relayout() + return m, m.filter.Focus() + + // A submitted filter keeps narrowing the table after the input loses focus, + // so escape has to be able to clear it from normal mode too. + case key.Matches(msg, m.keys.Cancel): + if m.filter.Value() == "" { + return m, nil + } + m.filter.Reset() + m.applyFilter() + m.relayout() + cmd := m.refreshSidebar() + m.status, m.statusLevel = "filter cleared", levelInfo + return m, cmd + + case key.Matches(msg, m.keys.Refresh): + m.generation++ + m.loading = true + m.status, m.statusLevel = "refreshing…", levelInfo + clocks := m.animate() + return m, tea.Batch(loadAll(m.generation, m.includeBots, true), clocks) + + case key.Matches(msg, m.keys.Dev): + return m.switchScreen(screenDev) + case key.Matches(msg, m.keys.Instances): + return m.switchScreen(screenInstances) + case key.Matches(msg, m.keys.Cycle): + if m.screen == screenDev { + return m.switchScreen(screenInstances) + } + return m.switchScreen(screenDev) + + case key.Matches(msg, m.keys.Sidebar): + m.sidebar.visible = !m.sidebar.visible + m.relayout() + cmd := m.refreshSidebar() + return m, cmd + + case key.Matches(msg, m.keys.SidebarTab): + switch m.sidebar.source { + case sourceLogs: + if m.tasks.Running() > 0 || len(m.taskList) > 0 { + m.sidebar.source = sourceTask + } else { + m.sidebar.source = sourceCI + } + case sourceTask: + m.sidebar.source = sourceCI + case sourceCI: + m.sidebar.source = sourceLogs + default: + m.sidebar.source = sourceLogs + } + m.sidebar.visible = true + m.relayout() + cmd := m.refreshSidebar() + return m, cmd + + case key.Matches(msg, m.keys.Pin): + return m.togglePin() + + case key.Matches(msg, m.keys.Follow): + m.sidebar.toggleFollow() + return m, nil + + case key.Matches(msg, m.keys.PageUp): + m.sidebar.pageUp() + return m, nil + case key.Matches(msg, m.keys.PageDown): + m.sidebar.pageDown() + return m, nil + + case key.Matches(msg, m.keys.Enter): + return m.primaryAction() + + case key.Matches(msg, m.keys.Build): + subject, found := m.currentTarget() + if !found { + return m, nil + } + return m.buildLocally(subject) + + case key.Matches(msg, m.keys.Up): + return m.moveSelection(-1) + case key.Matches(msg, m.keys.Down): + return m.moveSelection(1) + case key.Matches(msg, m.keys.Top): + m.activeTable().GotoTop() + if m.tasks.Running() == 0 && m.sidebar.source == sourceTask && m.focusTask == 0 { + m.sidebar.source = sourceLogs + } + m.relayout() + cmd := m.refreshSidebar() + return m, cmd + case key.Matches(msg, m.keys.Bottom): + m.activeTable().GotoBottom() + if m.tasks.Running() == 0 && m.sidebar.source == sourceTask && m.focusTask == 0 { + m.sidebar.source = sourceLogs + } + m.relayout() + cmd := m.refreshSidebar() + return m, cmd + + case key.Matches(msg, m.keys.OpenApp): + return m.openApp() + } + + if m.screen == screenDev { + return m.handleDevKey(msg) + } + return m.handleInstanceKey(msg) +} + +func (m Model) handleDevKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Pull): + row, found := m.selectedDev() + if !found { + return m, nil + } + return m.pullRemote(row) + + case key.Matches(msg, m.keys.Demo): + m.demo = !m.demo + if m.demo { + m.status, m.statusLevel = "demo mode on — the next launch mocks integrations", levelWarn + return m, nil + } + m.status, m.statusLevel = "demo mode off", levelInfo + return m, nil + + case key.Matches(msg, m.keys.Bots): + m.includeBots = !m.includeBots + m.generation++ + m.loading = true + if m.includeBots { + m.status, m.statusLevel = "including bot pull requests…", levelInfo + } else { + m.status, m.statusLevel = "hiding bot pull requests…", levelInfo + } + clocks := m.animate() + return m, tea.Batch(loadAll(m.generation, m.includeBots, false), clocks) + + case key.Matches(msg, m.keys.OpenPR): + return m.openPullRequest() + } + return m, nil +} + +func (m Model) handleInstanceKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + row, found := m.selectedInstance() + if !found { + return m, nil + } + switch { + case key.Matches(msg, m.keys.Stop): + return m.stopContainer(row.container.Name) + case key.Matches(msg, m.keys.Restart): + return m.restartContainer(row.container.Name) + case key.Matches(msg, m.keys.Remove): + return m.arm(actionRemoveContainer, m.instanceTarget(row)) + case key.Matches(msg, m.keys.Copy): + return m.copyURL() + case key.Matches(msg, m.keys.OpenPR): + if row.container.Running() && row.container.HostPort() != "" { + return m.openApp() + } + if number := row.prNumber(); number > 0 { + return m.openPullRequestNumber(number) + } + return m.openApp() + } + return m, nil +} + +func (m Model) handleFilterKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.Key().Code { + case tea.KeyEscape: + m.mode = modeNormal + m.filter.Reset() + m.filter.Blur() + m.relayout() + cmd := m.refreshSidebar() + return m, cmd + case tea.KeyEnter: + m.mode = modeNormal + m.filter.Blur() + m.relayout() + return m, nil + } + var command tea.Cmd + m.filter, command = m.filter.Update(msg) + m.applyFilter() + cmd := m.refreshSidebar() + return m, tea.Batch(command, cmd) +} + +func (m Model) handleOverlayKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + if key.Matches(msg, m.keys.Quit) { + m.tasks.CancelAll() + m.logs.Close() + return m, tea.Quit + } + m.mode = modeNormal + m.relayout() + return m, nil +} + +func (m Model) handleTasksKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.Cancel), key.Matches(msg, m.keys.Tasks): + m.mode = modeNormal + m.relayout() + if key.Matches(msg, m.keys.Quit) { + m.tasks.CancelAll() + m.logs.Close() + return m, tea.Quit + } + return m, nil + + case key.Matches(msg, m.keys.Up): + m.taskCursor = max(m.taskCursor-1, 0) + return m, nil + case key.Matches(msg, m.keys.Down): + m.taskCursor = min(m.taskCursor+1, max(len(m.taskList)-1, 0)) + return m, nil + + case key.Matches(msg, m.keys.Enter): + if snapshot, found := m.taskAt(m.taskCursor); found { + m.mode = modeNormal + return m.focusOn(snapshot.ID, snapshot.Title) + } + return m, nil + + case key.Matches(msg, m.keys.Remove): + if snapshot, found := m.taskAt(m.taskCursor); found && snapshot.Cancellable { + m.tasks.Cancel(snapshot.ID) + m.status, m.statusLevel = "canceling "+snapshot.Title+"…", levelWarn + return m, nil + } + m.tasks.Prune() + m.taskList = m.tasks.Snapshots() + m.taskCursor = 0 + m.status, m.statusLevel = "cleared finished tasks", levelInfo + return m, nil + } + return m, nil +} + +func (m Model) taskAt(index int) (task.Snapshot, bool) { + if index < 0 || index >= len(m.taskList) { + return task.Snapshot{}, false + } + return m.taskList[index], true +} + +func (m *Model) activeTable() *table.Model { + if m.screen == screenInstances { + return &m.instTable + } + return &m.devTable +} + +func (m Model) moveSelection(delta int) (Model, tea.Cmd) { + if delta < 0 { + m.activeTable().MoveUp(-delta) + } else { + m.activeTable().MoveDown(delta) + } + if m.tasks.Running() == 0 && m.sidebar.source == sourceTask && m.focusTask == 0 { + m.sidebar.source = sourceLogs + } + cmd := m.refreshSidebar() + return m, cmd +} + +func (m Model) switchScreen(next screen) (Model, tea.Cmd) { + if m.screen == next { + return m, nil + } + m.screen = next + if m.tasks.Running() == 0 && m.sidebar.source == sourceTask && m.focusTask == 0 { + m.sidebar.source = sourceLogs + } + m.relayout() + cmd := m.refreshSidebar() + return m, cmd +} + +// togglePin locks the sidebar onto one container so the user can browse other +// rows while still watching the instance they care about. +func (m Model) togglePin() (Model, tea.Cmd) { + if m.sidebar.pinned != "" { + m.status, m.statusLevel = "unpinned "+m.sidebar.pinned, levelInfo + m.sidebar.pinned = "" + m.relayout() + cmd := m.refreshSidebar() + return m, cmd + } + name := m.logTarget() + if name == "" { + m.status, m.statusLevel = "select a container to pin its logs", levelWarn + return m, nil + } + m.sidebar.pinned = name + m.sidebar.source = sourceLogs + m.sidebar.visible = true + m.status, m.statusLevel = fmt.Sprintf("pinned logs of %s", name), levelOK + m.relayout() + cmd := m.refreshSidebar() + return m, cmd +} diff --git a/tools/homarr-dev/internal/tui/manage.go b/tools/homarr-dev/internal/tui/manage.go new file mode 100644 index 000000000..0cc088ae7 --- /dev/null +++ b/tools/homarr-dev/internal/tui/manage.go @@ -0,0 +1,171 @@ +package tui + +import ( + "fmt" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui" +) + +type manageAction int + +const ( + actionNone manageAction = iota + actionBuild + actionDeleteImage + actionDeleteData + actionRemoveContainer + actionPrune +) + +// confirmation is the armed destructive action awaiting a yes or no. +type confirmation struct { + action manageAction + subject target + prompt string + detail string +} + +// handleManageKey is the second half of the `m` chord. Non-destructive actions +// run immediately; anything that deletes state goes through a confirmation, so +// a mistyped chord cannot destroy an instance. +func (m Model) handleManageKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + if key.Matches(msg, m.keys.Quit) { + m.tasks.CancelAll() + m.logs.Close() + return m, tea.Quit + } + if key.Matches(msg, m.keys.ManageAbort) || key.Matches(msg, m.keys.Manage) { + m.mode = modeNormal + m.status, m.statusLevel = "manage mode closed", levelInfo + m.relayout() + return m, nil + } + + subject, found := m.currentTarget() + if !found { + m.mode = modeNormal + m.relayout() + return m, nil + } + + switch { + case key.Matches(msg, m.keys.ManageBuild): + m.mode = modeNormal + m.relayout() + return m.buildLocally(subject) + + case key.Matches(msg, m.keys.ManageImage): + if subject.localImage == "" { + m.status, m.statusLevel = subject.label+" has no local image to delete", levelWarn + return m, nil + } + return m.arm(actionDeleteImage, subject) + + case key.Matches(msg, m.keys.ManageData): + return m.arm(actionDeleteData, subject) + + case key.Matches(msg, m.keys.ManageStop): + return m.arm(actionRemoveContainer, subject) + + case key.Matches(msg, m.keys.ManagePrune): + return m.arm(actionPrune, subject) + } + return m, nil +} + +// arm moves into the confirmation mode with a prompt describing exactly what is +// about to be destroyed. +func (m Model) arm(action manageAction, subject target) (Model, tea.Cmd) { + confirm := confirmation{action: action, subject: subject} + switch action { + case actionDeleteImage: + confirm.prompt = "Delete image " + subject.localImage + "?" + confirm.detail = "The image is removed from Docker. Rebuild it later with R." + case actionDeleteData: + confirm.prompt = "Delete data volume " + subject.volume + "?" + confirm.detail = "Boards, users and integrations in this instance are lost. The container is removed first." + if size := m.volumeSize(subject.volume); size != "" { + confirm.detail += " (" + size + " reclaimed)" + } + case actionRemoveContainer: + confirm.prompt = "Remove container " + subject.container + "?" + confirm.detail = "The container is force-removed. Its data volume is kept." + case actionPrune: + stopped := 0 + for _, container := range m.containers { + if !container.Running() { + stopped++ + } + } + confirm.prompt = fmt.Sprintf("Remove %d stopped Homarr containers?", stopped) + confirm.detail = "Data volumes are kept." + default: + return m, nil + } + m.confirm = confirm + m.mode = modeConfirm + // The confirmation is the only prompt that should be speaking. + m.status = "" + m.relayout() + return m, nil +} + +func (m Model) volumeSize(name string) string { + for _, volume := range m.volumes { + if volume.Name == name { + return volume.Size + } + } + return "" +} + +func (m Model) handleConfirmKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.ManageAbort): + m.mode = modeNormal + m.confirm = confirmation{} + m.status, m.statusLevel = "canceled", levelInfo + m.relayout() + return m, nil + + case key.Matches(msg, m.keys.ManageConfirm): + confirm := m.confirm + m.mode = modeNormal + m.confirm = confirmation{} + m.relayout() + switch confirm.action { + case actionDeleteImage: + return m.deleteImage(confirm.subject.localImage) + case actionDeleteData: + return m.deleteData(confirm.subject) + case actionRemoveContainer: + return m.removeContainer(confirm.subject.container) + case actionPrune: + return m.pruneStopped() + } + return m, nil + } + return m, nil +} + +// manageBar is the hint strip shown while the chord is armed. +func (m Model) manageBar() string { + subject, found := m.currentTarget() + label := "selection" + if found { + label = subject.label + } + return clip(ui.ModeBadge("MANAGE", ui.Warning)+" "+ + ui.Body.Render(label)+" "+ + m.helpView.ShortHelpView(m.keys.ManageHelp()), m.width) +} + +// confirmBar is the destructive-action prompt. It is deliberately loud. +func (m Model) confirmBar() string { + prompt := ui.ModeBadge("CONFIRM", ui.Danger) + " " + ui.Heading.Render(m.confirm.prompt) + detail := ui.Help.Render(m.confirm.detail + " y confirm · n cancel") + return clip(prompt, m.width) + "\n" + clip(detail, m.width) +} diff --git a/tools/homarr-dev/internal/tui/model.go b/tools/homarr-dev/internal/tui/model.go new file mode 100644 index 000000000..1c7b59549 --- /dev/null +++ b/tools/homarr-dev/internal/tui/model.go @@ -0,0 +1,788 @@ +package tui + +import ( + "fmt" + "strings" + "time" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/progress" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/table" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "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/logs" + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/task" + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui" +) + +type screen int + +const ( + screenDev screen = iota + screenInstances +) + +// mode is the interaction state. Every key press is routed by mode first, which +// is what makes chords like `m` then `i` unambiguous: the same physical key +// means different things depending on which mode armed it. +type mode int + +const ( + modeNormal mode = iota + modeFilter + modeManage + modeConfirm + modeImageSelect + modeHelp + modeTasks +) + +type imageChoice int + +const ( + imageChoiceLocal imageChoice = iota + imageChoiceRemote +) + +type imageSelection struct { + rowKey string + choice imageChoice +} + +type statusLevel int + +const ( + levelInfo statusLevel = iota + levelOK + levelWarn + levelError +) + +type ciCheckState struct { + loading bool + checks []gh.Check + err error + updated time.Time +} + +// Model is the whole application. It owns the data, the two screens, the +// sidebar, and the background task manager; the panes are rendering concerns +// rather than separate Bubble Tea models, which keeps the message routing flat +// and means any key can act on any part of the state. +type Model struct { + keys ui.KeyMap + tasks *task.Manager + logs *logs.Registry + + screen screen + mode mode + + prs []gh.PR + images []docker.Image + containers []docker.Container + volumes []docker.Volume + tags map[string]bool + tagsKnown bool + + devRows []devRow + devFiltered []devRow + instRows []instanceRow + instFiltered []instanceRow + + devTable table.Model + instTable table.Model + filter textinput.Model + helpView help.Model + spinner spinner.Model + progress progress.Model + sidebar sidebar + ciChecks map[int]ciCheckState + + taskList []task.Snapshot + taskStates map[int]task.State + taskCursor int + focusTask int + + confirm confirmation + imageSelection imageSelection + + includeBots bool + demo bool + loading bool + animating bool + spinning bool + generation int + + status string + statusLevel statusLevel + + width int + height int + bodyHeight int + sidebarCols int + sidebarStacked bool +} + +// New builds the application model. showInstances picks the starting screen. +func New(showInstances bool) Model { + styles := table.DefaultStyles() + styles.Header = styles.Header.Bold(true).Foreground(ui.Muted) + styles.Selected = ui.Selected + + devTable := table.New(table.WithColumns(devColumns(110)), table.WithFocused(true), table.WithHeight(16), table.WithWidth(110)) + devTable.SetStyles(styles) + instTable := table.New(table.WithColumns(instanceColumns(110)), table.WithFocused(true), table.WithHeight(16), table.WithWidth(110)) + instTable.SetStyles(styles) + + filter := textinput.New() + filter.Prompt = "/ " + filter.Placeholder = "filter by PR, title, author, branch, image, state, port…" + filter.SetWidth(60) + filterStyles := textinput.DefaultDarkStyles() + filterStyles.Focused.Prompt = filterStyles.Focused.Prompt.Foreground(ui.Accent).Bold(true) + filterStyles.Focused.Text = filterStyles.Focused.Text.Foreground(ui.Bright) + filter.SetStyles(filterStyles) + + helpModel := help.New() + helpModel.Styles = help.DefaultStyles(true) + + spin := spinner.New(spinner.WithSpinner(spinner.Dot), spinner.WithStyle(lipgloss.NewStyle().Foreground(ui.Accent))) + + start := screenDev + if showInstances { + start = screenInstances + } + + return Model{ + keys: ui.DefaultKeyMap(), + tasks: task.NewManager(), + logs: logs.NewRegistry(), + screen: start, + devTable: devTable, + instTable: instTable, + filter: filter, + helpView: helpModel, + spinner: spin, + progress: progress.New(progress.WithWidth(24), progress.WithoutPercentage(), progress.WithColors(ui.Accent)), + sidebar: newSidebar(), + ciChecks: make(map[int]ciCheckState), + taskStates: make(map[int]task.State), + loading: true, + // Init cannot persist state because it takes the model by value, so the + // clocks it arms are recorded as running here instead. + spinning: true, + animating: true, + status: "loading pull requests, images and instances…", + width: 110, + height: 34, + } +} + +func (m Model) Init() tea.Cmd { + return tea.Batch( + loadAll(m.generation, m.includeBots, false), + waitForTasks(m.tasks), + waitForLogs(m.logs), + tickRefresh(), + m.spinner.Tick, + tickFrame(), + ) +} + +// animate arms the animation clocks that are needed and not already ticking. +// Both clocks stop themselves once nothing is in flight, so an idle CLI does no +// work at all, and because arming is centralised a handler cannot start a second +// competing tick chain. It mutates the receiver, so callers must hoist the call +// out of a `return m, m.animate()` — the order of evaluation there is not +// specified, and getting it wrong is exactly how a duplicate chain appears. +func (m *Model) animate() tea.Cmd { + if !m.busy() { + return nil + } + commands := make([]tea.Cmd, 0, 2) + if !m.spinning { + m.spinning = true + commands = append(commands, m.spinner.Tick) + } + if !m.animating { + m.animating = true + commands = append(commands, tickFrame()) + } + return tea.Batch(commands...) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.filter.SetWidth(clampInt(msg.Width-6, 20, 72)) + m.helpView.SetWidth(msg.Width) + m.relayout() + return m, nil + + case loadedMsg: + if msg.generation != m.generation { + return m, nil + } + return m.applyLoaded(msg) + + case containersMsg: + if msg.generation != m.generation { + return m, nil + } + sizesByName := make(map[string]string, len(m.volumes)) + for _, v := range m.volumes { + if v.Size != "" { + sizesByName[v.Name] = v.Size + } + } + for i := range msg.volumes { + if msg.volumes[i].Size == "" { + msg.volumes[i].Size = sizesByName[msg.volumes[i].Name] + } + } + m.containers, m.volumes = msg.containers, msg.volumes + m.rebuildRows() + m.relayout() + cmd := m.refreshSidebar() + return m, tea.Batch(cmd, m.syncLogStreams()) + + case tasksChangedMsg: + return m.applyTasks() + + case logsChangedMsg: + cmd := m.refreshSidebar() + return m, tea.Batch(cmd, waitForLogs(m.logs)) + + case ciChecksMsg: + m.ciChecks[msg.pr] = ciCheckState{ + loading: false, + checks: msg.checks, + err: msg.err, + updated: time.Now(), + } + cmd := m.refreshSidebar() + for _, c := range msg.checks { + if c.IsPending() { + pollCmd := tea.Tick(6*time.Second, func(t time.Time) tea.Msg { + return pollCIChecksMsg{pr: msg.pr} + }) + return m, tea.Batch(cmd, pollCmd) + } + } + return m, cmd + + case pollCIChecksMsg: + if m.sidebar.source == sourceCI && m.selectedPRNumber() == msg.pr { + return m, fetchCIChecks(msg.pr, true) + } + return m, nil + + case refreshMsg: + return m, tea.Batch(tickRefresh(), loadContainers(m.generation)) + + case frameMsg: + cmd := m.refreshSidebar() + if !m.busy() { + m.animating = false + return m, cmd + } + return m, tea.Batch(cmd, tickFrame()) + + case spinner.TickMsg: + if !m.busy() { + m.spinning = false + return m, nil + } + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case statusMsg: + m.status, m.statusLevel = msg.text, msg.level + return m, nil + + case progress.FrameMsg: + var cmd tea.Cmd + m.progress, cmd = m.progress.Update(msg) + return m, cmd + + case tea.KeyPressMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m Model) busy() bool { return m.loading || m.tasks.Running() > 0 } + +// applyLoaded folds a full refresh into the model, preserving the selected row +// so a background refresh never moves the cursor under the user's hands. +func (m Model) applyLoaded(msg loadedMsg) (Model, tea.Cmd) { + m.loading = false + if msg.prErr == nil { + m.prs = msg.prs + } + if msg.imageErr == nil { + m.images = msg.images + } + if msg.tagErr == nil && msg.tags != nil { + m.tags, m.tagsKnown = msg.tags, true + } + m.containers, m.volumes = msg.containers, msg.volumes + + switch { + case msg.prErr != nil && msg.imageErr != nil: + m.status, m.statusLevel = "cannot reach GitHub or Docker: "+msg.prErr.Error(), levelError + case msg.prErr != nil: + m.status, m.statusLevel = "pull requests unavailable: "+msg.prErr.Error(), levelWarn + case msg.imageErr != nil: + m.status, m.statusLevel = "local images unavailable: "+msg.imageErr.Error(), levelWarn + case msg.tagErr != nil: + m.status, m.statusLevel = "GHCR tag listing unavailable: "+msg.tagErr.Error(), levelWarn + } + m.rebuildRows() + if msg.prErr == nil && msg.imageErr == nil && msg.tagErr == nil { + m.status, m.statusLevel = m.summary(), levelInfo + } + m.relayout() + cmd := m.refreshSidebar() + return m, tea.Batch(cmd, m.syncLogStreams()) +} + +func (m Model) summary() string { + remote, local, running := 0, 0, 0 + for _, row := range m.devRows { + if row.kind == rowLocal { + local++ + } else { + remote++ + if row.hasCurrentLocalImage() { + local++ + } + } + if row.running { + running++ + } + } + return fmt.Sprintf("%d pull requests · %d local images · %d running", remote, local, running) +} + +// applyTasks reacts to background progress. Finished jobs trigger a data +// refresh so the table reflects the new image or container without the user +// having to press r. +func (m Model) applyTasks() (Model, tea.Cmd) { + m.taskList = m.tasks.Snapshots() + commands := []tea.Cmd{waitForTasks(m.tasks)} + + for _, snapshot := range m.taskList { + previous, seen := m.taskStates[snapshot.ID] + if seen && previous == snapshot.State { + continue + } + m.taskStates[snapshot.ID] = snapshot.State + if !snapshot.State.Done() { + continue + } + switch snapshot.State { + case task.StateSucceeded: + m.status, m.statusLevel = snapshot.Title+" finished", levelOK + if m.sidebar.source == sourceTask && (m.focusTask == 0 || m.focusTask == snapshot.ID) { + m.sidebar.source = sourceLogs + m.focusTask = 0 + } + case task.StateFailed: + m.status, m.statusLevel = snapshot.Title+" failed: "+errorText(snapshot.Err), levelError + case task.StateCanceled: + m.status, m.statusLevel = snapshot.Title+" canceled", levelWarn + if m.sidebar.source == sourceTask && (m.focusTask == 0 || m.focusTask == snapshot.ID) { + m.sidebar.source = sourceLogs + m.focusTask = 0 + } + } + m.generation++ + commands = append(commands, loadAll(m.generation, m.includeBots, true)) + } + + if m.tasks.Running() == 0 && m.sidebar.source == sourceTask && m.focusTask == 0 { + m.sidebar.source = sourceLogs + } + + if focused, found := m.focusedTask(); found { + if command := m.progress.SetPercent(focused.Percent); command != nil { + commands = append(commands, command) + } + } + if clocks := m.animate(); clocks != nil { + commands = append(commands, clocks) + } + m.relayout() + if cmd := m.refreshSidebar(); cmd != nil { + commands = append(commands, cmd) + } + return m, tea.Batch(commands...) +} + +func errorText(err error) string { + if err == nil { + return "unknown error" + } + return err.Error() +} + +// focusedTask is the job the tray and sidebar follow: the one the user last +// started, falling back to the newest running job. +func (m Model) focusedTask() (task.Snapshot, bool) { + if m.focusTask > 0 { + if snapshot, found := m.tasks.Snapshot(m.focusTask); found { + return snapshot, true + } + } + for index := len(m.taskList) - 1; index >= 0; index-- { + if m.taskList[index].State == task.StateRunning { + return m.taskList[index], true + } + } + if len(m.taskList) > 0 { + return m.taskList[len(m.taskList)-1], true + } + return task.Snapshot{}, false +} + +// rebuildRows recomputes both screens from the current data, keeping the +// selection anchored to the same logical row. +func (m *Model) rebuildRows() { + devKey := m.selectedDevKey() + instKey := m.selectedInstanceName() + + m.devRows = applyContainers(buildDevRows(m.prs, m.images, m.tags, m.tagsKnown), m.containers) + m.instRows = buildInstanceRows(m.containers, m.volumes) + m.applyFilter() + + m.restoreDevSelection(devKey) + m.restoreInstanceSelection(instKey) +} + +func (m *Model) applyFilter() { + query := strings.ToLower(strings.TrimSpace(m.filter.Value())) + + m.devFiltered = m.devFiltered[:0] + for _, row := range m.devRows { + if query == "" || strings.Contains(row.searchTerms(), query) { + m.devFiltered = append(m.devFiltered, row) + } + } + devCols := devColumns(m.tableWidth()) + m.devTable.SetColumns(devCols) + m.devTable.SetRows(devTableRows(m.devFiltered, devCols)) + clampCursor(&m.devTable, len(m.devFiltered)) + + m.instFiltered = m.instFiltered[:0] + for _, row := range m.instRows { + if query == "" || strings.Contains(row.searchTerms(), query) { + m.instFiltered = append(m.instFiltered, row) + } + } + instCols := instanceColumns(m.tableWidth()) + m.instTable.SetColumns(instCols) + m.instTable.SetRows(instanceTableRows(m.instFiltered, instCols)) + clampCursor(&m.instTable, len(m.instFiltered)) +} + +// clampCursor keeps the selection inside the row set. SetCursor clamps against +// the rows the table currently holds, so calling it while the table is empty +// parks the cursor at -1; this restores it as soon as rows exist again. +func clampCursor(model *table.Model, length int) { + if length == 0 { + return + } + if cursor := model.Cursor(); cursor < 0 || cursor >= length { + model.SetCursor(clampInt(cursor, 0, length-1)) + } +} + +func (m Model) selectedDevKey() string { + if row, found := m.selectedDev(); found { + return row.key() + } + return "" +} + +func (m *Model) restoreDevSelection(key string) { + if key == "" { + return + } + for index, row := range m.devFiltered { + if row.key() == key { + m.devTable.SetCursor(index) + return + } + } +} + +func (m Model) selectedInstanceName() string { + if row, found := m.selectedInstance(); found { + return row.container.Name + } + return "" +} + +func (m *Model) restoreInstanceSelection(name string) { + if name == "" { + return + } + for index, row := range m.instFiltered { + if row.container.Name == name { + m.instTable.SetCursor(index) + return + } + } +} + +func (m Model) selectedDev() (devRow, bool) { + index := m.devTable.Cursor() + if index < 0 || index >= len(m.devFiltered) { + return devRow{}, false + } + return m.devFiltered[index], true +} + +func (m Model) selectedInstance() (instanceRow, bool) { + index := m.instTable.Cursor() + if index < 0 || index >= len(m.instFiltered) { + return instanceRow{}, false + } + return m.instFiltered[index], true +} + +// The sidebar splits the screen side by side when there is room, and stacks +// under the table when there is not. Hiding it outright on a narrow terminal +// would make live logs unavailable exactly where they are most needed. +const sidebarSplitWidth = 96 + +func (m Model) tableWidth() int { + if m.sidebarCols > 0 { + return max(m.width-m.sidebarCols-1, 40) + } + return max(m.width, 40) +} + +// mainHeight is the number of lines the table or an overlay may occupy. +func (m Model) mainHeight() int { + if m.sidebarStacked { + return max(m.bodyHeight-m.sidebar.height, 4) + } + return m.bodyHeight +} + +func (m Model) detailVisible() bool { + if m.screen == screenInstances { + _, found := m.selectedInstance() + return found + } + _, found := m.selectedDev() + return found +} + +// relayout recomputes every component's size from the terminal dimensions. The +// reserved count has to match what View actually emits line for line, or the +// bottom of the interface is pushed off screen. +func (m *Model) relayout() { + reserved := 2 // header + blank + if m.mode == modeFilter || m.filter.Value() != "" { + reserved += 2 + } + if m.detailVisible() { + reserved += 3 // blank + title + meta + } + if m.tasks.Running() > 0 { + reserved += 1 + } + switch m.mode { + case modeManage: + reserved += 2 + case modeConfirm: + reserved += 3 + case modeImageSelect: + reserved += 5 + } + if m.status != "" { + reserved += 2 + } + reserved += 3 // blank + two footer lines + + m.bodyHeight = max(m.height-reserved, 6) + + m.sidebarCols = 0 + m.sidebarStacked = false + if m.sidebarRelevant() { + if m.width >= sidebarSplitWidth { + m.sidebarCols = clampInt(m.width*2/5, 34, 72) + m.sidebar.resize(m.sidebarCols, m.bodyHeight) + } else { + m.sidebarStacked = true + m.sidebar.resize(m.width, clampInt(m.bodyHeight/2, 6, 14)) + } + } + + // A table renders a header line plus one line per row, so its height is + // one less than the space it is given. + tableHeight := max(m.mainHeight()-1, 3) + m.devTable.SetWidth(m.tableWidth()) + m.devTable.SetHeight(tableHeight) + m.instTable.SetWidth(m.tableWidth()) + m.instTable.SetHeight(tableHeight) + m.applyFilter() + m.progress.SetWidth(clampInt(m.width/4, 12, 32)) +} + +// syncLogStreams drops followers whose container no longer exists. Attaching is +// deliberately left to refreshSidebar: following every container eagerly would +// thrash against the stream cap on a machine running more instances than the +// cap allows, killing and restarting `docker logs` every refresh. +func (m Model) syncLogStreams() tea.Cmd { + live := make([]string, 0, len(m.containers)) + for _, container := range m.containers { + live = append(live, container.Name) + } + m.logs.Retain(live) + return nil +} + +// refreshSidebar recomputes panel content from whichever source is selected. +func (m *Model) refreshSidebar() tea.Cmd { + if !m.sidebarRelevant() { + return nil + } + if m.sidebar.source == sourceTask { + snapshot, found := m.focusedTask() + if !found { + m.sidebar.title, m.sidebar.subtitle = "tasks", "no background work" + m.sidebar.setContent(ui.Help.Render("Press R to build locally or p to pull a pull-request image.")) + return nil + } + title, subtitle, content := renderTaskSnapshot(snapshot, m.sidebar.textWidth()) + m.sidebar.title, m.sidebar.subtitle = title, subtitle + m.sidebar.setContent(content) + return nil + } + + if m.sidebar.source == sourceCI { + pr := m.selectedPRNumber() + state, found := m.ciChecks[pr] + var cmd tea.Cmd + if pr > 0 && (!found || (!state.loading && time.Since(state.updated) > 30*time.Second)) { + m.ciChecks[pr] = ciCheckState{loading: true, checks: state.checks, err: state.err, updated: state.updated} + cmd = fetchCIChecks(pr, false) + } + title, subtitle, content := renderCISnapshot(pr, state.checks, state.loading, state.err, m.sidebar.textWidth()) + m.sidebar.title, m.sidebar.subtitle = title, subtitle + m.sidebar.setContent(content) + return cmd + } + + name := m.sidebar.pinned + if name == "" { + name = m.logTarget() + } + if name == "" { + return nil + } + // Attaching here is what starts the stream, and it is a no-op once one + // exists. A stopped container is still worth following: `docker logs` + // replays why it died and then ends the stream. + m.logs.Attach(name) + snapshot, attached := m.logs.Snapshot(name) + title, subtitle, content := renderLogSnapshot(name, snapshot, attached) + if m.sidebar.pinned != "" { + subtitle = ui.IconPin + " pinned · " + subtitle + } + m.sidebar.title, m.sidebar.subtitle = title, subtitle + m.sidebar.setContent(content) + return nil +} + +// logTarget is the container whose logs the sidebar shows by default. +func (m Model) logTarget() string { + if m.screen == screenInstances { + if row, found := m.selectedInstance(); found { + return row.container.Name + } + return "" + } + if row, found := m.selectedDev(); found { + if row.state != "" || row.running { + return row.containerName() + } + } + return "" +} + +func (m Model) selectedPRNumber() int { + if m.screen == screenInstances { + if row, found := m.selectedInstance(); found { + return row.prNumber() + } + return 0 + } + if row, found := m.selectedDev(); found { + if row.kind == rowRemote { + return row.pr.Number + } + if row.local.PRNumber > 0 { + return row.local.PRNumber + } + } + return 0 +} + +func (m Model) containerExists(name string) bool { + if name == "" { + return false + } + for _, c := range m.containers { + if c.Name == name { + return true + } + } + return false +} + +// sidebarRelevant determines whether the right-hand panel has meaningful +// content to display (active tasks, CI checks on a PR, pinned logs, or an existing container). +func (m Model) sidebarRelevant() bool { + if !m.sidebar.visible { + return false + } + if m.sidebar.source == sourceTask { + _, found := m.focusedTask() + return found || m.tasks.Running() > 0 + } + if m.sidebar.source == sourceCI { + return m.selectedPRNumber() > 0 + } + if m.sidebar.pinned != "" && m.containerExists(m.sidebar.pinned) { + return true + } + return m.logTarget() != "" +} + +func formatDuration(d time.Duration) string { + switch { + case d < time.Minute: + return fmt.Sprintf("%.0fs", d.Seconds()) + case d < time.Hour: + return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60) + default: + return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60) + } +} + +func shortHelp(bindings []key.Binding, model help.Model) string { + return model.ShortHelpView(bindings) +} diff --git a/tools/homarr-dev/internal/tui/rows.go b/tools/homarr-dev/internal/tui/rows.go new file mode 100644 index 000000000..1c5d9e3f3 --- /dev/null +++ b/tools/homarr-dev/internal/tui/rows.go @@ -0,0 +1,381 @@ +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)) +} diff --git a/tools/homarr-dev/internal/tui/run.go b/tools/homarr-dev/internal/tui/run.go new file mode 100644 index 000000000..653ad0499 --- /dev/null +++ b/tools/homarr-dev/internal/tui/run.go @@ -0,0 +1,25 @@ +package tui + +import tea "charm.land/bubbletea/v2" + +// Run starts the interactive application. showInstances opens directly on the +// instances screen instead of the development browser. +func Run(showInstances bool) error { + model := New(showInstances) + program := tea.NewProgram(model) + final, err := program.Run() + // Background work outlives the render loop unless it is stopped here, and a + // stray `docker logs --follow` would keep the terminal's child process alive + // after the CLI has exited. + if quit, ok := final.(Model); ok { + quit.tasks.CancelAll() + quit.logs.Close() + } + return err +} + +// RunDev opens the development browser. +func RunDev() error { return Run(false) } + +// RunDashboard opens the instances screen. +func RunDashboard() error { return Run(true) } diff --git a/tools/homarr-dev/internal/tui/sidebar.go b/tools/homarr-dev/internal/tui/sidebar.go new file mode 100644 index 000000000..be92a34b1 --- /dev/null +++ b/tools/homarr-dev/internal/tui/sidebar.go @@ -0,0 +1,382 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/viewport" + "charm.land/lipgloss/v2" + + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh" + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/logs" + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/task" + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui" +) + +type sidebarSource int + +const ( + // sourceLogs follows the selected (or pinned) container. + sourceLogs sidebarSource = iota + // sourceTask follows a background pull or build. + sourceTask + // sourceCI follows the GitHub CI checks for the selected PR. + sourceCI +) + +var sidebarTabNames = map[sidebarSource]string{ + sourceLogs: "logs", + sourceTask: "build", + sourceCI: "ci", +} + +var tabOrder = []sidebarSource{sourceLogs, sourceTask, sourceCI} + +// sidebar is the right-hand panel. It has two jobs — live container output and +// live build output — and both are just text, so one scrollable viewport backs +// them and the source switch only changes what fills it. +type sidebar struct { + visible bool + source sidebarSource + viewport viewport.Model + follow bool + pinned string + taskID int + width int + height int + title string + subtitle string +} + +func newSidebar() sidebar { + view := viewport.New(viewport.WithWidth(40), viewport.WithHeight(10)) + view.SoftWrap = true + view.Style = lipgloss.NewStyle().Foreground(ui.Text) + return sidebar{visible: true, follow: true, viewport: view, width: 40, height: 10} +} + +// resize sets the panel's outer dimensions; the viewport is inset for the +// border and the two header lines. +// resize sets the panel's outer size in terminal cells. In Lip Gloss v2 a +// style's Width includes its border, so the text inside loses two columns to +// the border and two more to the horizontal padding. Vertically the border +// costs two rows and the title and subtitle one each. +func (s *sidebar) resize(width, height int) { + s.width, s.height = width, height + s.viewport.SetWidth(s.textWidth()) + s.viewport.SetHeight(max(height-4, 3)) +} + +// textWidth is the usable width inside the border and padding. Content wider +// than this soft-wraps, which turns a progress list into a ragged mess, so +// every renderer budgets against it. +func (s sidebar) textWidth() int { return max(s.width-4, 10) } + +func (s *sidebar) setContent(content string) { + atBottom := s.viewport.AtBottom() + s.viewport.SetContent(content) + if s.follow || atBottom { + s.viewport.GotoBottom() + } +} + +func (s *sidebar) toggleFollow() { + s.follow = !s.follow + if s.follow { + s.viewport.GotoBottom() + } +} + +func (s *sidebar) pageUp() { + s.follow = false + s.viewport.PageUp() +} + +func (s *sidebar) pageDown() { + s.viewport.PageDown() + s.follow = s.viewport.AtBottom() +} + +// view renders the panel, or nothing when it is hidden. +func (s sidebar) view() string { + if !s.visible || s.width < 24 { + return "" + } + text := s.textWidth() + header := ui.Heading.Render(ui.Truncate(s.title, text)) + mode := "paused" + if s.follow { + mode = "following" + } + subtitle := s.subtitle + if subtitle == "" { + subtitle = mode + } else { + subtitle += " · " + mode + } + body := lipgloss.JoinVertical(lipgloss.Left, + header, + ui.Help.Render(ui.Truncate(subtitle, text)), + s.viewport.View(), + ) + return ui.Panel("", s.source == sourceTask).Width(s.width).Height(s.height - 2).Render(body) +} + +var logStateLabels = map[logs.State]string{ + logs.StateStarting: "attaching", + logs.StateStopped: "stream ended", + logs.StateFailed: "stream failed", +} + +// renderLogSnapshot turns a live log buffer into panel content. +func renderLogSnapshot(name string, snapshot logs.Snapshot, attached bool) (title, subtitle, content string) { + title = ui.IconTerminal + " " + name + if name == "" { + return ui.IconTerminal + " logs", "no container selected", ui.Help.Render("Select a running instance to stream its logs.") + } + if !attached { + return title, "not streaming", ui.Help.Render("Container is not running.") + } + if snapshot.State == logs.StateStreaming { + subtitle = fmt.Sprintf("%d lines", len(snapshot.Lines)) + } else if label, found := logStateLabels[snapshot.State]; found { + subtitle = label + } + content = tailLines(snapshot.Lines, len(snapshot.Lines)) + if snapshot.Err != nil { + content += "\n" + ui.Alert.Render(snapshot.Err.Error()) + } + if strings.TrimSpace(content) == "" { + content = ui.Help.Render("waiting for output…") + } + return title, subtitle, content +} + +var taskKindIcons = map[task.Kind]string{ + task.KindBuild: ui.IconBuild, + task.KindPull: ui.IconCloud, +} + +// renderTaskSnapshot lays out a background job: a progress bar per unit of +// work, then the tail of its output. This is the panel that answers "what is my +// build doing right now" without leaving the interface. +func renderTaskSnapshot(snapshot task.Snapshot, width int) (title, subtitle, content string) { + kindIcon := taskKindIcons[snapshot.Kind] + if kindIcon == "" { + kindIcon = ui.IconBuild + } + title = kindIcon + " " + snapshot.Kind.String() + " · " + snapshot.Target + subtitle = snapshot.State.String() + if snapshot.Detail != "" { + subtitle += " · " + snapshot.Detail + } + subtitle += fmt.Sprintf(" · %s", formatDuration(snapshot.Elapsed())) + + var out strings.Builder + labelWidth := stepLabelWidth(snapshot.Steps, width) + for _, step := range snapshot.Steps { + out.WriteString(renderStep(step, labelWidth, width)) + out.WriteString("\n") + } + if snapshot.Kind == task.KindBuild { + if tail := tailLines(snapshot.Lines, 60); tail != "" { + if out.Len() > 0 { + out.WriteString(ui.Rule("output", width) + "\n") + } + // Left unstyled so the build tool's own colours survive. + out.WriteString(tail + "\n") + } + } + if snapshot.Err != nil { + out.WriteString("\n" + ui.Alert.Render(snapshot.Err.Error())) + } + if strings.TrimSpace(out.String()) == "" { + out.WriteString(ui.Help.Render("starting…")) + } + return title, subtitle, out.String() +} + +// renderStep lays out one progress row inside an exact width. The panel wraps +// long lines, which would turn a layer list into a ragged mess, so each row is +// budgeted rather than trimmed after the fact. +// stepLabelWidth sizes the label column to the widest label, so a pull of +// twelve-character layer IDs does not leave a gap the width of a Dockerfile +// instruction. +func stepLabelWidth(steps []task.Step, width int) int { + longest := 0 + for _, step := range steps { + longest = max(longest, lipgloss.Width(step.Label)) + } + bar := clampInt(width/3, 6, 22) + available := max(width-bar-stepFixedWidth-8, 8) + return clampInt(longest, 8, available) +} + +func renderStep(step task.Step, labelWidth, width int) string { + icon := ui.Pending.Render(ui.IconPending) + switch { + case step.Failed: + icon = ui.Alert.Render(ui.IconFail) + case step.Done: + icon = ui.OK.Render(ui.IconPass) + } + colour := ui.Warning + if step.Done { + colour = ui.Success + } + + note := step.Note + if note == "" { + note = strings.ToLower(step.Status) + } + bar := clampInt(width/3, 6, 22) + noteWidth := max(width-bar-labelWidth-stepFixedWidth, 0) + + rendered := fmt.Sprintf("%s %-*s %s %s", + icon, + labelWidth, ui.Truncate(step.Label, labelWidth), + ui.Bar(step.Percent, bar, colour), + ui.Percent(step.Percent), + ) + if noteWidth > 1 && note != "" { + rendered += " " + ui.Help.Render(ui.Truncate(note, noteWidth-1)) + } + return rendered +} + +// stepFixedWidth is the icon, the percentage, and the three separating spaces. +const stepFixedWidth = 1 + 4 + 3 + +// tailLines keeps the last count meaningful lines of output. +func tailLines(lines []string, count int) string { + kept := make([]string, 0, min(len(lines), count)) + for _, line := range lines { + if !ui.Blank(line) { + kept = append(kept, line) + } + } + if len(kept) > count { + kept = kept[len(kept)-count:] + } + return strings.Join(kept, "\n") +} + +func clampInt(value, low, high int) int { + if value < low { + return low + } + if value > high { + return high + } + return value +} + +type checkGlyphSpec struct { + symbol string + style lipgloss.Style +} + +var checkGlyphs = map[string]checkGlyphSpec{ + "pass": {ui.IconPass, ui.OK}, + "fail": {ui.IconFail, ui.Alert}, + "pending": {ui.IconPending, ui.Pending}, + "skipping": {ui.IconStopped, ui.Dim}, + "cancel": {ui.IconFail, ui.Dim}, +} + +func renderCheckLine(c gh.Check, width int) string { + spec, found := checkGlyphs[c.BucketKey()] + if !found { + spec = checkGlyphs["pending"] + } + icon := spec.style.Render(spec.symbol) + + dur := c.Duration() + durStyle := ui.Dim + if dur == "" { + if c.IsSkipped() { + dur = "skipped" + } else if c.IsPending() { + dur = "running" + durStyle = ui.Pending + } else if c.IsFailure() { + dur = "failed" + durStyle = ui.Alert + } else { + dur = "—" + } + } else if c.IsFailure() { + durStyle = ui.Alert + } else if c.IsPending() { + durStyle = ui.Pending + } + styledDur := durStyle.Render(dur) + durLen := lipgloss.Width(styledDur) + + name := c.Name + if c.Workflow != "" && c.Workflow != c.Name && c.Workflow != "CI" { + name += " " + ui.Dim.Render("("+c.Workflow+")") + } + + maxNameWidth := max(width-durLen-4, 10) + styledName := ui.Truncate(name, maxNameWidth) + padding := max(width-lipgloss.Width(icon)-1-lipgloss.Width(styledName)-durLen, 1) + + line := icon + " " + styledName + strings.Repeat(" ", padding) + styledDur + if c.Description != "" { + line += "\n " + ui.Help.Render(ui.Truncate(c.Description, max(width-6, 10))) + } + return line +} + +func renderCISnapshot(pr int, checks []gh.Check, loading bool, err error, width int) (title, subtitle, content string) { + if pr <= 0 { + return "ci", "no PR selected", ui.Help.Render("Select a pull request to inspect its CI checks.") + } + title = fmt.Sprintf("ci · PR #%d", pr) + if loading && len(checks) == 0 { + return title, "fetching…", ui.Help.Render("Fetching CI checks from GitHub…") + } + if err != nil && len(checks) == 0 { + return title, "error", ui.Alert.Render(err.Error()) + "\n\n" + ui.Help.Render("Check `gh auth status` and internet connection.") + } + if len(checks) == 0 { + return title, "no checks", ui.Help.Render("No CI checks reported for this pull request.") + } + + pass, fail, pending, skipping := 0, 0, 0, 0 + for _, c := range checks { + switch { + case c.IsSuccess(): + pass++ + case c.IsFailure(): + fail++ + case c.IsPending(): + pending++ + default: + skipping++ + } + } + + var parts []string + if pass > 0 { + parts = append(parts, fmt.Sprintf("%s %d pass", ui.IconPass, pass)) + } + if fail > 0 { + parts = append(parts, fmt.Sprintf("%s %d fail", ui.IconFail, fail)) + } + if pending > 0 { + parts = append(parts, fmt.Sprintf("%s %d pending", ui.IconPending, pending)) + } + if skipping > 0 { + parts = append(parts, fmt.Sprintf("%s %d skipped", ui.IconStopped, skipping)) + } + subtitle = strings.Join(parts, " · ") + + var out strings.Builder + for _, c := range checks { + out.WriteString(renderCheckLine(c, width)) + out.WriteString("\n") + } + return title, subtitle, strings.TrimRight(out.String(), "\n") +} diff --git a/tools/homarr-dev/internal/tui/view.go b/tools/homarr-dev/internal/tui/view.go new file mode 100644 index 000000000..619ef9b24 --- /dev/null +++ b/tools/homarr-dev/internal/tui/view.go @@ -0,0 +1,384 @@ +package tui + +import ( + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/task" + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui" +) + +func (m Model) View() tea.View { + sections := []string{m.header(), ""} + if m.mode == modeFilter || m.filter.Value() != "" { + sections = append(sections, m.filter.View(), "") + } + sections = append(sections, m.body()) + if detail := m.detail(); detail != "" { + sections = append(sections, "", detail) + } + if tray := m.taskTray(); tray != "" { + sections = append(sections, tray) + } + switch m.mode { + case modeManage: + sections = append(sections, "", m.manageBar()) + case modeConfirm: + sections = append(sections, "", m.confirmBar()) + case modeImageSelect: + sections = append(sections, "", m.imageSelectBar()) + } + if m.status != "" { + sections = append(sections, "", m.statusView()) + } + sections = append(sections, "", m.footer()) + + view := tea.NewView(strings.Join(sections, "\n")) + view.AltScreen = true + view.WindowTitle = ui.IconDocker + " Homarr" + return view +} + +func (m Model) header() string { + title := ui.IconDocker + " homarr" + screen := "development" + if m.screen == screenInstances { + screen = "instances" + } + header := ui.Title.Render(title) + ui.Dim.Render(" · ") + ui.Heading.Render(screen) + if m.busy() { + header += " " + m.spinner.View() + } + + badges := make([]string, 0, 4) + if m.screen == screenDev { + remote, local, running := 0, 0, 0 + for _, row := range m.devRows { + if row.kind == rowLocal { + local++ + } else { + remote++ + if row.hasCurrentLocalImage() { + local++ + } + } + if row.running { + running++ + } + } + badges = append(badges, ui.Help.Render(fmt.Sprintf("%s %d PRs · %s %d local · %s %d running · %s %d shown", ui.IconPR, remote, ui.IconLocal, local, ui.IconRunning, running, ui.IconSearch, len(m.devFiltered)))) + } else { + running := 0 + for _, row := range m.instRows { + if row.container.Running() { + running++ + } + } + badges = append(badges, ui.Help.Render(fmt.Sprintf("%s %d instances · %s %d running · %s %d shown", ui.IconDocker, len(m.instRows), ui.IconRunning, running, ui.IconSearch, len(m.instFiltered)))) + } + if m.demo { + badges = append(badges, ui.ModeBadge("DEMO", ui.Warning)) + } + if running := m.tasks.Running(); running > 0 { + badges = append(badges, ui.Pending.Render(fmt.Sprintf("%s %d working", ui.IconBuild, running))) + } + if !m.includeBots && m.screen == screenDev { + badges = append(badges, ui.Dim.Render("bots hidden")) + } + return clip(header+" "+strings.Join(badges, ui.Dim.Render(" · ")), m.width) +} + +// clip trims a rendered line to the terminal width. The line already carries +// styling, so it is measured and cut by display width rather than by bytes. +func clip(line string, width int) string { + if width <= 0 || lipgloss.Width(line) <= width { + return line + } + return lipgloss.NewStyle().MaxWidth(width).Render(line) +} + +// body joins the active screen with the sidebar. Overlays replace the table but +// keep the sidebar, so a build stays visible while the help is open. +func (m Model) body() string { + main := m.mainPane() + if !m.sidebarRelevant() { + return main + } + panel := m.sidebar.view() + if panel == "" { + return main + } + if m.sidebarStacked { + return main + "\n" + panel + } + return lipgloss.JoinHorizontal(lipgloss.Top, main, " ", panel) +} + +func (m Model) mainPane() string { + width := m.tableWidth() + switch m.mode { + case modeHelp: + return m.helpOverlay(width) + case modeTasks: + return m.taskOverlay(width) + } + if m.screen == screenInstances { + if len(m.instFiltered) == 0 { + return m.emptyPane(width, len(m.instRows) > 0, + "no Homarr instances — press enter on a pull request to start one") + } + return m.instTable.View() + } + if len(m.devFiltered) == 0 { + return m.emptyPane(width, len(m.devRows) > 0, + "nothing to show — check `gh auth status` and that Docker is running") + } + return m.devTable.View() +} + +func (m Model) emptyPane(width int, filtered bool, message string) string { + if filtered { + message = "nothing matches " + m.filter.Value() + } + block := lipgloss.NewStyle().Width(width).Height(m.mainHeight()).Padding(1, 2) + return block.Render(ui.Help.Render(message)) +} + +func (m Model) helpOverlay(width int) string { + body := lipgloss.JoinVertical(lipgloss.Left, + ui.Heading.Render("Keys"), + "", + m.helpView.FullHelpView(m.keys.FullHelp()), + "", + ui.Heading.Render("Manage mode"), + ui.Help.Render("m arms manage mode for the selected row, then:"), + m.helpView.ShortHelpView(m.keys.ManageHelp()), + "", + ui.Heading.Render("Legend"), + ui.Help.Render("CI "+ui.CIBadge("SUCCESS", true)+" "+ui.CIBadge("FAILURE", true)+" "+ui.CIBadge("PENDING", true)+" "+ui.CIBadge("", true)), + ui.Help.Render("IMAGE "+ui.ImageBadge(ui.ImageLocal, true)+" "+ui.ImageBadge(ui.ImageStale, true)+" "+ui.ImageBadge(ui.ImageRemote, true)+" "+ui.ImageBadge(ui.ImageMissing, true)), + ui.Help.Render("STATE "+ui.RunningBadge(true, "7575")+" "+ui.StateBadge("exited")+" "+ui.HealthBadge("healthy")), + "", + ui.Help.Render("press any key to close"), + ) + return lipgloss.NewStyle().Width(width).Height(m.mainHeight()).Padding(0, 1).Render(body) +} + +// taskOverlay lists background work with per-job progress, so a user who +// started three builds can see all of them without cycling the sidebar. +func (m Model) taskOverlay(width int) string { + if len(m.taskList) == 0 { + return m.emptyPane(width, false, "no background tasks yet — R builds locally, p pulls a pull-request image") + } + lines := []string{ui.Heading.Render(ui.IconBuild + " Background tasks"), ""} + barWidth := clampInt(width/4, 10, 26) + for index, snapshot := range m.taskList { + cursor := " " + if index == m.taskCursor { + cursor = ui.Local.Render("▸ ") + } + colour := ui.Warning + switch snapshot.State { + case task.StateSucceeded: + colour = ui.Success + case task.StateFailed: + colour = ui.Danger + case task.StateCanceled: + colour = ui.Faint + } + line := fmt.Sprintf("%s%s %s %s %s", + cursor, + ui.TaskBadge(snapshot.State.String(), snapshot.Kind.String()), + ui.Bar(snapshot.Percent, barWidth, colour), + ui.Percent(snapshot.Percent), + ui.Body.Render(ui.Truncate(snapshot.Title, max(width-barWidth-30, 12))), + ) + lines = append(lines, line) + detail := snapshot.Detail + if snapshot.Err != nil { + detail = snapshot.Err.Error() + } + if detail != "" { + lines = append(lines, ui.Help.Render(" "+ui.Truncate(detail, max(width-8, 20))+" · "+formatDuration(snapshot.Elapsed()))) + } + } + lines = append(lines, "", ui.Help.Render("enter watch · x cancel or clear · esc close")) + return lipgloss.NewStyle().Width(width).Height(m.mainHeight()).Padding(0, 1).Render(strings.Join(lines, "\n")) +} + +// detail describes the selected row in full, including the things that are too +// wide for a table cell: branch, provenance, and the image source Enter can run. +func (m Model) detail() string { + if m.screen == screenInstances { + row, found := m.selectedInstance() + if !found { + return "" + } + meta := []string{ + ui.StateBadge(row.container.State), + ui.Dim.Render(row.container.Image), + } + if health := ui.HealthBadge(row.health); health != "" { + meta = append(meta, health) + } + if url := row.url(); url != "" { + meta = append(meta, ui.OK.Render(ui.IconPort+" "+url)) + } + if row.volume != "" { + meta = append(meta, ui.Help.Render(ui.IconData+" "+row.volume)) + } + meta = append(meta, ui.Help.Render(row.container.Status)) + return clip(ui.Heading.Render(ui.IconDocker+" "+row.container.Name), m.width) + "\n" + + clip(strings.Join(meta, ui.Dim.Render(" · ")), m.width) + } + + row, found := m.selectedDev() + if !found { + return "" + } + if row.kind == rowLocal { + meta := []string{ui.Local.Render(ui.IconLocal + " local image")} + if row.local.Size != "" { + meta = append(meta, ui.Help.Render(row.local.Size)) + } + if row.local.Created != "" { + meta = append(meta, ui.Help.Render(row.local.Created)) + } + if row.local.Source != "" { + meta = append(meta, ui.Help.Render(row.local.Source)) + } else { + meta = append(meta, ui.Pending.Render("no provenance · rebuild unavailable")) + } + if row.local.Revision != "" { + meta = append(meta, ui.Dim.Render(ui.IconBranch+" "+truncateRevision(row.local.Revision))) + } + if row.local.PRNumber > 0 { + meta = append(meta, ui.Help.Render(fmt.Sprintf("%s PR #%d", ui.IconPR, row.local.PRNumber))) + } + meta = append(meta, ui.RunningBadge(row.running, row.port)) + return clip(ui.Heading.Render(ui.IconLocal+" "+row.local.Reference()), m.width) + "\n" + + clip(strings.Join(meta, ui.Dim.Render(" · ")), m.width) + } + + branch := row.pr.HeadRef + if branch == "" { + branch = "unknown branch" + } + meta := []string{ + ui.Help.Render(ui.IconUser + " @" + row.pr.Author), + ui.Dim.Render(ui.IconBranch + " " + branch), + ui.CIBadge(row.pr.CIState, true), + ui.ImageBadge(row.image, true), + } + switch { + case row.running: + meta = append(meta, ui.Help.Render("enter stops it")) + case row.hasCurrentLocalImage() && m.remoteImageAvailable(row): + meta = append(meta, ui.Help.Render("enter chooses local or remote")) + case row.hasCurrentLocalImage(): + meta = append(meta, ui.Local.Render("enter runs "+row.localReference())) + case row.image == ui.ImageRemote: + meta = append(meta, ui.Help.Render("enter pulls "+remoteImageFor(row))) + case row.image == ui.ImageStale: + meta = append(meta, ui.Pending.Render(row.localReference()+" is behind the head commit")) + case row.image == ui.ImageMissing: + meta = append(meta, ui.Pending.Render("R builds it locally")) + } + meta = append(meta, ui.RunningBadge(row.running, row.port)) + title := fmt.Sprintf("%s #%d %s", ui.IconPR, row.pr.Number, row.pr.Title) + return clip(ui.Heading.Render(ui.Truncate(title, max(m.width-2, 20))), m.width) + "\n" + + clip(strings.Join(meta, ui.Dim.Render(" · ")), m.width) +} + +func (m Model) imageSelectBar() string { + row, found := m.selectedDev() + if !found { + return ui.Help.Render("image selection is no longer available") + } + + title := fmt.Sprintf("%s choose image for PR #%d", ui.IconDocker, row.pr.Number) + local := m.imageChoiceLine(imageChoiceLocal, ui.IconLocal+" local", row.localReference()) + remote := m.imageChoiceLine(imageChoiceRemote, ui.IconCloud+" remote", remoteImageFor(row)) + hint := ui.Help.Render("↑/↓ choose · enter start · esc cancel") + return strings.Join([]string{ui.Heading.Render(title), local, remote, hint}, "\n") +} + +func (m Model) imageChoiceLine(choice imageChoice, label, reference string) string { + marker := " " + style := ui.Help + if m.imageSelection.choice == choice { + marker = "▸ " + style = ui.Selected + } + return style.Render(ui.Truncate(marker+label+" "+reference, max(m.width-2, 20))) +} + +// taskTray is the always-visible one-liner for the job being followed. It uses +// the springy bubbles progress bar because a single headline bar reads better +// when it eases towards its target. +func (m Model) taskTray() string { + if m.tasks.Running() == 0 { + return "" + } + snapshot, found := m.focusedTask() + if !found { + return "" + } + detail := snapshot.Detail + if detail == "" { + detail = snapshot.State.String() + } + title := ui.Truncate(snapshot.Title, clampInt(m.width/3, 12, 40)) + head := fmt.Sprintf("%s %s %s %s", + ui.Pending.Render(m.spinner.View()), + ui.Body.Render(title), + m.progress.View(), + ui.Pending.Render(ui.Percent(snapshot.Percent)), + ) + remaining := m.width - lipgloss.Width(head) - 1 + if remaining < 8 { + return head + } + return head + " " + ui.Help.Render(ui.Truncate(detail+" · "+formatDuration(snapshot.Elapsed()), remaining)) +} + +var statusStyles = map[statusLevel]lipgloss.Style{ + levelInfo: ui.Help, + levelOK: ui.OK, + levelWarn: ui.Pending, + levelError: ui.Alert, +} + +var statusIcons = map[statusLevel]string{ + levelInfo: ui.IconNone, + levelOK: ui.IconPass, + levelWarn: ui.IconPending, + levelError: ui.IconFail, +} + +func (m Model) statusView() string { + style, found := statusStyles[m.statusLevel] + if !found { + style = ui.Help + } + icon := statusIcons[m.statusLevel] + text := m.status + if icon != "" && icon != ui.IconNone { + text = icon + " " + text + } + return style.Render(ui.Truncate(text, max(m.width-1, 20))) +} + +// footer shows the bindings that apply to the current screen, so the hints +// never advertise a key that would do nothing here. +func (m Model) footer() string { + primary := m.keys.DevHelp() + if m.screen == screenInstances { + primary = m.keys.InstanceHelp() + } + return clip(m.helpView.ShortHelpView(primary), m.width) + "\n" + + clip(m.helpView.ShortHelpView(m.keys.CommonHelp()), m.width) +} diff --git a/tools/homarr-dev/internal/ui/badge.go b/tools/homarr-dev/internal/ui/badge.go new file mode 100644 index 000000000..3dd1c7831 --- /dev/null +++ b/tools/homarr-dev/internal/ui/badge.go @@ -0,0 +1,200 @@ +package ui + +import ( + "fmt" + "image/color" + "strings" + + "charm.land/lipgloss/v2" +) + +// Badges come in two weights. A chip is a filled pill used for the handful of +// states that must be seen at a glance across a busy table — running, demo, +// manage mode. Everything else is a glyph plus coloured text, which stays +// readable when a dozen of them share a row. +func chip(label string, background color.Color) string { + return lipgloss.NewStyle().Bold(true).Foreground(Ink).Background(background).Padding(0, 1).Render(label) +} + +func glyph(symbol, label string, colour color.Color) string { + style := lipgloss.NewStyle().Foreground(colour) + if label == "" { + return style.Render(symbol) + } + return style.Render(symbol + " " + label) +} + +// CI & Status badge glyphs (Nerd Fonts). +const ( + IconPass = "\uf00c" //  nf-fa-check + IconFail = "\uf00d" //  nf-fa-times + IconPending = "\uf444" //  nf-oct-dot_fill + IconNone = "–" + IconLocal = "\uf1b2" //  nf-fa-cube + IconCloud = "\uf0c2" //  nf-fa-cloud + IconRunning = "\uf04b" //  nf-fa-play + IconStopped = "\uf04d" //  nf-fa-stop + IconPaused = "\uf04c" //  nf-fa-pause + IconBuild = "\uf0ad" //  nf-fa-wrench + IconData = "\uf1c0" //  nf-fa-database + IconDocker = "\uf308" //  nf-fa-docker + IconGitHub = "\uf09b" //  nf-fa-github + IconPR = "\uf407" //  nf-oct-git_pull_request + IconBranch = "\uf418" //  nf-oct-git_branch + IconTerminal = "\uf120" //  nf-fa-terminal + IconHealth = "\uf004" //  nf-fa-heart + IconPort = "\uf08e" //  nf-fa-external_link + IconUser = "\uf007" //  nf-fa-user + IconPin = "\uf435" //  nf-oct-pin + IconSearch = "\uf002" //  nf-fa-search + IconKey = "\uf084" //  nf-fa-key +) + +type badgeSpec struct { + symbol string + label string + colour color.Color +} + +var ciBadges = map[string]badgeSpec{ + "SUCCESS": {IconPass, "checks pass", Success}, + "FAILURE": {IconFail, "checks fail", Danger}, + "PENDING": {IconPending, "checks running", Warning}, +} + +// CIBadge renders the GitHub check rollup of a pull request. +func CIBadge(state string, verbose bool) string { + spec, found := ciBadges[state] + if !found { + spec = badgeSpec{IconNone, "no checks", Faint} + } + label := spec.label + if !verbose { + label = "" + } + return glyph(spec.symbol, label, spec.colour) +} + +// ImageState describes where a runnable image for a row can come from. +type ImageState int + +const ( + ImageUnknown ImageState = iota + ImageChecking + ImageRemote + ImageMissing + ImageLocal + ImageStale + ImageError +) + +var imageBadges = map[ImageState]badgeSpec{ + ImageLocal: {IconLocal, "local", Info}, + ImageStale: {IconLocal, "local (outdated)", Muted}, + ImageRemote: {IconCloud, "ghcr", Success}, + ImageMissing: {"…", "not built", Faint}, + ImageError: {"!", "check failed", Danger}, + ImageChecking: {"?", "checking", Warning}, + ImageUnknown: {"?", "checking", Warning}, +} + +// ImageBadge renders the preferred image availability for a row. A matching +// local build is shown first; the start action can still offer the registry +// image when both sources are available. +func ImageBadge(state ImageState, verbose bool) string { + spec, found := imageBadges[state] + if !found { + spec = badgeSpec{"?", "checking", Warning} + } + label := spec.label + if !verbose { + label = "" + } + return glyph(spec.symbol, label, spec.colour) +} + +// RunningBadge renders container state, filled when the container is up so it +// stands out from every other marker in the row. +func RunningBadge(running bool, port string) string { + if !running { + return glyph(IconStopped, "", Faint) + } + if port == "" { + return chip(IconRunning+" UP", Success) + } + return chip(IconRunning+" :"+port, Success) +} + +var stateBadges = map[string]badgeSpec{ + "running": {IconRunning, "running", Success}, + "restarting": {IconPending, "restarting", Warning}, + "created": {IconPending, "created", Warning}, + "paused": {IconPending, "paused", Warning}, + "exited": {IconStopped, "exited", Danger}, + "dead": {IconStopped, "dead", Danger}, +} + +// StateBadge renders a Docker container state string. +func StateBadge(state string) string { + if spec, found := stateBadges[state]; found { + return glyph(spec.symbol, spec.label, spec.colour) + } + return glyph(IconStopped, state, Faint) +} + +var healthBadges = map[string]badgeSpec{ + "healthy": {IconHealth, "healthy", Success}, + "unhealthy": {IconFail, "unhealthy", Danger}, + "starting": {IconPending, "starting", Warning}, +} + +// HealthBadge renders the healthcheck verdict Docker embeds in a status string. +func HealthBadge(health string) string { + if spec, found := healthBadges[health]; found { + return glyph(spec.symbol, spec.label, spec.colour) + } + return "" +} + +// ModeBadge marks a non-default interaction mode in the header. +func ModeBadge(label string, colour color.Color) string { return chip(label, colour) } + +var taskGlyphs = map[string]struct { + symbol string + colour color.Color +}{ + "running": {IconPending, Warning}, + "succeeded": {IconPass, Success}, + "failed": {IconFail, Danger}, +} + +// TaskBadge renders the outcome of a background job. +func TaskBadge(state, kind string) string { + if spec, found := taskGlyphs[state]; found { + return glyph(spec.symbol, kind, spec.colour) + } + return glyph(IconNone, kind, Faint) +} + +// Bar renders a fixed width progress bar. The bubbles progress component +// animates a single value with a spring; these bars are drawn many per frame +// for individual layers and steps, where an instant, exact bar reads better +// than a dozen independently easing ones. +func Bar(percent float64, width int, colour color.Color) string { + if width <= 0 { + return "" + } + if percent < 0 { + percent = 0 + } + if percent > 1 { + percent = 1 + } + filled := int(percent * float64(width)) + empty := width - filled + return lipgloss.NewStyle().Foreground(colour).Render(strings.Repeat("█", filled)) + + lipgloss.NewStyle().Foreground(Faint).Render(strings.Repeat("░", empty)) +} + +// Percent formats a fraction for display next to a bar. +func Percent(value float64) string { return fmt.Sprintf("%3.0f%%", value*100) } diff --git a/tools/homarr-dev/internal/ui/keys.go b/tools/homarr-dev/internal/ui/keys.go new file mode 100644 index 000000000..13dd63ae0 --- /dev/null +++ b/tools/homarr-dev/internal/ui/keys.go @@ -0,0 +1,138 @@ +package ui + +import "charm.land/bubbles/v2/key" + +// KeyMap is the complete binding set. Bindings are declared once here so the +// help overlay, the footer hints, and the dispatcher can never drift apart — +// adding a key to the map is what makes it appear in the help. +type KeyMap struct { + Up key.Binding + Down key.Binding + Top key.Binding + Bottom key.Binding + Enter key.Binding + Filter key.Binding + Refresh key.Binding + Quit key.Binding + Help key.Binding + + Dev key.Binding + Instances key.Binding + Cycle key.Binding + + Sidebar key.Binding + SidebarTab key.Binding + Pin key.Binding + Follow key.Binding + PageUp key.Binding + PageDown key.Binding + + Pull key.Binding + Build key.Binding + Demo key.Binding + Bots key.Binding + OpenPR key.Binding + OpenApp key.Binding + Copy key.Binding + + Stop key.Binding + Restart key.Binding + Remove key.Binding + + Manage key.Binding + Tasks key.Binding + Cancel key.Binding + + ManageImage key.Binding + ManageData key.Binding + ManageBuild key.Binding + ManageStop key.Binding + ManagePrune key.Binding + ManageConfirm key.Binding + ManageAbort key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), + Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("g", "top")), + Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("G", "bottom")), + Enter: key.NewBinding(key.WithKeys("enter", " "), key.WithHelp("enter", "start/stop")), + Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), + Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), + + Dev: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "development")), + Instances: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "instances")), + Cycle: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "switch screen")), + + Sidebar: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "sidebar")), + SidebarTab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "logs/build/ci")), + Pin: key.NewBinding(key.WithKeys("L"), key.WithHelp("L", "pin logs")), + Follow: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "follow")), + PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "scroll up")), + PageDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "scroll down")), + + Pull: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "pull remote")), + Build: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "build locally")), + Demo: key.NewBinding(key.WithKeys("M"), key.WithHelp("M", "demo mode")), + Bots: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "bot PRs")), + OpenPR: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "open PR")), + OpenApp: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "open app")), + Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy URL")), + + Stop: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "stop")), + Restart: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "restart")), + Remove: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "remove")), + + Manage: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "manage")), + Tasks: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "tasks")), + Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear filter")), + + ManageImage: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "delete image")), + ManageData: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete data")), + ManageBuild: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "rebuild locally")), + ManageStop: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "remove container")), + ManagePrune: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "prune stopped")), + ManageConfirm: key.NewBinding(key.WithKeys("y", "enter"), key.WithHelp("y", "confirm")), + ManageAbort: key.NewBinding(key.WithKeys("n", "esc"), key.WithHelp("n", "cancel")), + } +} + +// ShortHelp is the footer hint set, kept to what fits on two lines. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Up, k.Down, k.Enter, k.Filter, k.Sidebar, k.Manage, k.Tasks, k.Cycle, k.Help, k.Quit} +} + +// FullHelp is the grouped set rendered by the help overlay. +func (k KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.Top, k.Bottom, k.Filter, k.Refresh}, + {k.Enter, k.Pull, k.Build, k.Demo, k.Bots}, + {k.Sidebar, k.SidebarTab, k.Pin, k.Follow, k.PageUp, k.PageDown}, + {k.Stop, k.Restart, k.Remove, k.OpenPR, k.OpenApp, k.Copy}, + {k.Manage, k.Tasks, k.Dev, k.Instances, k.Cycle, k.Quit}, + } +} + +// DevHelp is the footer hint set for the development screen. +func (k KeyMap) DevHelp() []key.Binding { + return []key.Binding{k.Enter, k.Pull, k.Build, k.Demo, k.OpenPR, k.OpenApp, k.Bots} +} + +// InstanceHelp is the footer hint set for the instances screen. +func (k KeyMap) InstanceHelp() []key.Binding { + return []key.Binding{k.Enter, k.Stop, k.Restart, k.Remove, k.Build, k.OpenApp, k.Copy} +} + +// CommonHelp is the second footer line, shared by both screens. +func (k KeyMap) CommonHelp() []key.Binding { + return []key.Binding{k.Filter, k.Cancel, k.Sidebar, k.SidebarTab, k.Pin, k.Follow, k.Manage, k.Tasks, k.Cycle, k.Refresh, k.Help, k.Quit} +} + +// ManageHelp is the binding set shown while manage mode is armed. +func (k KeyMap) ManageHelp() []key.Binding { + return []key.Binding{k.ManageBuild, k.ManageImage, k.ManageData, k.ManageStop, k.ManagePrune, k.ManageAbort} +} diff --git a/tools/homarr-dev/internal/ui/theme.go b/tools/homarr-dev/internal/ui/theme.go new file mode 100644 index 000000000..c0c5f4979 --- /dev/null +++ b/tools/homarr-dev/internal/ui/theme.go @@ -0,0 +1,80 @@ +// Package ui holds the CLI's visual vocabulary: one palette, one set of status +// badges, and one keymap, so every screen looks and behaves the same way. +package ui + +import ( + "image/color" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +// The palette is intentionally small. Colour carries meaning here — green is +// healthy, red is broken, amber is in flight, cyan is local — so introducing +// extra hues would dilute the signal rather than add information. +var ( + Accent = lipgloss.Color("212") + Success = lipgloss.Color("42") + Warning = lipgloss.Color("214") + Danger = lipgloss.Color("203") + Info = lipgloss.Color("81") + Muted = lipgloss.Color("241") + Faint = lipgloss.Color("238") + Text = lipgloss.Color("252") + Bright = lipgloss.Color("230") + Ink = lipgloss.Color("235") +) + +var ( + Title = lipgloss.NewStyle().Bold(true).Foreground(Accent) + Heading = lipgloss.NewStyle().Bold(true).Foreground(Bright) + Help = lipgloss.NewStyle().Foreground(Muted) + Dim = lipgloss.NewStyle().Foreground(Faint) + Body = lipgloss.NewStyle().Foreground(Text) + OK = lipgloss.NewStyle().Foreground(Success) + Alert = lipgloss.NewStyle().Foreground(Danger) + Pending = lipgloss.NewStyle().Foreground(Warning) + Local = lipgloss.NewStyle().Foreground(Info) + Selected = lipgloss.NewStyle().Bold(true).Foreground(Bright).Background(lipgloss.Color("57")) +) + +// Panel frames the sidebar and overlays. +func Panel(title string, focused bool) lipgloss.Style { + var border color.Color = Faint + if focused { + border = Accent + } + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(border). + Padding(0, 1) +} + +// Rule draws a labelled separator across the given width. +func Rule(label string, width int) string { + if width < 8 { + return Dim.Render(Truncate(label, width)) + } + prefix := "── " + label + " " + prefixWidth := lipgloss.Width(prefix) + if prefixWidth >= width { + return Dim.Render(Truncate(prefix, width)) + } + fill := width - prefixWidth + return Dim.Render(prefix + strings.Repeat("─", fill)) +} + +// Blank reports whether a line would render as empty. Build tools emit lines +// that hold nothing but a colour reset; they are not empty as strings but they +// occupy a row for no reason, which turns a log panel into double-spaced text. +func Blank(line string) bool { return lipgloss.Width(strings.TrimSpace(line)) == 0 } + +// Truncate shortens a string to display width, marking the cut with an ellipsis. +// It handles ANSI escape sequences and multi-byte runes correctly. +func Truncate(value string, width int) string { + if width <= 0 { + return "" + } + return ansi.Truncate(value, width, "…") +} diff --git a/tools/homarr-dev/internal/ui/width_probe_test.go b/tools/homarr-dev/internal/ui/width_probe_test.go new file mode 100644 index 000000000..701b2b0ca --- /dev/null +++ b/tools/homarr-dev/internal/ui/width_probe_test.go @@ -0,0 +1,34 @@ +package ui + +import ( + "testing" + + "charm.land/lipgloss/v2" +) + +func TestGlyphWidths(t *testing.T) { + glyphs := []string{ + "✓", "✗", "●", "–", "◆", "☁", "▶", "○", "⚒", "▤", "█", "░", + // Nerd Font glyphs: + "\uf00c", // nf-fa-check  + "\uf00d", // nf-fa-times  + "\uf444", // nf-oct-dot_fill  + "\uf0c2", // nf-fa-cloud  + "\uf1b2", // nf-fa-cube  + "\uf04b", // nf-fa-play  + "\uf04d", // nf-fa-stop  + "\uf0ad", // nf-fa-wrench  + "\uf1c0", // nf-fa-database  + "\uf407", // nf-oct-git_pull_request  + "\uf120", // nf-fa-terminal  + "\uf007", // nf-fa-user  + "\uf004", // nf-fa-heart  + "\uf09b", // nf-fa-github  + "\uf308", // nf-fa-docker  + "\uf002", // nf-fa-search  + "\uf08e", // nf-fa-external_link  + } + for _, glyph := range glyphs { + t.Logf("%q (%s) width=%d", glyph, glyph, lipgloss.Width(glyph)) + } +} diff --git a/tools/homarr-dev/main.go b/tools/homarr-dev/main.go index b4c868296..754efe8cd 100644 --- a/tools/homarr-dev/main.go +++ b/tools/homarr-dev/main.go @@ -1,316 +1,16 @@ +// Command homarr is the Homarr developer CLI. It builds and launches local +// images and pull-request builds, then manages them from an interactive +// terminal application where every slow operation runs in the background. package main import ( - "fmt" "os" - "os/exec" - "strings" - "github.com/spf13/cobra" - - "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/run" - "github.com/homarr-labs/homarr/tools/homarr-dev/internal/tui" -) - -var ( - flagDemo bool - flagPR int - flagEnv []string - flagDetach bool - flagBuildPR int - runMain = tui.RunDev - runRootMain = runLaunch + "github.com/homarr-labs/homarr/tools/homarr-dev/internal/cli" ) func main() { - if err := rootCmd.Execute(); err != nil { + if err := cli.Execute(); err != nil { os.Exit(1) } } - -var rootCmd = &cobra.Command{ - Use: "homarr", - Short: "Launch and manage Homarr Docker instances", - SilenceUsage: true, - Long: `Launch and manage Homarr Docker containers. - -Start a local image, a PR test build from ghcr.io, browse open PRs, -or manage running instances from an interactive dashboard. - -Examples: - homarr --pr 6390 # launch remote PR test image directly - homarr --pr 6390 -e FOO=bar # PR image with environment overrides - homarr run dev # launch local homarr:dev image - homarr run --pr 6390 # launch remote PR test image - homarr run --pr 6390 --demo # PR + demo mode - homarr run -e FOO=bar dev # extra env vars - homarr run --detach dev # start in background - homarr dash # interactive instance dashboard - homarr dev # browse remote PRs and local images - homarr build feature # build this checkout as homarr:feature - homarr build --pr 6390 # build PR #6390 locally as homarr:pr-6390 - homarr list # script-friendly instance list - homarr logs homarr_pr_6390 # follow logs - homarr doctor # check Docker and optional integrations`, - Args: cobra.NoArgs, - RunE: runRoot, -} - -var dashCmd = &cobra.Command{ - Use: "dash", - Short: "Interactive dashboard of running instances", - RunE: func(cmd *cobra.Command, args []string) error { - return tui.RunDashboard() - }, -} - -var runCmd = &cobra.Command{ - Use: "run [tag]", - Short: "Launch a local image or remote pull-request image", - Args: cobra.MaximumNArgs(1), - RunE: runLaunch, -} - -var devCmd = &cobra.Command{ - Use: "dev", - Short: "Browse remote PRs and local development images", - RunE: func(cmd *cobra.Command, args []string) error { - return tui.RunDev() - }, -} - -var buildCmd = &cobra.Command{ - Use: "build [name]", - Short: "Build a local Homarr image from this checkout or a pull request", - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - tag := "" - if len(args) > 0 { - tag = args[0] - } - if flagBuildPR < 0 { - return fmt.Errorf("PR number must be positive") - } - if flagBuildPR > 0 { - if tag == "" { - tag = fmt.Sprintf("pr-%d", flagBuildPR) - } - return run.BuildPRImage(flagBuildPR, tag) - } - if tag == "" { - return fmt.Errorf("image name is required when --pr is not set") - } - return run.BuildCurrentImage(tag, ".") - }, -} - -var rebuildCmd = &cobra.Command{ - Use: "rebuild ", - 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(cmd.Context()) - if err != nil { - return err - } - for _, image := range images { - if image.Tag == args[0] || image.Reference() == args[0] { - return run.RebuildImage(image) - } - } - return fmt.Errorf("local image homarr:%s was not found", strings.TrimPrefix(args[0], "homarr:")) - }, -} - -var logsCmd = &cobra.Command{ - Use: "logs ", - Short: "Follow logs of a running instance", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return docker.FollowLogs(args[0]) - }, -} - -var stopCmd = &cobra.Command{ - Use: "stop ", - Short: "Stop a running instance", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return docker.Stop(args[0]) - }, -} - -var restartCmd = &cobra.Command{ - Use: "restart ", - Short: "Restart a running instance", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return docker.Restart(args[0]) - }, -} - -var removeCmd = &cobra.Command{ - Use: "remove ", - Short: "Force-remove an instance", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return docker.Remove(args[0]) - }, -} - -var openCmd = &cobra.Command{ - Use: "open ", - Short: "Open a running instance in the browser", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - containers, err := docker.List() - if err != nil { - return err - } - for _, container := range containers { - 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("container %q was not found", args[0]) - }, -} - -var listCmd = &cobra.Command{ - Use: "list", - Aliases: []string{"ls"}, - Short: "List Homarr instances without opening the dashboard", - RunE: func(cmd *cobra.Command, args []string) error { - containers, err := docker.List() - if err != nil { - return err - } - if len(containers) == 0 { - fmt.Println("No Homarr instances found.") - return nil - } - fmt.Printf("%-28s %-10s %-7s %s\n", "NAME", "STATE", "PORT", "IMAGE") - for _, container := range containers { - fmt.Printf("%-28s %-10s %-7s %s\n", container.Name, container.State, container.HostPort(), container.Image) - } - return nil - }, -} - -var doctorCmd = &cobra.Command{ - Use: "doctor", - Short: "Check Docker and optional developer CLI integrations", - RunE: func(cmd *cobra.Command, args []string) error { - failed := false - output, err := exec.Command("docker", "info", "--format", "{{.ServerVersion}}").CombinedOutput() - if err != nil { - failed = true - fmt.Printf("✗ Docker daemon: %s\n", strings.TrimSpace(string(output))) - } else { - fmt.Printf("✓ Docker daemon: %s\n", strings.TrimSpace(string(output))) - } - - if _, err := exec.LookPath("gh"); err != nil { - fmt.Println("- GitHub CLI not found (optional; required for pull-request features)") - } else if output, err := exec.Command("gh", "auth", "status").CombinedOutput(); err != nil { - fmt.Printf("- GitHub CLI is not authenticated (optional; run `gh auth login` for pull-request features): %s\n", strings.TrimSpace(string(output))) - } else { - fmt.Println("✓ GitHub CLI authentication") - } - - if _, err := exec.LookPath("homarr"); err != nil { - fmt.Println("- homarr is not on PATH (optional; use `pnpm dev:cli -- ...` or run `pnpm dev:cli:install`)") - } else { - fmt.Println("✓ homarr is on PATH") - } - if failed { - return fmt.Errorf("one or more checks failed") - } - return nil - }, -} - -func init() { - addRunFlags(rootCmd) - addRunFlags(runCmd) - buildCmd.Flags().IntVarP(&flagBuildPR, "pr", "p", 0, "Build a pull request in a temporary checkout") - - rootCmd.AddCommand(runCmd, dashCmd, devCmd, buildCmd, rebuildCmd, listCmd, logsCmd, stopCmd, restartCmd, removeCmd, openCmd, doctorCmd) -} - -func addRunFlags(cmd *cobra.Command) { - cmd.Flags().BoolVarP(&flagDemo, "demo", "m", false, "Demo mode (mock integrations)") - cmd.Flags().IntVarP(&flagPR, "pr", "p", 0, "Launch PR test image from ghcr.io") - cmd.Flags().StringArrayVarP(&flagEnv, "env", "e", nil, "Extra env vars (repeatable, KEY=VALUE)") - cmd.Flags().BoolVar(&flagDetach, "detach", false, "Start in the background") -} - -func runRoot(cmd *cobra.Command, args []string) error { - for _, name := range []string{"pr", "env", "demo", "detach"} { - if cmd.Flags().Changed(name) { - return runRootMain(cmd, args) - } - } - return runMain() -} - -func runLaunch(cmd *cobra.Command, args []string) error { - tag := "" - if len(args) > 0 { - tag = args[0] - } - if flagPR < 0 { - return fmt.Errorf("PR number must be positive") - } - if flagPR > 0 && tag != "" { - return fmt.Errorf("tag and --pr cannot be used together") - } - if flagPR == 0 && tag == "" { - 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, - Env: flagEnv, - FetchTitle: flagPR > 0, - }) - if err != nil { - return err - } - - fmt.Printf("Starting Homarr (%s)\n", plan.Label) - fmt.Printf(" Image : %s\n", plan.Image) - fmt.Printf(" Container : %s\n", plan.Name) - fmt.Printf(" Volume : %s\n", plan.Volume) - fmt.Printf(" Port : %d:7575\n", plan.HostPort) - fmt.Printf(" URL : http://localhost:%d\n", plan.HostPort) - if plan.PRNumber > 0 { - fmt.Printf(" PR : https://github.com/homarr-labs/homarr/pull/%d\n", plan.PRNumber) - } - fmt.Println() - - if flagDetach { - if err := run.StartDetached(plan); err != nil { - return err - } - fmt.Printf("Started in background: http://localhost:%d\n", plan.HostPort) - return nil - } - return run.StartForeground(plan) -} diff --git a/tools/homarr-dev/main_test.go b/tools/homarr-dev/main_test.go deleted file mode 100644 index 45cae5ae2..000000000 --- a/tools/homarr-dev/main_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package main - -import ( - "errors" - "strings" - "testing" - - "github.com/spf13/cobra" -) - -func TestRootCommandLaunchesDevelopmentDashboard(t *testing.T) { - want := errors.New("dashboard started") - previous := runMain - t.Cleanup(func() { runMain = previous }) - runMain = func() error { return want } - - cmd := &cobra.Command{} - if err := runRoot(cmd, nil); !errors.Is(err, want) { - t.Fatalf("root command error = %v", err) - } -} - -func TestRootCommandLaunchesPRWhenRunFlagIsProvided(t *testing.T) { - want := errors.New("PR launched") - previous := runRootMain - t.Cleanup(func() { runRootMain = previous }) - runRootMain = func(cmd *cobra.Command, args []string) error { return want } - - cmd := &cobra.Command{} - cmd.Flags().Bool("pr", false, "") - if err := cmd.Flags().Set("pr", "true"); err != nil { - t.Fatal(err) - } - - if err := runRoot(cmd, nil); !errors.Is(err, want) { - t.Fatalf("root command error = %v", err) - } -} - -func TestRunLaunchRejectsTagWithPR(t *testing.T) { - previousPR := flagPR - t.Cleanup(func() { flagPR = previousPR }) - flagPR = 42 - - err := runLaunch(runCmd, []string{"feature"}) - if err == nil || !strings.Contains(err.Error(), "cannot be used together") { - t.Fatalf("error = %v", err) - } -} - -func TestRunLaunchRejectsNegativePR(t *testing.T) { - previousPR := flagPR - t.Cleanup(func() { flagPR = previousPR }) - flagPR = -1 - - err := runLaunch(runCmd, nil) - if err == nil || !strings.Contains(err.Error(), "must be positive") { - 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) - } -}