Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
193a536a2d | ||
|
|
c8df2fbe5f | ||
|
|
7c4c1ffbe7 | ||
|
|
84b000afda | ||
|
|
7710abcf26 |
No files matched your search
@@ -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 <name>` when only the image is needed, and `pnpm cli harness run <name>` to start an already-built image. `--port <number>` 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.
|
||||
@@ -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."
|
||||
@@ -25,6 +25,8 @@ packages/db/migrations/*/migrate.cjs
|
||||
|
||||
# production
|
||||
build
|
||||
tools/homarr-dev/homarr
|
||||
tools/homarr-dev/homarr-dev
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
@@ -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 <name>
|
||||
pnpm dev:cli -- build --pr <number> [name]
|
||||
homarr dash
|
||||
# or
|
||||
pnpm dev:cli -- dash
|
||||
```
|
||||
|
||||
The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` 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 <name>
|
||||
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 <number>`: Pull and run a PR image from GHCR (shorthand: `homarr --pr <number>`).
|
||||
- `--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 <number> --env HOMARR_WEBSITE_URL=https://app-v2.preview.homarr.dev/
|
||||
homarr run <name>
|
||||
homarr run --pr <number>
|
||||
# Build current checkout as homarr:<name>
|
||||
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 <number>` form is shorthand for `homarr run --pr <number>`. Add `--detach` to run in the
|
||||
background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
|
||||
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
|
||||
```
|
||||
@@ -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. 
|
||||
5. Select /bin/bash and click Execute 
|
||||
6. Run the Homarr command and press enter (eg. homarr reset-owner-password) 
|
||||
|
||||
## 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 <name>`, `harness run <name>`, and `harness stop <name>`. Use the returned URL with a browser automation harness for feature validation; do not assume the host port is `7575`.
|
||||
+3
-2
@@ -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",
|
||||
|
||||
@@ -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<string> {
|
||||
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<void> {
|
||||
await new Promise<void>((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<string> {
|
||||
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<string, string> {
|
||||
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<string | undefined> {
|
||||
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<string> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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],
|
||||
});
|
||||
+38
-10
@@ -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<void> {
|
||||
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();
|
||||
+42
-25
@@ -56,52 +56,69 @@ homarr dash Start on the instance dashboard
|
||||
homarr build <name> Build this checkout as homarr:<name>
|
||||
homarr build --pr <number> Build a PR locally from a temporary checkout
|
||||
homarr rebuild <name> 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 <container> Follow logs
|
||||
homarr shell <container> Open an interactive shell in a running instance
|
||||
homarr ci [pr] [--watch] View or watch GitHub CI checks for a pull request
|
||||
homarr open <container> Open in browser
|
||||
homarr stop <container> Stop
|
||||
homarr restart <container> Restart
|
||||
homarr remove <container> 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
|
||||
```
|
||||
@@ -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 <name>",
|
||||
Short: "Rebuild a local image from its recorded checkout or pull request",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 <name>`.")
|
||||
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 <name>...",
|
||||
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 <volume>...",
|
||||
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 <container>",
|
||||
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 <container>",
|
||||
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 <container>",
|
||||
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 <container>",
|
||||
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 <container>",
|
||||
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 <container> [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 <number>`")
|
||||
}
|
||||
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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 != "<none>" {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 <output>` — 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) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 <tag>` 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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, "…")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+5
-305
@@ -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 <name>",
|
||||
Short: "Rebuild a local image from its recorded checkout or pull request",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
images, err := docker.ListLocalImages(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 <container>",
|
||||
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 <container>",
|
||||
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 <container>",
|
||||
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 <container>",
|
||||
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 <container>",
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user