Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f64bcfbf1 |
No files matched your search
@@ -1,60 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,4 +0,0 @@
|
||||
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,8 +25,6 @@ packages/db/migrations/*/migrate.cjs
|
||||
|
||||
# production
|
||||
build
|
||||
tools/homarr-dev/homarr
|
||||
tools/homarr-dev/homarr-dev
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
@@ -1,175 +1,43 @@
|
||||
# Developer Docker CLI
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 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
|
||||
## Browse development images
|
||||
|
||||
```bash
|
||||
pnpm dev:cli --
|
||||
# or with global binary:
|
||||
homarr
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### 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
|
||||
## Build images
|
||||
|
||||
```bash
|
||||
homarr dash
|
||||
# or
|
||||
pnpm dev:cli -- dash
|
||||
pnpm dev:cli -- build <name>
|
||||
pnpm dev:cli -- build --pr <number> [name]
|
||||
```
|
||||
|
||||
The instance dashboard displays all running and stopped Homarr containers, published ports, health states, and resource volumes.
|
||||
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.
|
||||
|
||||
| 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
|
||||
## Rebuild images
|
||||
|
||||
```bash
|
||||
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/
|
||||
pnpm dev:cli -- rebuild <name>
|
||||
```
|
||||
|
||||
- `--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`).
|
||||
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.
|
||||
|
||||
### Build & Rebuild
|
||||
## Run images
|
||||
|
||||
```bash
|
||||
# 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
|
||||
homarr --pr <number> --env HOMARR_WEBSITE_URL=https://app-v2.preview.homarr.dev/
|
||||
homarr run <name>
|
||||
homarr run --pr <number>
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
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.
|
||||
@@ -25,27 +25,3 @@ 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`.
|
||||
@@ -16,7 +16,7 @@ import beszelSystemGrid from "./img/beszel-system-grid.png";
|
||||
|
||||
<WidgetHeader widget={beszelSystemGridWidget} categories={["System Monitoring"]} />
|
||||
|
||||
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
|
||||
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Resizing the widget adjusts its rows and columns to keep the cards balanced. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
|
||||
|
||||
Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
|
||||
|
||||
|
||||
+2
-3
@@ -30,9 +30,8 @@
|
||||
"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 -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:cli": "go -C tools/homarr-dev run .",
|
||||
"dev:cli:install": "go -C tools/homarr-dev install .",
|
||||
"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",
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
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],
|
||||
});
|
||||
+10
-38
@@ -1,42 +1,14 @@
|
||||
import { run } from "@drizzle-team/brocli";
|
||||
import type { Command } from "@drizzle-team/brocli";
|
||||
|
||||
import { harnessRoot, normalizeHarnessEnvironmentArgs } from "./commands/harness";
|
||||
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";
|
||||
|
||||
const harnessCommandNames = new Set(["harness", "local-harness"]);
|
||||
const commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin];
|
||||
|
||||
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();
|
||||
void run(commands, {
|
||||
name: "homarr-cli",
|
||||
version: "1.0.0",
|
||||
});
|
||||
@@ -123,19 +123,12 @@ const MIN_CELL_HEIGHT = 80;
|
||||
const ADVANCED_MIN_CELL_WIDTH = 320;
|
||||
const ADVANCED_MIN_CELL_HEIGHT = 420;
|
||||
|
||||
const getColCount = (width: number, _height: number, itemCount: number, minCellWidth = MIN_CELL_WIDTH): number => {
|
||||
const getColCount = (width: number, height: number, itemCount: number, minCellWidth = MIN_CELL_WIDTH): number => {
|
||||
if (itemCount <= 1) return 1;
|
||||
const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / minCellWidth)));
|
||||
|
||||
let best = 1;
|
||||
for (let c = 1; c <= maxCols; c++) {
|
||||
const emptyCells = (c - (itemCount % c)) % c;
|
||||
const bestEmpty = (best - (itemCount % best)) % best;
|
||||
if (emptyCells < bestEmpty || (emptyCells === bestEmpty && c > best)) {
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
const aspectRatio = width / Math.max(height, MIN_CELL_HEIGHT);
|
||||
const idealCols = Math.round(Math.sqrt(itemCount * aspectRatio));
|
||||
return Math.min(maxCols, Math.max(1, idealCols));
|
||||
};
|
||||
|
||||
interface MetricRowProps {
|
||||
|
||||
+25
-42
@@ -56,69 +56,52 @@ 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, Engine API, and optional integrations
|
||||
homarr doctor Check Docker and optional CLI integrations
|
||||
```
|
||||
|
||||
## Development Browser
|
||||
|
||||
```text
|
||||
/ Filter by PR number, title, author, branch, CI, image, state, or port
|
||||
↑/↓ 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
|
||||
↑/↓ 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
|
||||
o Open PR on GitHub
|
||||
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)
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Instance Dashboard
|
||||
|
||||
```text
|
||||
/ Filter by name, image, state, health, port, or URL
|
||||
↑/↓ 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
|
||||
↑/↓ or j/k Select
|
||||
l Toggle inline logs
|
||||
f Follow or pause logs
|
||||
Page Up/Down Scroll logs
|
||||
o Open running instance in browser
|
||||
c Copy URL to clipboard
|
||||
R Rebuild from recorded provenance
|
||||
s Stop
|
||||
R Restart
|
||||
x Remove with confirmation
|
||||
o Open in browser
|
||||
c Copy URL
|
||||
r Refresh
|
||||
1 / 2 / d Switch screen
|
||||
? Help overlay
|
||||
d Switch to the development browser
|
||||
q Quit
|
||||
```
|
||||
@@ -1,74 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
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"))
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
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,29 +378,27 @@ func IsPortConflict(err error) bool {
|
||||
strings.Contains(message, "failed programming external connectivity")
|
||||
}
|
||||
|
||||
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 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 RestartContext(ctx context.Context, name string) error {
|
||||
return runContainerCommand(ctx, "restart", 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 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()
|
||||
func Remove(name string) error {
|
||||
cmd := exec.Command("docker", "rm", "-f", name)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
@@ -427,15 +425,3 @@ 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,8 +27,6 @@ type Image struct {
|
||||
Source string
|
||||
Revision string
|
||||
PRNumber int
|
||||
Size string
|
||||
Created string
|
||||
}
|
||||
|
||||
func (image Image) Reference() string {
|
||||
@@ -36,11 +34,9 @@ func (image Image) Reference() string {
|
||||
}
|
||||
|
||||
type imageListRow struct {
|
||||
ID string
|
||||
Repository string
|
||||
Tag string
|
||||
Size string
|
||||
CreatedSince string
|
||||
ID string
|
||||
Repository string
|
||||
Tag string
|
||||
}
|
||||
|
||||
func parseImageList(output []byte) ([]Image, error) {
|
||||
@@ -54,7 +50,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, Size: row.Size, Created: row.CreatedSince})
|
||||
images = append(images, Image{ID: row.ID, Tag: row.Tag})
|
||||
}
|
||||
}
|
||||
return images, nil
|
||||
@@ -90,39 +86,22 @@ 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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
labelsByImage := make([]map[string]string, len(images))
|
||||
for index, image := range images {
|
||||
for index, line := range lines {
|
||||
labelsByImage[index] = make(map[string]string)
|
||||
singleOut, singleErr := exec.CommandContext(ctx, "docker", "image", "inspect", "--format", "{{json .Config.Labels}}", image.Reference()).Output()
|
||||
if singleErr != nil {
|
||||
if line == "null" {
|
||||
continue
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(singleOut))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
continue
|
||||
if err := json.Unmarshal([]byte(line), &labelsByImage[index]); err != nil {
|
||||
return nil, fmt.Errorf("parse labels for %s: %w", images[index].Reference(), err)
|
||||
}
|
||||
_ = json.Unmarshal([]byte(trimmed), &labelsByImage[index])
|
||||
}
|
||||
return labelsByImage, nil
|
||||
}
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
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) }
|
||||
@@ -1,163 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
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,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -198,151 +197,3 @@ 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,47 +89,3 @@ 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")
|
||||
}
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
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,16 +14,15 @@ func OpenURL(url string) error {
|
||||
return exec.Command(name, args...).Start()
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
return "", nil, fmt.Errorf("opening URLs is not supported on %s", goos)
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
// 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)))
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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,9 +7,11 @@ 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 {
|
||||
@@ -17,11 +19,11 @@ func BuildCurrentImage(tag, directory string) error {
|
||||
}
|
||||
|
||||
func buildCurrentImage(ctx context.Context, tag, directory string, stdout, stderr io.Writer) error {
|
||||
options, err := CurrentBuildOptions(ctx, tag, directory)
|
||||
root, revision, err := checkoutDetails(ctx, directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return docker.BuildContext(ctx, options, stdout, stderr)
|
||||
return docker.BuildContext(ctx, docker.BuildOptions{Context: root, Tag: tag, Source: root, Revision: revision}, stdout, stderr)
|
||||
}
|
||||
|
||||
func BuildPRImage(number int, tag string) error {
|
||||
@@ -29,18 +31,44 @@ func BuildPRImage(number int, tag string) error {
|
||||
}
|
||||
|
||||
func buildPRImage(ctx context.Context, number int, tag string, stdout, stderr io.Writer) error {
|
||||
options, cleanup, err := PRBuildOptions(ctx, number, tag, func(line string) {
|
||||
_, _ = fmt.Fprintln(stdout, line)
|
||||
})
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
return docker.BuildContext(ctx, options, stdout, stderr)
|
||||
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)
|
||||
}
|
||||
|
||||
func prFetchArgs(repoURL, refSpec string) []string {
|
||||
return []string{"fetch", "--depth=1", repoURL, refSpec}
|
||||
func prCloneArgs(checkout string) []string {
|
||||
return []string{"repo", "clone", gh.Repo, checkout, "--", "--depth=1", "--filter=blob:none"}
|
||||
}
|
||||
|
||||
func RebuildImage(image docker.Image) error {
|
||||
@@ -61,16 +89,7 @@ func RebuildImageContext(ctx context.Context, image docker.Image, stdout, stderr
|
||||
}
|
||||
|
||||
func checkoutDetails(ctx context.Context, directory string) (root string, revision string, err error) {
|
||||
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()
|
||||
rootOutput, err := exec.CommandContext(ctx, "git", "-C", directory, "rev-parse", "--show-toplevel").CombinedOutput()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("find Homarr checkout: %w: %s", err, strings.TrimSpace(string(rootOutput)))
|
||||
}
|
||||
@@ -78,16 +97,7 @@ 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)
|
||||
}
|
||||
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()
|
||||
revisionOutput, err := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "HEAD").CombinedOutput()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("read checkout revision: %w: %s", err, strings.TrimSpace(string(revisionOutput)))
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
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 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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]
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
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
@@ -0,0 +1,228 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,788 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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) }
|
||||
@@ -1,382 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
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) }
|
||||
@@ -1,138 +0,0 @@
|
||||
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}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
// 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, "…")
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
+305
-5
@@ -1,16 +1,316 @@
|
||||
// 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/homarr-labs/homarr/tools/homarr-dev/internal/cli"
|
||||
"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
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := cli.Execute(); err != nil {
|
||||
if err := rootCmd.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)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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