Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
193a536a2d | ||
|
|
c8df2fbe5f | ||
|
|
7c4c1ffbe7 | ||
|
|
84b000afda | ||
|
|
7710abcf26 |
No files matched your search
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
name: homarr-local-harness
|
||||||
|
description: Build and inspect a writable Docker demo harness from the current Homarr checkout when validating a local branch in a browser.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Homarr Local Harness
|
||||||
|
|
||||||
|
Use this skill when a Homarr change needs a real production image and browser validation. Work from the repository root and keep the harness name Docker-safe, such as `feature-board`.
|
||||||
|
|
||||||
|
## Set up the harness
|
||||||
|
|
||||||
|
Run the TypeScript operations CLI; this workflow does not use the interactive developer TUI:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm cli harness setup feature-board
|
||||||
|
```
|
||||||
|
|
||||||
|
`setup` builds the current checkout as `homarr:feature-board`, replaces the named harness container, and starts it with a persistent data volume. It enables demo mode, mock integrations, and writable demo mutations by default:
|
||||||
|
|
||||||
|
- `DEMO_MODE=true`
|
||||||
|
- `DEMO_READ_ONLY=false`
|
||||||
|
- `UNSAFE_ENABLE_MOCK_INTEGRATION=true`
|
||||||
|
|
||||||
|
Pass repeatable environment overrides when the feature needs them:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm cli harness setup feature-board \
|
||||||
|
--env WORKSHOP_WEB_URL=https://example.test \
|
||||||
|
--env FEATURE_FLAG=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `pnpm cli harness build <name>` when only the image is needed, and `pnpm cli harness run <name>` to start an already-built image. `--port <number>` pins a host port; otherwise Docker assigns one.
|
||||||
|
|
||||||
|
Always use the URL printed by the command. If the output is no longer available, query it directly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm cli harness port feature-board
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not assume port `7575`; the container listens on `7575`, but the host port is dynamic by default.
|
||||||
|
|
||||||
|
## Browser validation
|
||||||
|
|
||||||
|
Open the printed URL with the environment's `browser-harness` tool. In this workspace, the `agent-browser` CLI is the supported browser automation fallback. Load its current workflow before invoking it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
agent-browser skills get core
|
||||||
|
```
|
||||||
|
|
||||||
|
Navigate to the exact harness URL, wait for the app to become usable, exercise the changed flow, and capture screenshots or other browser evidence. Keep browser state in a named session when the workflow needs login persistence. Report the exact URL, viewport/session used, and screenshot paths; a Docker process or HTTP health response alone is not browser acceptance.
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
Stop the named container when finished:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm cli harness stop feature-board
|
||||||
|
```
|
||||||
|
|
||||||
|
Stopping keeps the data volume so the same harness can be restarted. A later `setup` rebuilds the image and replaces only the container. Preserve unrelated containers, images, volumes, and working-tree changes.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Homarr Local Harness"
|
||||||
|
short_description: "Build and browse a writable local Homarr branch harness"
|
||||||
|
default_prompt: "Use $homarr-local-harness to set up a writable Homarr harness for the current branch and validate it in the browser."
|
||||||
@@ -25,6 +25,8 @@ packages/db/migrations/*/migrate.cjs
|
|||||||
|
|
||||||
# production
|
# production
|
||||||
build
|
build
|
||||||
|
tools/homarr-dev/homarr
|
||||||
|
tools/homarr-dev/homarr-dev
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -1,43 +1,175 @@
|
|||||||
# Developer Docker CLI
|
# Developer Docker CLI
|
||||||
|
|
||||||
The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
|
The host-side developer CLI builds and runs local Homarr images and pull-request previews with non-blocking background operations. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
|
||||||
|
|
||||||
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features.
|
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication (`gh auth login`) is only needed for pull-request features.
|
||||||
|
|
||||||
## Browse development images
|
## Requirements
|
||||||
|
|
||||||
|
- Docker or OrbStack
|
||||||
|
- Go 1.25+ (when building the CLI from source)
|
||||||
|
- GitHub CLI authenticated with `gh auth login` (for pull-request and CI features)
|
||||||
|
- A [Nerd Font](https://www.nerdfonts.com/) enabled in your terminal (recommended for icons, status indicators, and TUI glyphs)
|
||||||
|
|
||||||
|
## Interactive Development Browser
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev:cli --
|
pnpm dev:cli --
|
||||||
|
# or with global binary:
|
||||||
|
homarr
|
||||||
```
|
```
|
||||||
|
|
||||||
The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen.
|
The main browser combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` (or `1`/`2`) to toggle between the image browser and the instance dashboard.
|
||||||
|
|
||||||
A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds.
|
A locally built PR image is folded into its remote PR row only when its recorded revision matches the current GitHub head SHA (marked `◆ local`). When the matching GHCR image is also available, pressing `Enter` opens a local/remote selector with local preselected. Outdated local builds remain separate rows.
|
||||||
|
|
||||||
## Build images
|
### Multi-Source Sidebar
|
||||||
|
|
||||||
|
Press `Tab` to cycle between sidebar tabs:
|
||||||
|
|
||||||
|
- **`[logs]`**: Live container output streams (press `f` to follow/pause, `L` to pin to the selected container, `Page Up`/`Page Down` to scroll).
|
||||||
|
- **`[build]`**: Real-time BuildKit step streaming for active local builds.
|
||||||
|
- **`[ci]`**: GitHub Actions CI check statuses, run duration, and workflow links for pull requests.
|
||||||
|
|
||||||
|
### Background Task Overlay
|
||||||
|
|
||||||
|
Pulls and builds run asynchronously in the background. Press `t` to open the tasks overlay, showing:
|
||||||
|
|
||||||
|
- Layer-by-layer download progress and transfer speeds (via Docker Engine API).
|
||||||
|
- Active build stages and logs.
|
||||||
|
- Task cancellation and status.
|
||||||
|
|
||||||
|
### Browser Shortcuts
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
| ---------------------- | --------------------------------------------------------------------------------------------- |
|
||||||
|
| `/` | Filter rows by PR number, title, author, branch, CI, image, state, or port |
|
||||||
|
| `↑` / `↓` or `j` / `k` | Navigate rows |
|
||||||
|
| `Enter` or `Space` | Start the available image; choose local or remote when both exist; stop if running |
|
||||||
|
| `R` | Build locally in background (or rebuild local image) |
|
||||||
|
| `p` | Pull remote PR image in background and start container |
|
||||||
|
| `m` | Manage mode (`b` rebuild, `i` delete image, `d` delete data, `c` remove container, `p` prune) |
|
||||||
|
| `t` | Toggle background tasks overlay |
|
||||||
|
| `Tab` | Cycle sidebar tabs (`[logs]`, `[build]`, `[ci]`) |
|
||||||
|
| `l` / `L` | Toggle sidebar visibility / Pin logs to selected instance |
|
||||||
|
| `f` | Follow or pause logs |
|
||||||
|
| `M` | Toggle demo mode for next launch |
|
||||||
|
| `b` | Toggle bot pull requests |
|
||||||
|
| `o` / `a` | Open PR on GitHub / Open running instance in browser |
|
||||||
|
| `c` | Copy instance URL to clipboard |
|
||||||
|
| `r` | Refresh data |
|
||||||
|
| `1` / `2` / `d` | Switch screen (development browser / instance dashboard) |
|
||||||
|
| `?` / `q` | Help overlay / Quit (cancels active background tasks) |
|
||||||
|
|
||||||
|
## Instance Dashboard
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev:cli -- build <name>
|
homarr dash
|
||||||
pnpm dev:cli -- build --pr <number> [name]
|
# or
|
||||||
|
pnpm dev:cli -- dash
|
||||||
```
|
```
|
||||||
|
|
||||||
The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
|
The instance dashboard displays all running and stopped Homarr containers, published ports, health states, and resource volumes.
|
||||||
|
|
||||||
## Rebuild images
|
| Key | Action |
|
||||||
|
| ------------------ | ---------------------------------------- |
|
||||||
|
| `Enter` or `Space` | Start or stop selected instance |
|
||||||
|
| `s` / `S` | Stop / Restart instance |
|
||||||
|
| `x` | Remove container (preserves data volume) |
|
||||||
|
| `m` | Manage mode for selected instance |
|
||||||
|
| `R` | Rebuild from recorded build provenance |
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
|
The commands below use the installed `homarr` binary. Without global installation, prefix each command with `pnpm dev:cli --` (for example, `pnpm dev:cli -- list`).
|
||||||
|
|
||||||
|
### Launch Instances
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev:cli -- rebuild <name>
|
homarr run dev
|
||||||
|
homarr run --detach dev
|
||||||
|
homarr run --pr 6441
|
||||||
|
homarr run --pr 6441 --demo
|
||||||
|
homarr --pr 6441 --env WORKSHOP_WEB_URL=https://app-v2.preview.homarr.dev/
|
||||||
```
|
```
|
||||||
|
|
||||||
Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout.
|
- `--pr <number>`: Pull and run a PR image from GHCR (shorthand: `homarr --pr <number>`).
|
||||||
|
- `--demo` (`-m`): Start with mock integrations and sample data.
|
||||||
|
- `--detach`: Start container in background.
|
||||||
|
- `--env` (`-e`): Pass additional environment variables (repeatable: `--env KEY=VALUE`).
|
||||||
|
|
||||||
## Run images
|
### Build & Rebuild
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
homarr --pr <number> --env HOMARR_WEBSITE_URL=https://app-v2.preview.homarr.dev/
|
# Build current checkout as homarr:<name>
|
||||||
homarr run <name>
|
homarr build feature
|
||||||
homarr run --pr <number>
|
|
||||||
|
# Build a pull request locally in a temporary checkout
|
||||||
|
homarr build --pr 6441
|
||||||
|
|
||||||
|
# Rebuild an existing image from recorded provenance
|
||||||
|
homarr rebuild feature
|
||||||
```
|
```
|
||||||
|
|
||||||
The direct `homarr --pr <number>` form is shorthand for `homarr run --pr <number>`. Add `--detach` to run in the
|
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.
|
||||||
background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
|
|
||||||
|
### GitHub CI Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View CI checks for a specific PR
|
||||||
|
homarr ci 6441
|
||||||
|
|
||||||
|
# View CI checks for the current branch
|
||||||
|
homarr ci
|
||||||
|
|
||||||
|
# Watch CI checks until completion
|
||||||
|
homarr ci 6441 --watch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interactive Shell
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Open interactive shell in running container
|
||||||
|
homarr shell homarr-dev
|
||||||
|
|
||||||
|
# Run specific command in container
|
||||||
|
homarr shell homarr-dev pnpm db:migration:sqlite:run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Image & Volume Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List local images with build provenance (source, revision, PR)
|
||||||
|
homarr images
|
||||||
|
|
||||||
|
# Delete local image
|
||||||
|
homarr images rm feature
|
||||||
|
|
||||||
|
# List data volumes with owner PR/tag and in-use status
|
||||||
|
homarr data
|
||||||
|
|
||||||
|
# Delete data volume (safely stops and removes owning container first)
|
||||||
|
homarr data rm homarr_feature_data
|
||||||
|
|
||||||
|
# Remove stopped containers (prompts without --force)
|
||||||
|
homarr prune --force
|
||||||
|
```
|
||||||
|
|
||||||
|
### Instance Operations & Diagnostics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List instances in a script-friendly table
|
||||||
|
homarr list
|
||||||
|
|
||||||
|
# Follow container logs
|
||||||
|
homarr logs homarr-dev
|
||||||
|
|
||||||
|
# Stop, restart, remove, or open container in browser
|
||||||
|
homarr stop homarr-dev
|
||||||
|
homarr restart homarr-dev
|
||||||
|
homarr remove homarr-dev
|
||||||
|
homarr open homarr-dev
|
||||||
|
|
||||||
|
# Verify Docker daemon, Engine API, GHCR access, and gh CLI authentication
|
||||||
|
homarr doctor
|
||||||
|
```
|
||||||
@@ -25,3 +25,27 @@ You should also create a backup of the database, in case you break something.
|
|||||||
4. Click "Execute" in the top right of the Window. 
|
4. Click "Execute" in the top right of the Window. 
|
||||||
5. Select /bin/bash and click Execute 
|
5. Select /bin/bash and click Execute 
|
||||||
6. Run the Homarr command and press enter (eg. homarr reset-owner-password) 
|
6. Run the Homarr command and press enter (eg. homarr reset-owner-password) 
|
||||||
|
|
||||||
|
## Build a local branch harness
|
||||||
|
|
||||||
|
The host-side operations CLI can build the current checkout and run it as a named, browser-ready Docker harness. This is separate from the recovery CLI described above and does not open the interactive developer TUI.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm cli harness setup feature-board
|
||||||
|
```
|
||||||
|
|
||||||
|
The command builds `homarr:feature-board`, starts a container with a persistent data volume, enables demo mode, and keeps demo mutations writable (`DEMO_READ_ONLY=false`). Docker assigns a free host port by default; use the URL printed by the command or query it later:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm cli harness port feature-board
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass environment overrides by repeating `--env`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm cli harness setup feature-board \
|
||||||
|
--env WORKSHOP_WEB_URL=https://example.test \
|
||||||
|
--env FEATURE_FLAG=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Other commands are `harness build <name>`, `harness run <name>`, and `harness stop <name>`. Use the returned URL with a browser automation harness for feature validation; do not assume the host port is `7575`.
|
||||||
+3
-2
@@ -30,8 +30,9 @@
|
|||||||
"db:studio": "pnpm -F db studio",
|
"db:studio": "pnpm -F db studio",
|
||||||
"dev": "pnpm -F nextjs dev",
|
"dev": "pnpm -F nextjs dev",
|
||||||
"dev:benchmark": "node --experimental-strip-types scripts/benchmarks/dev-board.mts",
|
"dev:benchmark": "node --experimental-strip-types scripts/benchmarks/dev-board.mts",
|
||||||
"dev:cli": "go -C tools/homarr-dev run .",
|
"dev:cli": "go -C tools/homarr-dev run -buildvcs=false .",
|
||||||
"dev:cli:install": "go -C tools/homarr-dev install .",
|
"dev:cli:build": "go -C tools/homarr-dev build -buildvcs=false .",
|
||||||
|
"dev:cli:install": "go -C tools/homarr-dev install -buildvcs=false .",
|
||||||
"dev:docs": "pnpm -F @homarr/docs dev",
|
"dev:docs": "pnpm -F @homarr/docs dev",
|
||||||
"docker:docs": "docker compose -f apps/workshop/docker-compose.yml up --build docs",
|
"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",
|
"docker:workshop": "docker compose -f apps/workshop/docker-compose.yml up --build workshop",
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { constants } from "node:fs";
|
||||||
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { execFile, spawn } from "node:child_process";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
import { command, number, positional, string } from "@drizzle-team/brocli";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const environmentSeparator = "\u0000";
|
||||||
|
const validEnvironmentName = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||||
|
const validHarnessName = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
|
||||||
|
|
||||||
|
const demoEnvironment: ReadonlyArray<[string, string]> = [
|
||||||
|
["DEMO_MODE", "true"],
|
||||||
|
["DEMO_READ_ONLY", "false"],
|
||||||
|
["UNSAFE_ENABLE_MOCK_INTEGRATION", "true"],
|
||||||
|
];
|
||||||
|
|
||||||
|
type HarnessRuntimeOptions = {
|
||||||
|
name: string;
|
||||||
|
env?: string;
|
||||||
|
port?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtimeOptions = () => ({
|
||||||
|
name: positional("name").required().desc("Name used for the image, container, and data volume"),
|
||||||
|
env: string("env").alias("e").desc("Environment override KEY=VALUE; repeat the flag for more values"),
|
||||||
|
port: number("port").int().min(0).max(65535).desc("Host port, or 0 for an automatically assigned port"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function validateHarnessName(name: string): string {
|
||||||
|
if (!validHarnessName.test(name)) {
|
||||||
|
throw new Error("Harness name must be 1-63 characters and contain only letters, numbers, '.', '_' or '-'");
|
||||||
|
}
|
||||||
|
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageName(name: string): string {
|
||||||
|
return `homarr:${validateHarnessName(name)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function containerName(name: string): string {
|
||||||
|
return `homarr-harness-${validateHarnessName(name)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function volumeName(name: string): string {
|
||||||
|
return `homarr-harness-${validateHarnessName(name)}-data`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasErrorCode(error: unknown, code: string): boolean {
|
||||||
|
return error instanceof Error && "code" in error && error.code === code;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandError(commandName: string, error: unknown): Error {
|
||||||
|
if (hasErrorCode(error, "ENOENT")) {
|
||||||
|
return new Error(`${commandName} is required but was not found on PATH`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return new Error(`${commandName} failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Error(`${commandName} failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execCommand(commandName: string, args: string[], cwd?: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const result = await execFileAsync(commandName, args, {
|
||||||
|
cwd,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
return result.stdout.trim();
|
||||||
|
} catch (error) {
|
||||||
|
throw commandError(commandName, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCommand(commandName: string, args: string[], cwd?: string): Promise<void> {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const child = spawn(commandName, args, {
|
||||||
|
cwd,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
child.once("error", (error) => reject(commandError(commandName, error)));
|
||||||
|
child.once("close", (code, signal) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
|
||||||
|
reject(new Error(`${commandName} failed with ${reason}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function repositoryRoot(): Promise<string> {
|
||||||
|
const root = await execCommand("git", ["rev-parse", "--show-toplevel"], process.cwd());
|
||||||
|
|
||||||
|
try {
|
||||||
|
await access(join(root, "Dockerfile"), constants.R_OK);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Run the harness command from a Homarr checkout containing a Dockerfile");
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvironmentOverrides(raw?: string): Map<string, string> {
|
||||||
|
const values = raw?.split(environmentSeparator) ?? [];
|
||||||
|
const environment = new Map(demoEnvironment);
|
||||||
|
|
||||||
|
for (const value of values) {
|
||||||
|
const separator = value.indexOf("=");
|
||||||
|
if (separator <= 0) {
|
||||||
|
throw new Error(`Invalid environment override ${JSON.stringify(value)}; use KEY=VALUE`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = value.slice(0, separator);
|
||||||
|
if (!validEnvironmentName.test(name)) {
|
||||||
|
throw new Error(`Invalid environment variable name ${JSON.stringify(name)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
environment.set(name, value.slice(separator + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEncryptionKey(value: string | undefined): value is string {
|
||||||
|
return value !== undefined && /^[a-f0-9]{64}$/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readEncryptionKey(path: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const value = (await readFile(path, "utf8")).trim();
|
||||||
|
if (isEncryptionKey(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Invalid encryption key in ${path}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (hasErrorCode(error, "ENOENT")) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function harnessEncryptionKey(name: string): Promise<string> {
|
||||||
|
if (isEncryptionKey(process.env.SECRET_ENCRYPTION_KEY)) {
|
||||||
|
return process.env.SECRET_ENCRYPTION_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configRoot = process.env.HOMARR_HARNESS_CONFIG_DIR ?? join(homedir(), ".config", "homarr");
|
||||||
|
const keyPath = join(configRoot, "local-harness", `${validateHarnessName(name)}.key`);
|
||||||
|
const existingKey = await readEncryptionKey(keyPath);
|
||||||
|
if (existingKey) {
|
||||||
|
return existingKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newKey = randomBytes(32).toString("hex");
|
||||||
|
await mkdir(join(configRoot, "local-harness"), { recursive: true, mode: 0o700 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFile(keyPath, `${newKey}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||||
|
return newKey;
|
||||||
|
} catch (error) {
|
||||||
|
if (!hasErrorCode(error, "EEXIST")) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const racedKey = await readEncryptionKey(keyPath);
|
||||||
|
if (racedKey) {
|
||||||
|
return racedKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Could not read the harness encryption key at ${keyPath}`, { cause: error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function harnessEnvironment(name: string, raw?: string): Promise<string[]> {
|
||||||
|
const environment = parseEnvironmentOverrides(raw);
|
||||||
|
|
||||||
|
if (!environment.has("SECRET_ENCRYPTION_KEY")) {
|
||||||
|
environment.set("SECRET_ENCRYPTION_KEY", await harnessEncryptionKey(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(environment, ([key, value]) => `${key}=${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildHarnessImage(name: string): Promise<void> {
|
||||||
|
const root = await repositoryRoot();
|
||||||
|
const image = imageName(name);
|
||||||
|
|
||||||
|
console.log(`Building ${image} from ${root}`);
|
||||||
|
await runCommand("docker", ["build", "--tag", image, "."], root);
|
||||||
|
console.log(`Built ${image}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeExistingContainer(name: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await execCommand("docker", ["rm", "--force", containerName(name)]);
|
||||||
|
} catch {
|
||||||
|
// A missing container is the normal first-run path. Docker reports it as
|
||||||
|
// an error, so the cleanup is intentionally best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishedPort(name: string): Promise<number> {
|
||||||
|
const output = await execCommand("docker", ["port", containerName(name), "7575/tcp"]);
|
||||||
|
|
||||||
|
for (const line of output.split("\n")) {
|
||||||
|
const match = line.trim().match(/:(\d+)$/);
|
||||||
|
if (match) {
|
||||||
|
return Number(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Container ${containerName(name)} has no published port for 7575/tcp`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startHarness(options: HarnessRuntimeOptions): Promise<void> {
|
||||||
|
const name = validateHarnessName(options.name);
|
||||||
|
const image = imageName(name);
|
||||||
|
const container = containerName(name);
|
||||||
|
const requestedPort = options.port ?? 0;
|
||||||
|
const environment = await harnessEnvironment(name, options.env);
|
||||||
|
|
||||||
|
await removeExistingContainer(name);
|
||||||
|
console.log(`Starting ${container} from ${image}`);
|
||||||
|
await runCommand("docker", [
|
||||||
|
"run",
|
||||||
|
"--detach",
|
||||||
|
"--name",
|
||||||
|
container,
|
||||||
|
"--publish",
|
||||||
|
`${requestedPort}:7575`,
|
||||||
|
"--volume",
|
||||||
|
`${volumeName(name)}:/appdata`,
|
||||||
|
...environment.flatMap((value) => ["--env", value]),
|
||||||
|
image,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const port = await publishedPort(name);
|
||||||
|
console.log(`Harness ${name} is running`);
|
||||||
|
console.log(` Container : ${container}`);
|
||||||
|
console.log(` Image : ${image}`);
|
||||||
|
console.log(` Port : ${port}`);
|
||||||
|
console.log(` URL : http://127.0.0.1:${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeHarnessEnvironmentArgs = (argv: string[]): string[] => {
|
||||||
|
const normalized = argv.slice(0, 2);
|
||||||
|
const args = argv.slice(2);
|
||||||
|
const environmentValues: string[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < args.length; index += 1) {
|
||||||
|
const arg = args[index];
|
||||||
|
if (arg === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg === "--env" || arg === "-e") {
|
||||||
|
const value = args[index + 1];
|
||||||
|
if (value === undefined) {
|
||||||
|
normalized.push(arg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
environmentValues.push(value);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg.startsWith("--env=")) {
|
||||||
|
environmentValues.push(arg.slice("--env=".length));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized.push(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (environmentValues.length > 0) {
|
||||||
|
normalized.push(`--env=${environmentValues.join(environmentSeparator)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const harnessBuild = command({
|
||||||
|
name: "build",
|
||||||
|
desc: "Build the current checkout as a named local Homarr image",
|
||||||
|
options: {
|
||||||
|
name: positional("name").required().desc("Local image tag"),
|
||||||
|
},
|
||||||
|
handler: async ({ name }) => buildHarnessImage(name),
|
||||||
|
});
|
||||||
|
|
||||||
|
const harnessRun = command({
|
||||||
|
name: "run",
|
||||||
|
aliases: ["start", "up"],
|
||||||
|
desc: "Run a named local image in writable demo mode",
|
||||||
|
options: runtimeOptions(),
|
||||||
|
handler: async (options) => startHarness(options),
|
||||||
|
});
|
||||||
|
|
||||||
|
const harnessSetup = command({
|
||||||
|
name: "setup",
|
||||||
|
aliases: ["create"],
|
||||||
|
desc: "Build the current checkout and run its writable demo harness",
|
||||||
|
options: runtimeOptions(),
|
||||||
|
handler: async (options) => {
|
||||||
|
await buildHarnessImage(options.name);
|
||||||
|
await startHarness(options);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const harnessPort = command({
|
||||||
|
name: "port",
|
||||||
|
desc: "Show the published host port and URL for a harness",
|
||||||
|
options: {
|
||||||
|
name: positional("name").required().desc("Harness name"),
|
||||||
|
},
|
||||||
|
handler: async ({ name }) => {
|
||||||
|
const port = await publishedPort(name);
|
||||||
|
console.log(`Port: ${port}`);
|
||||||
|
console.log(`URL: http://127.0.0.1:${port}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const harnessStop = command({
|
||||||
|
name: "stop",
|
||||||
|
aliases: ["down"],
|
||||||
|
desc: "Stop a local harness container and keep its data volume",
|
||||||
|
options: {
|
||||||
|
name: positional("name").required().desc("Harness name"),
|
||||||
|
},
|
||||||
|
handler: async ({ name }) => {
|
||||||
|
const container = containerName(name);
|
||||||
|
await execCommand("docker", ["stop", container]);
|
||||||
|
console.log(`Stopped ${container}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const harnessRoot = command({
|
||||||
|
name: "harness",
|
||||||
|
aliases: ["local-harness"],
|
||||||
|
desc: "Build and run a local branch in a browser-ready demo harness",
|
||||||
|
subcommands: [harnessBuild, harnessRun, harnessSetup, harnessPort, harnessStop],
|
||||||
|
});
|
||||||
+38
-10
@@ -1,14 +1,42 @@
|
|||||||
import { run } from "@drizzle-team/brocli";
|
import { run } from "@drizzle-team/brocli";
|
||||||
|
import type { Command } from "@drizzle-team/brocli";
|
||||||
|
|
||||||
import { fixUsernames } from "./commands/fix-usernames";
|
import { harnessRoot, normalizeHarnessEnvironmentArgs } from "./commands/harness";
|
||||||
import { integrationsRoot } from "./commands/integrations";
|
|
||||||
import { recreateAdmin } from "./commands/recreate-admin";
|
|
||||||
import { resetPassword } from "./commands/reset-password";
|
|
||||||
import { usersRoot } from "./commands/users";
|
|
||||||
|
|
||||||
const commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin];
|
const harnessCommandNames = new Set(["harness", "local-harness"]);
|
||||||
|
|
||||||
void run(commands, {
|
function isHarnessInvocation(argv: string[]): boolean {
|
||||||
name: "homarr-cli",
|
return argv.slice(2).some((argument) => harnessCommandNames.has(argument));
|
||||||
version: "1.0.0",
|
}
|
||||||
});
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const harnessInvocation = isHarnessInvocation(process.argv);
|
||||||
|
let commands: Command[];
|
||||||
|
|
||||||
|
if (harnessInvocation) {
|
||||||
|
commands = [harnessRoot];
|
||||||
|
} else {
|
||||||
|
const [{ fixUsernames }, { integrationsRoot }, { recreateAdmin }, { resetPassword }, { usersRoot }] =
|
||||||
|
await Promise.all([
|
||||||
|
import("./commands/fix-usernames"),
|
||||||
|
import("./commands/integrations"),
|
||||||
|
import("./commands/recreate-admin"),
|
||||||
|
import("./commands/reset-password"),
|
||||||
|
import("./commands/users"),
|
||||||
|
]);
|
||||||
|
commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin, harnessRoot];
|
||||||
|
}
|
||||||
|
|
||||||
|
let argSource = process.argv;
|
||||||
|
if (harnessInvocation) {
|
||||||
|
argSource = normalizeHarnessEnvironmentArgs(process.argv);
|
||||||
|
}
|
||||||
|
|
||||||
|
await run(commands, {
|
||||||
|
argSource,
|
||||||
|
name: "homarr-cli",
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
+42
-25
@@ -56,52 +56,69 @@ homarr dash Start on the instance dashboard
|
|||||||
homarr build <name> Build this checkout as homarr:<name>
|
homarr build <name> Build this checkout as homarr:<name>
|
||||||
homarr build --pr <number> Build a PR locally from a temporary checkout
|
homarr build --pr <number> Build a PR locally from a temporary checkout
|
||||||
homarr rebuild <name> Rebuild from recorded provenance
|
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 list Script-friendly instance list
|
||||||
homarr logs <container> Follow logs
|
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 open <container> Open in browser
|
||||||
homarr stop <container> Stop
|
homarr stop <container> Stop
|
||||||
homarr restart <container> Restart
|
homarr restart <container> Restart
|
||||||
homarr remove <container> Force-remove
|
homarr remove <container> Force-remove
|
||||||
homarr doctor Check Docker and optional CLI integrations
|
homarr doctor Check Docker, Engine API, and optional integrations
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development Browser
|
## Development Browser
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/ Filter by PR number, title, author, branch, CI, image, state, or port
|
/ Filter by PR number, title, author, branch, CI, image, state, or port
|
||||||
↑/↓ or j/k Select
|
↑/↓ or j/k Select row
|
||||||
Enter or Space Start the matching local build, otherwise pull remote; stop when running
|
Enter or Space Start the available image; choose local or remote when both exist; stop when running
|
||||||
R Build a remote PR locally in the background or rebuild a local image
|
R Build locally in background (or rebuild local image)
|
||||||
p Pull a remote PR image and redeploy, preserving port and volume
|
p Pull remote PR image in background and start container
|
||||||
m Toggle demo mode for the next launch
|
m Manage mode (chords: b rebuild, i delete image, d delete data, c remove container, p prune)
|
||||||
|
t Background tasks overlay (status, progress, cancellation)
|
||||||
|
Tab Cycle sidebar tab (logs / build / ci)
|
||||||
|
l Toggle sidebar visibility
|
||||||
|
L Pin logs to currently selected container
|
||||||
|
f Follow or pause logs
|
||||||
|
Page Up/Down Scroll logs
|
||||||
|
M Toggle demo mode for next launch
|
||||||
|
b Toggle bot PRs
|
||||||
o Open PR on GitHub
|
o Open PR on GitHub
|
||||||
a Open running app
|
a Open running instance in browser
|
||||||
l Toggle inline logs for the selected running PR
|
c Copy instance URL to clipboard
|
||||||
f Follow or pause inline logs
|
r Refresh data
|
||||||
Page Up/Down Scroll inline logs
|
1 / 2 / d Switch screen (development / instances)
|
||||||
b Show or hide bot PRs
|
? Help overlay
|
||||||
r Refresh
|
q Quit (cancels background tasks)
|
||||||
d Switch to the instance dashboard
|
|
||||||
q Quit; during a pull, cancel it
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Pull progress, layer completion, local build provenance, selected PR details, CI state, image availability, inline logs, and the final port remain inside the TUI.
|
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.
|
||||||
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
|
## Instance Dashboard
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/ Filter by name, image, state, health, port, or URL
|
/ Filter by name, image, state, health, port, or URL
|
||||||
↑/↓ or j/k Select
|
↑/↓ or j/k Select row
|
||||||
l Toggle inline logs
|
Enter or Space Start / stop selected instance
|
||||||
f Follow or pause logs
|
s Stop instance
|
||||||
|
S Restart instance
|
||||||
|
x Remove container (keeps data)
|
||||||
|
m Manage mode for selected instance
|
||||||
|
t Background tasks overlay
|
||||||
|
Tab Cycle sidebar tab (logs / build / ci)
|
||||||
|
l Toggle sidebar visibility
|
||||||
|
L Pin logs
|
||||||
|
f Follow / pause logs
|
||||||
Page Up/Down Scroll logs
|
Page Up/Down Scroll logs
|
||||||
s Stop
|
o Open running instance in browser
|
||||||
R Restart
|
c Copy URL to clipboard
|
||||||
x Remove with confirmation
|
R Rebuild from recorded provenance
|
||||||
o Open in browser
|
|
||||||
c Copy URL
|
|
||||||
r Refresh
|
r Refresh
|
||||||
d Switch to the development browser
|
1 / 2 / d Switch screen
|
||||||
|
? Help overlay
|
||||||
q Quit
|
q Quit
|
||||||
```
|
```
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/run"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) buildCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "build [name]",
|
||||||
|
Short: "Build a local Homarr image from this checkout or a pull request",
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
tag := ""
|
||||||
|
if len(args) > 0 {
|
||||||
|
tag = args[0]
|
||||||
|
}
|
||||||
|
if a.buildPR < 0 {
|
||||||
|
return fmt.Errorf("PR number must be positive")
|
||||||
|
}
|
||||||
|
if a.buildPR > 0 {
|
||||||
|
if tag == "" {
|
||||||
|
tag = fmt.Sprintf("pr-%d", a.buildPR)
|
||||||
|
}
|
||||||
|
return run.BuildPRImage(a.buildPR, tag)
|
||||||
|
}
|
||||||
|
if tag == "" {
|
||||||
|
return fmt.Errorf("image name is required when --pr is not set")
|
||||||
|
}
|
||||||
|
return run.BuildCurrentImage(tag, ".")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().IntVarP(&a.buildPR, "pr", "p", 0, "Build a pull request in a temporary checkout")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) rebuildCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "rebuild <name>",
|
||||||
|
Short: "Rebuild a local image from its recorded checkout or pull request",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
image, err := findLocalImage(cmd, args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return run.RebuildImage(image)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLocalImage resolves a tag or full reference to a local image, so both
|
||||||
|
// `homarr rebuild dev` and `homarr rebuild homarr:dev` work.
|
||||||
|
func findLocalImage(cmd *cobra.Command, name string) (docker.Image, error) {
|
||||||
|
images, err := docker.ListLocalImages(cmd.Context())
|
||||||
|
if err != nil {
|
||||||
|
return docker.Image{}, err
|
||||||
|
}
|
||||||
|
tag := name
|
||||||
|
if strings.Contains(name, ":") {
|
||||||
|
_, tag = docker.SplitImageReference(name)
|
||||||
|
}
|
||||||
|
for _, image := range images {
|
||||||
|
if image.Tag == tag {
|
||||||
|
return image, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return docker.Image{}, fmt.Errorf("local image homarr:%s was not found", tag)
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
// Package cli wires the command line surface. The interactive application
|
||||||
|
// lives in internal/tui; everything here is the scriptable path that does not
|
||||||
|
// need a terminal.
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/run"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/tui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// App holds command state. The three function fields are seams: they let the
|
||||||
|
// command wiring be tested without opening a terminal, and they are the only
|
||||||
|
// mutable state in the package — the previous version reached for package-level
|
||||||
|
// variables, which meant tests had to mutate globals to observe dispatch.
|
||||||
|
type App struct {
|
||||||
|
demo bool
|
||||||
|
pr int
|
||||||
|
env []string
|
||||||
|
detach bool
|
||||||
|
buildPR int
|
||||||
|
|
||||||
|
RunBrowser func() error
|
||||||
|
RunInstances func() error
|
||||||
|
Launch func(cmd *cobra.Command, args []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApp() *App {
|
||||||
|
app := &App{RunBrowser: tui.RunDev, RunInstances: tui.RunDashboard}
|
||||||
|
app.Launch = app.launch
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute runs the CLI.
|
||||||
|
func Execute() error {
|
||||||
|
args := os.Args[1:]
|
||||||
|
if len(args) > 0 && args[0] == "--" {
|
||||||
|
args = args[1:]
|
||||||
|
}
|
||||||
|
root := NewApp().Root()
|
||||||
|
root.SetArgs(args)
|
||||||
|
return root.Execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) Root() *cobra.Command {
|
||||||
|
root := &cobra.Command{
|
||||||
|
Use: "homarr",
|
||||||
|
Short: "Launch and manage Homarr Docker instances",
|
||||||
|
SilenceUsage: true,
|
||||||
|
Long: `Launch and manage Homarr Docker containers.
|
||||||
|
|
||||||
|
Start a local image, a pull-request build from ghcr.io, browse open pull
|
||||||
|
requests, or manage running instances — all from one interactive terminal
|
||||||
|
application where builds and pulls run in the background.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
homarr # interactive browser
|
||||||
|
homarr --pr 6390 # launch a pull-request image directly
|
||||||
|
homarr --pr 6390 -e FOO=bar # with environment overrides
|
||||||
|
homarr run dev # launch local homarr:dev
|
||||||
|
homarr run --detach dev # start in the background
|
||||||
|
homarr dash # open on the instances screen
|
||||||
|
homarr build feature # build this checkout as homarr:feature
|
||||||
|
homarr build --pr 6390 # build a pull request locally
|
||||||
|
homarr images # local images with provenance
|
||||||
|
homarr data # instance data volumes
|
||||||
|
homarr prune # remove stopped instances
|
||||||
|
homarr doctor # check Docker and optional integrations`,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: a.runRoot,
|
||||||
|
}
|
||||||
|
|
||||||
|
a.addRunFlags(root)
|
||||||
|
root.AddCommand(
|
||||||
|
a.runCommand(),
|
||||||
|
a.devCommand(),
|
||||||
|
a.dashCommand(),
|
||||||
|
a.buildCommand(),
|
||||||
|
a.rebuildCommand(),
|
||||||
|
a.listCommand(),
|
||||||
|
a.imagesCommand(),
|
||||||
|
a.dataCommand(),
|
||||||
|
a.pruneCommand(),
|
||||||
|
a.logsCommand(),
|
||||||
|
a.stopCommand(),
|
||||||
|
a.restartCommand(),
|
||||||
|
a.removeCommand(),
|
||||||
|
a.openCommand(),
|
||||||
|
a.shellCommand(),
|
||||||
|
a.ciCommand(),
|
||||||
|
a.doctorCommand(),
|
||||||
|
)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) addRunFlags(cmd *cobra.Command) {
|
||||||
|
cmd.Flags().BoolVarP(&a.demo, "demo", "m", false, "Demo mode (mock integrations)")
|
||||||
|
cmd.Flags().IntVarP(&a.pr, "pr", "p", 0, "Launch a pull-request image from ghcr.io")
|
||||||
|
cmd.Flags().StringArrayVarP(&a.env, "env", "e", nil, "Extra env vars (repeatable, KEY=VALUE)")
|
||||||
|
cmd.Flags().BoolVar(&a.detach, "detach", false, "Start in the background")
|
||||||
|
}
|
||||||
|
|
||||||
|
// runRoot opens the browser unless a launch flag was given, in which case the
|
||||||
|
// bare `homarr` invocation is shorthand for `homarr run`.
|
||||||
|
func (a *App) runRoot(cmd *cobra.Command, args []string) error {
|
||||||
|
for _, name := range []string{"pr", "env", "demo", "detach"} {
|
||||||
|
if cmd.Flags().Changed(name) {
|
||||||
|
return a.Launch(cmd, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a.RunBrowser()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) runCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "run [tag]",
|
||||||
|
Short: "Launch a local image or a pull-request image",
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error { return a.Launch(cmd, args) },
|
||||||
|
}
|
||||||
|
a.addRunFlags(cmd)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) devCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "dev",
|
||||||
|
Short: "Browse pull requests and local development images",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(*cobra.Command, []string) error { return a.RunBrowser() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) dashCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "dash",
|
||||||
|
Aliases: []string{"instances"},
|
||||||
|
Short: "Open the interactive application on the instances screen",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(*cobra.Command, []string) error { return a.RunInstances() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) launch(cmd *cobra.Command, args []string) error {
|
||||||
|
tag := ""
|
||||||
|
if len(args) > 0 {
|
||||||
|
tag = args[0]
|
||||||
|
}
|
||||||
|
if a.pr < 0 {
|
||||||
|
return fmt.Errorf("PR number must be positive")
|
||||||
|
}
|
||||||
|
if a.pr > 0 && tag != "" {
|
||||||
|
return fmt.Errorf("tag and --pr cannot be used together")
|
||||||
|
}
|
||||||
|
if a.pr == 0 && tag == "" {
|
||||||
|
if err := cmd.Usage(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("provide a tag or --pr")
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := run.BuildPlan(run.Options{
|
||||||
|
Context: cmd.Context(),
|
||||||
|
PR: a.pr,
|
||||||
|
Tag: tag,
|
||||||
|
Demo: a.demo,
|
||||||
|
Env: a.env,
|
||||||
|
FetchTitle: a.pr > 0,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Starting Homarr (%s)\n", plan.Label)
|
||||||
|
fmt.Printf(" Image : %s\n", plan.Image)
|
||||||
|
fmt.Printf(" Container : %s\n", plan.Name)
|
||||||
|
fmt.Printf(" Volume : %s\n", plan.Volume)
|
||||||
|
fmt.Printf(" Port : %d:7575\n", plan.HostPort)
|
||||||
|
fmt.Printf(" URL : http://localhost:%d\n", plan.HostPort)
|
||||||
|
if plan.PRNumber > 0 {
|
||||||
|
fmt.Printf(" PR : https://github.com/homarr-labs/homarr/pull/%d\n", plan.PRNumber)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if a.detach {
|
||||||
|
if err := run.StartDetached(plan); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Started in background: http://localhost:%d\n", plan.HostPort)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return run.StartForeground(plan)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRootCommandOpensBrowser(t *testing.T) {
|
||||||
|
want := errors.New("browser started")
|
||||||
|
app := NewApp()
|
||||||
|
app.RunBrowser = func() error { return want }
|
||||||
|
|
||||||
|
if err := app.runRoot(&cobra.Command{}, nil); !errors.Is(err, want) {
|
||||||
|
t.Fatalf("root command error = %v, want %v", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRootCommandLaunchesWhenRunFlagIsProvided(t *testing.T) {
|
||||||
|
want := errors.New("PR launched")
|
||||||
|
app := NewApp()
|
||||||
|
app.Launch = func(*cobra.Command, []string) error { return want }
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.Flags().Bool("pr", false, "")
|
||||||
|
if err := cmd.Flags().Set("pr", "true"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := app.runRoot(cmd, nil); !errors.Is(err, want) {
|
||||||
|
t.Fatalf("root command error = %v, want %v", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchRejectsTagWithPR(t *testing.T) {
|
||||||
|
app := NewApp()
|
||||||
|
// Registering the flags binds them to app.pr and resets it, so the command
|
||||||
|
// has to exist before the test sets the value it wants to assert on.
|
||||||
|
cmd := app.runCommand()
|
||||||
|
app.pr = 42
|
||||||
|
|
||||||
|
err := app.launch(cmd, []string{"feature"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "cannot be used together") {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchRejectsNegativePR(t *testing.T) {
|
||||||
|
app := NewApp()
|
||||||
|
cmd := app.runCommand()
|
||||||
|
app.pr = -1
|
||||||
|
|
||||||
|
err := app.launch(cmd, nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "must be positive") {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchRejectsMissingImage(t *testing.T) {
|
||||||
|
app := NewApp()
|
||||||
|
|
||||||
|
err := app.launch(app.runCommand(), nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "provide a tag or --pr") {
|
||||||
|
t.Fatalf("error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The root command doubles as `run`, so both must accept the same launch flags.
|
||||||
|
func TestRootAndRunShareLaunchFlags(t *testing.T) {
|
||||||
|
app := NewApp()
|
||||||
|
root := app.Root()
|
||||||
|
run, _, err := root.Find([]string{"run"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, name := range []string{"pr", "demo", "env", "detach"} {
|
||||||
|
if root.Flags().Lookup(name) == nil {
|
||||||
|
t.Fatalf("root command is missing the %q flag", name)
|
||||||
|
}
|
||||||
|
if run.Flags().Lookup(name) == nil {
|
||||||
|
t.Fatalf("run command is missing the %q flag", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCICommandRegisteredAndValidated(t *testing.T) {
|
||||||
|
app := NewApp()
|
||||||
|
root := app.Root()
|
||||||
|
ciCmd, _, err := root.Find([]string{"ci"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to find 'ci' command: %v", err)
|
||||||
|
}
|
||||||
|
if ciCmd.Name() != "ci" {
|
||||||
|
t.Fatalf("expected command name 'ci', got %q", ciCmd.Name())
|
||||||
|
}
|
||||||
|
if ciCmd.Flags().Lookup("watch") == nil {
|
||||||
|
t.Fatalf("expected --watch flag on 'ci' command")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test invalid PR argument
|
||||||
|
err = ciCmd.RunE(ciCmd, []string{"invalid-num"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid PR number") {
|
||||||
|
t.Fatalf("expected invalid PR number error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/registry"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) doctorCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "doctor",
|
||||||
|
Short: "Check Docker, the Engine API, GHCR and optional integrations",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
ctx, cancel := context.WithTimeout(cmd.Context(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
failed := false
|
||||||
|
if output, err := exec.CommandContext(ctx, "docker", "info", "--format", "{{.ServerVersion}}").CombinedOutput(); err != nil {
|
||||||
|
failed = true
|
||||||
|
fail("Docker daemon", strings.TrimSpace(string(output)))
|
||||||
|
} else {
|
||||||
|
pass("Docker daemon", strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Engine API is what gives pulls byte-level progress. Losing it
|
||||||
|
// is not fatal — the CLI falls back to parsing `docker pull` — so
|
||||||
|
// this reports a downgrade rather than a failure.
|
||||||
|
host := docker.EngineHost()
|
||||||
|
if docker.EngineAvailable(ctx) {
|
||||||
|
pass("Engine API", host)
|
||||||
|
} else {
|
||||||
|
optional("Engine API unreachable at "+host, "pull progress falls back to coarse layer states")
|
||||||
|
}
|
||||||
|
|
||||||
|
if tags, err := registry.PublishedTags(ctx, true); err != nil {
|
||||||
|
optional("GHCR tag listing unavailable", err.Error())
|
||||||
|
} else {
|
||||||
|
pass("GHCR test images", fmt.Sprintf("%d published tags", len(tags)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("gh"); err != nil {
|
||||||
|
optional("GitHub CLI not found", "required for pull-request features")
|
||||||
|
} else if output, err := exec.CommandContext(ctx, "gh", "auth", "status").CombinedOutput(); err != nil {
|
||||||
|
optional("GitHub CLI is not authenticated", strings.TrimSpace(string(output)))
|
||||||
|
} else {
|
||||||
|
pass("GitHub CLI authentication", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("homarr"); err != nil {
|
||||||
|
optional("homarr is not on PATH", "use `pnpm dev:cli -- ...` or run `pnpm dev:cli:install`")
|
||||||
|
} else {
|
||||||
|
pass("homarr is on PATH", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed {
|
||||||
|
return fmt.Errorf("one or more checks failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pass(label, detail string) {
|
||||||
|
if detail == "" {
|
||||||
|
fmt.Printf("%s %s\n", ui.OK.Render(ui.IconPass), label)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("%s %s: %s\n", ui.OK.Render(ui.IconPass), label, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(label, detail string) {
|
||||||
|
fmt.Printf("%s %s: %s\n", ui.Alert.Render(ui.IconFail), label, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func optional(label, detail string) {
|
||||||
|
if detail == "" {
|
||||||
|
fmt.Printf("%s %s (optional)\n", ui.Dim.Render(ui.IconNone), label)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("%s %s (optional; %s)\n", ui.Dim.Render(ui.IconNone), label, detail)
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/platform"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *App) listCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Aliases: []string{"ls", "ps"},
|
||||||
|
Short: "List Homarr instances without opening the application",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
containers, err := docker.List()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(containers) == 0 {
|
||||||
|
fmt.Println("No Homarr instances found.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
writer := newTable("NAME", "STATE", "PORT", "IMAGE", "STATUS")
|
||||||
|
for _, container := range containers {
|
||||||
|
row(writer, container.Name, container.State, container.HostPort(), container.Image, container.Status)
|
||||||
|
}
|
||||||
|
return writer.Flush()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) imagesCommand() *cobra.Command {
|
||||||
|
images := &cobra.Command{
|
||||||
|
Use: "images",
|
||||||
|
Aliases: []string{"image"},
|
||||||
|
Short: "List local Homarr images and their build provenance",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
found, err := docker.ListLocalImages(cmd.Context())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(found) == 0 {
|
||||||
|
fmt.Println("No local homarr images found. Build one with `homarr build <name>`.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
writer := newTable("IMAGE", "SIZE", "CREATED", "PR", "REVISION", "SOURCE")
|
||||||
|
for _, image := range found {
|
||||||
|
pr := ""
|
||||||
|
if image.PRNumber > 0 {
|
||||||
|
pr = fmt.Sprintf("#%d", image.PRNumber)
|
||||||
|
}
|
||||||
|
revision := image.Revision
|
||||||
|
if len(revision) > 12 {
|
||||||
|
revision = revision[:12]
|
||||||
|
}
|
||||||
|
source := image.Source
|
||||||
|
if source == "" {
|
||||||
|
source = "unknown"
|
||||||
|
}
|
||||||
|
row(writer, image.Reference(), image.Size, image.Created, pr, revision, source)
|
||||||
|
}
|
||||||
|
return writer.Flush()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
images.AddCommand(&cobra.Command{
|
||||||
|
Use: "rm <name>...",
|
||||||
|
Aliases: []string{"remove", "delete"},
|
||||||
|
Short: "Delete local Homarr images",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
for _, name := range args {
|
||||||
|
reference := name
|
||||||
|
if !strings.Contains(reference, ":") {
|
||||||
|
reference = "homarr:" + reference
|
||||||
|
}
|
||||||
|
if err := docker.RemoveImage(cmd.Context(), reference); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("removed " + reference)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) dataCommand() *cobra.Command {
|
||||||
|
data := &cobra.Command{
|
||||||
|
Use: "data",
|
||||||
|
Aliases: []string{"volumes", "volume"},
|
||||||
|
Short: "List Homarr instance data volumes",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
volumes, err := docker.ListVolumes(cmd.Context(), true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(volumes) == 0 {
|
||||||
|
fmt.Println("No Homarr data volumes found.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
writer := newTable("VOLUME", "SIZE", "IN USE", "BELONGS TO")
|
||||||
|
for _, volume := range volumes {
|
||||||
|
owner := volume.Tag()
|
||||||
|
if number := volume.PRNumber(); number > 0 {
|
||||||
|
owner = fmt.Sprintf("PR #%d", number)
|
||||||
|
}
|
||||||
|
inUse := "no"
|
||||||
|
if volume.InUse {
|
||||||
|
inUse = "yes"
|
||||||
|
}
|
||||||
|
row(writer, volume.Name, volume.Size, inUse, owner)
|
||||||
|
}
|
||||||
|
return writer.Flush()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data.AddCommand(&cobra.Command{
|
||||||
|
Use: "rm <volume>...",
|
||||||
|
Aliases: []string{"remove", "delete"},
|
||||||
|
Short: "Delete instance data, removing the owning container first",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
for _, name := range args {
|
||||||
|
// Docker refuses to delete an attached volume, and the data is
|
||||||
|
// going away regardless, so the container goes first.
|
||||||
|
_ = docker.Remove(strings.TrimSuffix(name, "_data"))
|
||||||
|
if err := docker.RemoveVolume(cmd.Context(), name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("removed " + name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) pruneCommand() *cobra.Command {
|
||||||
|
var force bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "prune",
|
||||||
|
Short: "Remove stopped Homarr containers, keeping their data",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
containers, err := docker.List()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stopped := make([]docker.Container, 0, len(containers))
|
||||||
|
for _, container := range containers {
|
||||||
|
if !container.Running() {
|
||||||
|
stopped = append(stopped, container)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(stopped) == 0 {
|
||||||
|
fmt.Println("No stopped Homarr containers.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !force {
|
||||||
|
fmt.Printf("Would remove %d stopped containers:\n", len(stopped))
|
||||||
|
for _, container := range stopped {
|
||||||
|
fmt.Println(" " + container.Name)
|
||||||
|
}
|
||||||
|
fmt.Println("\nRe-run with --force to remove them.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, container := range stopped {
|
||||||
|
if err := docker.Remove(container.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("removed " + container.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVarP(&force, "force", "f", false, "Actually remove the containers")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) logsCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "logs <container>",
|
||||||
|
Short: "Follow logs of a running instance",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error { return docker.FollowLogs(args[0]) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) stopCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "stop <container>",
|
||||||
|
Short: "Stop a running instance",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error { return docker.Stop(args[0]) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) restartCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "restart <container>",
|
||||||
|
Short: "Restart an instance",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error { return docker.Restart(args[0]) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) removeCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "remove <container>",
|
||||||
|
Short: "Force-remove an instance, keeping its data",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error { return docker.Remove(args[0]) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) openCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "open <container>",
|
||||||
|
Short: "Open a running instance in the browser",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
containers, err := docker.List()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, container := range containers {
|
||||||
|
if container.Name != args[0] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !container.Running() {
|
||||||
|
return fmt.Errorf("container %q is not running", args[0])
|
||||||
|
}
|
||||||
|
port := container.HostPort()
|
||||||
|
if port == "" {
|
||||||
|
return fmt.Errorf("container %q does not publish the Homarr port", args[0])
|
||||||
|
}
|
||||||
|
return platform.OpenURL("http://localhost:" + port)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("container %q was not found", args[0])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) shellCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "shell <container> [cmd...]",
|
||||||
|
Aliases: []string{"sh", "exec"},
|
||||||
|
Short: "Open an interactive shell or run a command in a running Homarr container",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
shellArgs := []string{"sh"}
|
||||||
|
if len(args) > 1 {
|
||||||
|
shellArgs = args[1:]
|
||||||
|
}
|
||||||
|
return docker.ExecInteractive(cmd.Context(), args[0], shellArgs...)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ciCommand() *cobra.Command {
|
||||||
|
var watch bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ci [pr]",
|
||||||
|
Aliases: []string{"checks", "check"},
|
||||||
|
Short: "View or watch GitHub CI checks for a pull request",
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
var prNumber int
|
||||||
|
if len(args) > 0 {
|
||||||
|
num, err := strconv.Atoi(strings.TrimPrefix(args[0], "#"))
|
||||||
|
if err != nil || num <= 0 {
|
||||||
|
return fmt.Errorf("invalid PR number %q", args[0])
|
||||||
|
}
|
||||||
|
prNumber = num
|
||||||
|
} else {
|
||||||
|
currentPR, err := gh.CurrentBranchPR(cmd.Context())
|
||||||
|
if err != nil || currentPR == nil {
|
||||||
|
return fmt.Errorf("could not detect PR for current branch: specify a PR number with `homarr ci <number>`")
|
||||||
|
}
|
||||||
|
prNumber = currentPR.Number
|
||||||
|
}
|
||||||
|
|
||||||
|
if watch {
|
||||||
|
return gh.WatchPRChecks(cmd.Context(), prNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
checks, err := gh.GetPRChecks(cmd.Context(), prNumber, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(checks) == 0 {
|
||||||
|
fmt.Printf("No CI checks found for PR #%d.\n", prNumber)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
writer := newTable("CHECK", "STATUS", "DURATION", "WORKFLOW", "LINK")
|
||||||
|
for _, check := range checks {
|
||||||
|
dur := check.Duration()
|
||||||
|
if dur == "" {
|
||||||
|
dur = "—"
|
||||||
|
}
|
||||||
|
row(writer, check.Name, check.State, dur, check.Workflow, check.Link)
|
||||||
|
}
|
||||||
|
return writer.Flush()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVarP(&watch, "watch", "w", false, "Watch CI checks until completion")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTable(headers ...string) *tabwriter.Writer {
|
||||||
|
writer := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
||||||
|
row(writer, headers...)
|
||||||
|
return writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func row(writer *tabwriter.Writer, cells ...string) {
|
||||||
|
fmt.Fprintln(writer, strings.Join(cells, "\t"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// engineAPIVersion is the oldest Engine API version that supports every
|
||||||
|
// endpoint this CLI relies on. The daemon negotiates downwards on its own.
|
||||||
|
const engineAPIVersion = "v1.41"
|
||||||
|
|
||||||
|
// PullMessage is one record of the JSON progress stream returned by the Docker
|
||||||
|
// Engine when an image is pulled. Unlike the plain-text output of `docker
|
||||||
|
// pull`, this stream carries per-layer byte counters even when stdout is not a
|
||||||
|
// terminal, which is what makes a real progress animation possible.
|
||||||
|
type PullMessage struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Detail struct {
|
||||||
|
Current int64 `json:"current"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
} `json:"progressDetail"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type engineEndpoint struct {
|
||||||
|
client *http.Client
|
||||||
|
base string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
var engineOnce struct {
|
||||||
|
sync.Once
|
||||||
|
endpoint engineEndpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineHost reports the Docker endpoint this machine talks to, honouring
|
||||||
|
// DOCKER_HOST and the active `docker context` (OrbStack, Colima, and Docker
|
||||||
|
// Desktop all install their own socket).
|
||||||
|
func EngineHost() string {
|
||||||
|
if host := strings.TrimSpace(os.Getenv("DOCKER_HOST")); host != "" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
out, err := exec.CommandContext(ctx, "docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}").Output()
|
||||||
|
if err == nil {
|
||||||
|
if host := strings.TrimSpace(string(out)); host != "" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "unix:///var/run/docker.sock"
|
||||||
|
}
|
||||||
|
|
||||||
|
func engine() (*http.Client, string, error) {
|
||||||
|
engineOnce.Do(func() { engineOnce.endpoint = dialEngine(EngineHost()) })
|
||||||
|
e := engineOnce.endpoint
|
||||||
|
return e.client, e.base, e.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialEngine(host string) engineEndpoint {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(host, "unix://"):
|
||||||
|
path := strings.TrimPrefix(host, "unix://")
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, "unix", path)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return engineEndpoint{client: &http.Client{Transport: transport}, base: "http://docker/" + engineAPIVersion}
|
||||||
|
case strings.HasPrefix(host, "tcp://"), strings.HasPrefix(host, "http://"):
|
||||||
|
authority := host
|
||||||
|
for _, scheme := range []string{"tcp://", "http://"} {
|
||||||
|
authority = strings.TrimPrefix(authority, scheme)
|
||||||
|
}
|
||||||
|
return engineEndpoint{client: &http.Client{}, base: "http://" + authority + "/" + engineAPIVersion}
|
||||||
|
default:
|
||||||
|
return engineEndpoint{err: fmt.Errorf("unsupported Docker endpoint %q", host)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SplitImageReference separates a reference into its repository and tag,
|
||||||
|
// tolerating registries that carry an explicit port.
|
||||||
|
func SplitImageReference(reference string) (repository, tag string) {
|
||||||
|
slash := strings.LastIndex(reference, "/")
|
||||||
|
colon := strings.LastIndex(reference, ":")
|
||||||
|
if colon > slash {
|
||||||
|
return reference[:colon], reference[colon+1:]
|
||||||
|
}
|
||||||
|
return reference, "latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullStream starts an image pull against the Engine API and returns its
|
||||||
|
// progress records. The channel closes when the pull finishes; a message
|
||||||
|
// carrying Error describes a failure. Cancelling ctx aborts the pull.
|
||||||
|
func PullStream(ctx context.Context, image, platform string) (<-chan PullMessage, error) {
|
||||||
|
client, base, err := engine()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
repository, tag := SplitImageReference(image)
|
||||||
|
query := url.Values{"fromImage": {repository}, "tag": {tag}}
|
||||||
|
if platform != "" {
|
||||||
|
query.Set("platform", platform)
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/images/create?"+query.Encode(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("contact Docker engine: %w", err)
|
||||||
|
}
|
||||||
|
if response.StatusCode < 200 || response.StatusCode > 299 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
|
||||||
|
_ = response.Body.Close()
|
||||||
|
return nil, fmt.Errorf("pull %s: %s", image, engineErrorMessage(response.Status, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
messages := make(chan PullMessage, 64)
|
||||||
|
go func() {
|
||||||
|
defer close(messages)
|
||||||
|
defer response.Body.Close()
|
||||||
|
decoder := json.NewDecoder(response.Body)
|
||||||
|
for {
|
||||||
|
var message PullMessage
|
||||||
|
if err := decoder.Decode(&message); err != nil {
|
||||||
|
if err != io.EOF && ctx.Err() == nil {
|
||||||
|
select {
|
||||||
|
case messages <- PullMessage{Error: err.Error()}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case messages <- message:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func engineErrorMessage(status string, body []byte) string {
|
||||||
|
var payload struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err == nil && payload.Message != "" {
|
||||||
|
return payload.Message
|
||||||
|
}
|
||||||
|
if trimmed := strings.TrimSpace(string(body)); trimmed != "" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineAvailable reports whether the Engine API answers, so callers can fall
|
||||||
|
// back to the `docker` binary instead of failing outright.
|
||||||
|
func EngineAvailable(ctx context.Context) bool {
|
||||||
|
client, base, err := engine()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/_ping", nil)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, response.Body)
|
||||||
|
return response.StatusCode >= 200 && response.StatusCode < 300
|
||||||
|
}
|
||||||
@@ -378,27 +378,29 @@ func IsPortConflict(err error) bool {
|
|||||||
strings.Contains(message, "failed programming external connectivity")
|
strings.Contains(message, "failed programming external connectivity")
|
||||||
}
|
}
|
||||||
|
|
||||||
func Stop(name string) error {
|
func Stop(name string) error { return StopContext(context.Background(), name) }
|
||||||
cmd := exec.Command("docker", "stop", name)
|
|
||||||
out, err := cmd.CombinedOutput()
|
func Restart(name string) error { return RestartContext(context.Background(), name) }
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
func Remove(name string) error { return RemoveContext(context.Background(), name) }
|
||||||
}
|
|
||||||
return nil
|
// The context-aware variants let a background job be cancelled while the daemon
|
||||||
|
// is still thinking, which matters when Docker is wedged and the user wants the
|
||||||
|
// interface back.
|
||||||
|
func StopContext(ctx context.Context, name string) error {
|
||||||
|
return runContainerCommand(ctx, "stop", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Restart(name string) error {
|
func RestartContext(ctx context.Context, name string) error {
|
||||||
cmd := exec.Command("docker", "restart", name)
|
return runContainerCommand(ctx, "restart", name)
|
||||||
out, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Remove(name string) error {
|
func RemoveContext(ctx context.Context, name string) error {
|
||||||
cmd := exec.Command("docker", "rm", "-f", name)
|
return runContainerCommand(ctx, "rm", "-f", name)
|
||||||
out, err := cmd.CombinedOutput()
|
}
|
||||||
|
|
||||||
|
func runContainerCommand(ctx context.Context, args ...string) error {
|
||||||
|
out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||||
}
|
}
|
||||||
@@ -425,3 +427,15 @@ func FollowLogs(name string) error {
|
|||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = os.Stderr
|
||||||
return cmd.Run()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ExecInteractive(ctx context.Context, name string, command ...string) error {
|
||||||
|
if len(command) == 0 {
|
||||||
|
command = []string{"sh"}
|
||||||
|
}
|
||||||
|
args := append([]string{"exec", "-it", name}, command...)
|
||||||
|
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ type Image struct {
|
|||||||
Source string
|
Source string
|
||||||
Revision string
|
Revision string
|
||||||
PRNumber int
|
PRNumber int
|
||||||
|
Size string
|
||||||
|
Created string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (image Image) Reference() string {
|
func (image Image) Reference() string {
|
||||||
@@ -34,9 +36,11 @@ func (image Image) Reference() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type imageListRow struct {
|
type imageListRow struct {
|
||||||
ID string
|
ID string
|
||||||
Repository string
|
Repository string
|
||||||
Tag string
|
Tag string
|
||||||
|
Size string
|
||||||
|
CreatedSince string
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseImageList(output []byte) ([]Image, error) {
|
func parseImageList(output []byte) ([]Image, error) {
|
||||||
@@ -50,7 +54,7 @@ func parseImageList(output []byte) ([]Image, error) {
|
|||||||
return nil, fmt.Errorf("parse local Docker image: %w", err)
|
return nil, fmt.Errorf("parse local Docker image: %w", err)
|
||||||
}
|
}
|
||||||
if row.Repository == "homarr" && row.Tag != "<none>" {
|
if row.Repository == "homarr" && row.Tag != "<none>" {
|
||||||
images = append(images, Image{ID: row.ID, Tag: row.Tag})
|
images = append(images, Image{ID: row.ID, Tag: row.Tag, Size: row.Size, Created: row.CreatedSince})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return images, nil
|
return images, nil
|
||||||
@@ -86,22 +90,39 @@ func inspectImageLabels(ctx context.Context, images []Image) ([]map[string]strin
|
|||||||
args = append(args, image.Reference())
|
args = append(args, image.Reference())
|
||||||
}
|
}
|
||||||
out, err := exec.CommandContext(ctx, "docker", args...).Output()
|
out, err := exec.CommandContext(ctx, "docker", args...).Output()
|
||||||
if err != 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) {
|
||||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
labelsByImage := make([]map[string]string, len(images))
|
||||||
if len(lines) != len(images) {
|
valid := true
|
||||||
return nil, fmt.Errorf("inspect returned %d images, expected %d", len(lines), len(images))
|
for index, line := range lines {
|
||||||
|
labelsByImage[index] = make(map[string]string)
|
||||||
|
if line == "null" || line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(line), &labelsByImage[index]); err != nil {
|
||||||
|
valid = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if valid {
|
||||||
|
return labelsByImage, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
labelsByImage := make([]map[string]string, len(images))
|
labelsByImage := make([]map[string]string, len(images))
|
||||||
for index, line := range lines {
|
for index, image := range images {
|
||||||
labelsByImage[index] = make(map[string]string)
|
labelsByImage[index] = make(map[string]string)
|
||||||
if line == "null" {
|
singleOut, singleErr := exec.CommandContext(ctx, "docker", "image", "inspect", "--format", "{{json .Config.Labels}}", image.Reference()).Output()
|
||||||
|
if singleErr != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(line), &labelsByImage[index]); err != nil {
|
trimmed := strings.TrimSpace(string(singleOut))
|
||||||
return nil, fmt.Errorf("parse labels for %s: %w", images[index].Reference(), err)
|
if trimmed == "" || trimmed == "null" {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
_ = json.Unmarshal([]byte(trimmed), &labelsByImage[index])
|
||||||
}
|
}
|
||||||
return labelsByImage, nil
|
return labelsByImage, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,410 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Layer is the download and extraction state of a single image layer.
|
||||||
|
type Layer struct {
|
||||||
|
ID string
|
||||||
|
Status string
|
||||||
|
Current int64
|
||||||
|
Total int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fraction reports how far this layer is towards being usable, treating
|
||||||
|
// download and extraction as two halves of the work. Docker restarts the byte
|
||||||
|
// counters between the two phases, so they are weighted rather than summed.
|
||||||
|
func (l Layer) Fraction() float64 {
|
||||||
|
switch normalizeLayerStatus(l.Status) {
|
||||||
|
case "already exists", "pull complete":
|
||||||
|
return 1
|
||||||
|
case "downloading":
|
||||||
|
return 0.5 * ratio(l.Current, l.Total)
|
||||||
|
case "verifying checksum", "download complete":
|
||||||
|
return 0.5
|
||||||
|
case "extracting":
|
||||||
|
return 0.5 + 0.5*ratio(l.Current, l.Total)
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Layer) Done() bool {
|
||||||
|
status := normalizeLayerStatus(l.Status)
|
||||||
|
return status == "pull complete" || status == "already exists"
|
||||||
|
}
|
||||||
|
|
||||||
|
func ratio(current, total int64) float64 {
|
||||||
|
if total <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if current >= total {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return float64(current) / float64(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLayerStatus(status string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullTracker folds a pull progress stream into stable, ordered per-layer
|
||||||
|
// state so the UI can redraw it every frame without flicker.
|
||||||
|
type PullTracker struct {
|
||||||
|
order []string
|
||||||
|
layers map[string]*Layer
|
||||||
|
status string
|
||||||
|
started time.Time
|
||||||
|
sampleAt time.Time
|
||||||
|
sampled int64
|
||||||
|
rate float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPullTracker() *PullTracker {
|
||||||
|
now := time.Now()
|
||||||
|
return &PullTracker{layers: make(map[string]*Layer), started: now, sampleAt: now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply folds one Engine API record into the tracker.
|
||||||
|
func (t *PullTracker) Apply(message PullMessage) {
|
||||||
|
if message.ID == "" {
|
||||||
|
if message.Status != "" {
|
||||||
|
t.status = message.Status
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Digest and manifest records reuse the ID field but are not layers.
|
||||||
|
if strings.HasPrefix(message.ID, "sha256:") || len(message.ID) > 24 {
|
||||||
|
t.status = message.Status
|
||||||
|
return
|
||||||
|
}
|
||||||
|
layer, exists := t.layers[message.ID]
|
||||||
|
if !exists {
|
||||||
|
layer = &Layer{ID: message.ID}
|
||||||
|
t.layers[message.ID] = layer
|
||||||
|
t.order = append(t.order, message.ID)
|
||||||
|
}
|
||||||
|
if message.Status != "" {
|
||||||
|
layer.Status = message.Status
|
||||||
|
}
|
||||||
|
if message.Detail.Total > 0 {
|
||||||
|
layer.Total = message.Detail.Total
|
||||||
|
}
|
||||||
|
if message.Detail.Current > 0 || message.Detail.Total > 0 {
|
||||||
|
layer.Current = message.Detail.Current
|
||||||
|
}
|
||||||
|
if layer.Done() {
|
||||||
|
layer.Current = layer.Total
|
||||||
|
}
|
||||||
|
t.sample()
|
||||||
|
}
|
||||||
|
|
||||||
|
var plainPullPattern = regexp.MustCompile(`^([0-9a-zA-Z_-]{4,64}):\s+(.+)$`)
|
||||||
|
|
||||||
|
// ApplyLine folds a line of plain `docker pull` output into the tracker. It is
|
||||||
|
// the fallback used when the Engine API is unreachable; the resulting progress
|
||||||
|
// is coarser because the CLI omits byte counters when stdout is not a TTY.
|
||||||
|
func (t *PullTracker) ApplyLine(line string) {
|
||||||
|
line = strings.TrimSpace(ansi.Strip(line))
|
||||||
|
if line == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
match := plainPullPattern.FindStringSubmatch(line)
|
||||||
|
if match == nil {
|
||||||
|
t.status = line
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status, current, total := parsePlainProgress(match[2])
|
||||||
|
t.Apply(PullMessage{
|
||||||
|
ID: match[1],
|
||||||
|
Status: status,
|
||||||
|
Detail: struct {
|
||||||
|
Current int64 `json:"current"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}{Current: current, Total: total},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var plainSizePattern = regexp.MustCompile(`([0-9.]+\s*[A-Za-z]*B)\s*/\s*([0-9.]+\s*[A-Za-z]*B)`)
|
||||||
|
|
||||||
|
func parsePlainProgress(rest string) (status string, current, total int64) {
|
||||||
|
status = rest
|
||||||
|
if bracket := strings.Index(rest, "["); bracket >= 0 {
|
||||||
|
status = strings.TrimSpace(rest[:bracket])
|
||||||
|
}
|
||||||
|
if match := plainSizePattern.FindStringSubmatch(rest); match != nil {
|
||||||
|
current = ParseSize(match[1])
|
||||||
|
total = ParseSize(match[2])
|
||||||
|
}
|
||||||
|
return status, current, total
|
||||||
|
}
|
||||||
|
|
||||||
|
var sizeUnits = map[string]int64{
|
||||||
|
"b": 1,
|
||||||
|
"kb": 1000, "mb": 1000 * 1000, "gb": 1000 * 1000 * 1000, "tb": 1000 * 1000 * 1000 * 1000,
|
||||||
|
"kib": 1024, "mib": 1024 * 1024, "gib": 1024 * 1024 * 1024, "tib": 1024 * 1024 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSize converts a human readable Docker size such as "12.3MB" into bytes.
|
||||||
|
func ParseSize(value string) int64 {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
index := 0
|
||||||
|
for index < len(value) && (value[index] >= '0' && value[index] <= '9' || value[index] == '.') {
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
number, err := strconv.ParseFloat(strings.TrimSpace(value[:index]), 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
unit := strings.TrimSpace(value[index:])
|
||||||
|
if unit == "" {
|
||||||
|
return int64(number)
|
||||||
|
}
|
||||||
|
multiplier, known := sizeUnits[unit]
|
||||||
|
if !known {
|
||||||
|
return int64(number)
|
||||||
|
}
|
||||||
|
return int64(number * float64(multiplier))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatSize renders a byte count the way the Docker CLI does.
|
||||||
|
func FormatSize(bytes int64) string {
|
||||||
|
const unit = 1000
|
||||||
|
if bytes < unit {
|
||||||
|
return fmt.Sprintf("%dB", bytes)
|
||||||
|
}
|
||||||
|
value := float64(bytes)
|
||||||
|
for _, suffix := range []string{"kB", "MB", "GB", "TB"} {
|
||||||
|
value /= unit
|
||||||
|
if value < unit {
|
||||||
|
return fmt.Sprintf("%.1f%s", value, suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1fPB", value/unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *PullTracker) sample() {
|
||||||
|
now := time.Now()
|
||||||
|
elapsed := now.Sub(t.sampleAt)
|
||||||
|
if elapsed < 500*time.Millisecond {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current, _ := t.Bytes()
|
||||||
|
if delta := current - t.sampled; delta > 0 {
|
||||||
|
t.rate = float64(delta) / elapsed.Seconds()
|
||||||
|
}
|
||||||
|
t.sampled = current
|
||||||
|
t.sampleAt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layers returns the layers in the order Docker announced them.
|
||||||
|
func (t *PullTracker) Layers() []Layer {
|
||||||
|
layers := make([]Layer, 0, len(t.order))
|
||||||
|
for _, id := range t.order {
|
||||||
|
layers = append(layers, *t.layers[id])
|
||||||
|
}
|
||||||
|
return layers
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes reports downloaded and total bytes across every layer that announced a
|
||||||
|
// size.
|
||||||
|
func (t *PullTracker) Bytes() (current, total int64) {
|
||||||
|
for _, layer := range t.layers {
|
||||||
|
if layer.Total <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += layer.Total
|
||||||
|
if layer.Done() {
|
||||||
|
current += layer.Total
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current += min(layer.Current, layer.Total)
|
||||||
|
}
|
||||||
|
return current, total
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete reports how many layers are fully pulled out of how many are known.
|
||||||
|
func (t *PullTracker) Complete() (complete, total int) {
|
||||||
|
for _, layer := range t.layers {
|
||||||
|
total++
|
||||||
|
if layer.Done() {
|
||||||
|
complete++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return complete, total
|
||||||
|
}
|
||||||
|
|
||||||
|
// Percent is the overall progress of the pull in the range [0,1].
|
||||||
|
func (t *PullTracker) Percent() float64 {
|
||||||
|
if len(t.order) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
sum := 0.0
|
||||||
|
for _, id := range t.order {
|
||||||
|
sum += t.layers[id].Fraction()
|
||||||
|
}
|
||||||
|
return sum / float64(len(t.order))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate is the recent download throughput in bytes per second.
|
||||||
|
func (t *PullTracker) Rate() float64 { return t.rate }
|
||||||
|
|
||||||
|
// Status is the most recent message that was not tied to a single layer.
|
||||||
|
func (t *PullTracker) Status() string { return t.status }
|
||||||
|
|
||||||
|
// Elapsed reports how long the pull has been running.
|
||||||
|
func (t *PullTracker) Elapsed() time.Duration { return time.Since(t.started) }
|
||||||
|
|
||||||
|
// BuildStep is one BuildKit vertex, such as a Dockerfile instruction.
|
||||||
|
type BuildStep struct {
|
||||||
|
ID string
|
||||||
|
Label string
|
||||||
|
Status string
|
||||||
|
Output []string
|
||||||
|
Done bool
|
||||||
|
Cached bool
|
||||||
|
Failed bool
|
||||||
|
Canceled bool
|
||||||
|
Elapsed string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildTracker folds `docker build --progress=plain` output into ordered steps.
|
||||||
|
type BuildTracker struct {
|
||||||
|
order []string
|
||||||
|
steps map[string]*BuildStep
|
||||||
|
total int
|
||||||
|
started time.Time
|
||||||
|
last string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBuildTracker() *BuildTracker {
|
||||||
|
return &BuildTracker{steps: make(map[string]*BuildStep), started: time.Now()}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
buildLinePattern = regexp.MustCompile(`^#(\d+)\s*(.*)$`)
|
||||||
|
buildStagePattern = regexp.MustCompile(`^\[[^\]]*?(\d+)/(\d+)\]`)
|
||||||
|
buildTimePattern = regexp.MustCompile(`^\d+\.\d+\s`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ApplyLine folds one line of BuildKit plain output into the tracker.
|
||||||
|
func (t *BuildTracker) ApplyLine(line string) {
|
||||||
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
trimmed := strings.TrimSpace(ansi.Strip(line))
|
||||||
|
if trimmed == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
match := buildLinePattern.FindStringSubmatch(trimmed)
|
||||||
|
if match == nil {
|
||||||
|
t.last = trimmed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, rest := match[1], strings.TrimSpace(match[2])
|
||||||
|
// BuildKit reserves #0 for its own preamble ("building with ... driver").
|
||||||
|
// It never completes, so counting it would leave every finished build one
|
||||||
|
// step short of done.
|
||||||
|
if id == "0" {
|
||||||
|
t.last = rest
|
||||||
|
return
|
||||||
|
}
|
||||||
|
step, exists := t.steps[id]
|
||||||
|
if !exists {
|
||||||
|
step = &BuildStep{ID: id}
|
||||||
|
t.steps[id] = step
|
||||||
|
t.order = append(t.order, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(rest, "DONE"):
|
||||||
|
step.Done = true
|
||||||
|
step.Status = "done"
|
||||||
|
step.Elapsed = strings.TrimSpace(strings.TrimPrefix(rest, "DONE"))
|
||||||
|
case rest == "CACHED":
|
||||||
|
step.Done, step.Cached, step.Status = true, true, "cached"
|
||||||
|
case strings.HasPrefix(rest, "ERROR"):
|
||||||
|
step.Failed, step.Done, step.Status = true, true, "failed"
|
||||||
|
step.Output = appendStepOutput(step.Output, rest)
|
||||||
|
t.last = rest
|
||||||
|
case strings.HasPrefix(rest, "CANCELED"):
|
||||||
|
step.Done, step.Status, step.Canceled = true, "canceled", true
|
||||||
|
case buildTimePattern.MatchString(rest):
|
||||||
|
// `#12 1.234 <output>` — timestamped output from inside the step.
|
||||||
|
_, output, _ := strings.Cut(rest, " ")
|
||||||
|
if output = strings.TrimSpace(output); output != "" {
|
||||||
|
step.Output = appendStepOutput(step.Output, output)
|
||||||
|
t.last = output
|
||||||
|
}
|
||||||
|
case step.Label == "":
|
||||||
|
step.Label = rest
|
||||||
|
if stage := buildStagePattern.FindStringSubmatch(rest); stage != nil {
|
||||||
|
if total, err := strconv.Atoi(stage[2]); err == nil && total > t.total {
|
||||||
|
t.total = total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
step.Output = appendStepOutput(step.Output, rest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendStepOutput(output []string, line string) []string {
|
||||||
|
const retained = 200
|
||||||
|
output = append(output, line)
|
||||||
|
if len(output) > retained {
|
||||||
|
output = output[len(output)-retained:]
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
// Steps returns every announced step in declaration order.
|
||||||
|
func (t *BuildTracker) Steps() []BuildStep {
|
||||||
|
steps := make([]BuildStep, 0, len(t.order))
|
||||||
|
for _, id := range t.order {
|
||||||
|
steps = append(steps, *t.steps[id])
|
||||||
|
}
|
||||||
|
return steps
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active returns the step BuildKit is currently working on, if any.
|
||||||
|
func (t *BuildTracker) Active() (BuildStep, bool) {
|
||||||
|
for index := len(t.order) - 1; index >= 0; index-- {
|
||||||
|
if step := t.steps[t.order[index]]; !step.Done && step.Label != "" {
|
||||||
|
return *step, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return BuildStep{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete reports finished steps out of the best known total. The total grows
|
||||||
|
// as BuildKit announces stages, so it is never allowed to shrink below what has
|
||||||
|
// already been seen. A step BuildKit reported as canceled or failed is finished
|
||||||
|
// but not complete — counting it would report a killed build as fully built.
|
||||||
|
func (t *BuildTracker) Complete() (complete, total int) {
|
||||||
|
for _, step := range t.steps {
|
||||||
|
if step.Done && !step.Canceled && !step.Failed {
|
||||||
|
complete++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return complete, max(t.total, len(t.order))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Percent is the overall build progress in the range [0,1].
|
||||||
|
func (t *BuildTracker) Percent() float64 {
|
||||||
|
complete, total := t.Complete()
|
||||||
|
if total == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return ratio(int64(complete), int64(total))
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastMessage is the most recent human readable line, used for error context.
|
||||||
|
func (t *BuildTracker) LastMessage() string { return t.last }
|
||||||
|
|
||||||
|
// Elapsed reports how long the build has been running.
|
||||||
|
func (t *BuildTracker) Elapsed() time.Duration { return time.Since(t.started) }
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLayerFraction(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
status string
|
||||||
|
current int64
|
||||||
|
total int64
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"pull complete", 0, 0, 1.0},
|
||||||
|
{"already exists", 0, 0, 1.0},
|
||||||
|
{"download complete", 100, 100, 0.5},
|
||||||
|
{"verifying checksum", 100, 100, 0.5},
|
||||||
|
{"downloading", 50, 100, 0.25},
|
||||||
|
{"extracting", 50, 100, 0.75},
|
||||||
|
{"waiting", 0, 100, 0.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
layer := Layer{Status: c.status, Current: c.current, Total: c.total}
|
||||||
|
if got := layer.Fraction(); got != c.want {
|
||||||
|
t.Errorf("Layer{%q, %d, %d}.Fraction() = %v, want %v", c.status, c.current, c.total, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSizeAndFormatSize(t *testing.T) {
|
||||||
|
if got := ParseSize("12.3MB"); got != 12300000 {
|
||||||
|
t.Errorf("ParseSize(12.3MB) = %d, want 12300000", got)
|
||||||
|
}
|
||||||
|
if got := ParseSize("10MiB"); got != 10*1024*1024 {
|
||||||
|
t.Errorf("ParseSize(10MiB) = %d, want %d", got, 10*1024*1024)
|
||||||
|
}
|
||||||
|
if got := ParseSize("500B"); got != 500 {
|
||||||
|
t.Errorf("ParseSize(500B) = %d, want 500", got)
|
||||||
|
}
|
||||||
|
if got := ParseSize("invalid"); got != 0 {
|
||||||
|
t.Errorf("ParseSize(invalid) = %d, want 0", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := FormatSize(500); got != "500B" {
|
||||||
|
t.Errorf("FormatSize(500) = %q, want 500B", got)
|
||||||
|
}
|
||||||
|
if got := FormatSize(1500000); got != "1.5MB" {
|
||||||
|
t.Errorf("FormatSize(1500000) = %q, want 1.5MB", got)
|
||||||
|
}
|
||||||
|
if got := FormatSize(2500000000); got != "2.5GB" {
|
||||||
|
t.Errorf("FormatSize(2500000000) = %q, want 2.5GB", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPullTrackerApplyAndProgress(t *testing.T) {
|
||||||
|
tracker := NewPullTracker()
|
||||||
|
tracker.Apply(PullMessage{
|
||||||
|
ID: "layer1",
|
||||||
|
Status: "Downloading",
|
||||||
|
Detail: struct {
|
||||||
|
Current int64 `json:"current"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}{Current: 50, Total: 100},
|
||||||
|
})
|
||||||
|
tracker.Apply(PullMessage{
|
||||||
|
ID: "layer2",
|
||||||
|
Status: "Extracting",
|
||||||
|
Detail: struct {
|
||||||
|
Current int64 `json:"current"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
}{Current: 50, Total: 100},
|
||||||
|
})
|
||||||
|
|
||||||
|
layers := tracker.Layers()
|
||||||
|
if len(layers) != 2 {
|
||||||
|
t.Fatalf("expected 2 layers, got %d", len(layers))
|
||||||
|
}
|
||||||
|
if layers[0].ID != "layer1" || layers[1].ID != "layer2" {
|
||||||
|
t.Errorf("unexpected layers order: %v, %v", layers[0].ID, layers[1].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentBytes, totalBytes := tracker.Bytes()
|
||||||
|
if currentBytes != 100 || totalBytes != 200 {
|
||||||
|
t.Errorf("Bytes() = %d, %d; want 100, 200", currentBytes, totalBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
complete, total := tracker.Complete()
|
||||||
|
if complete != 0 || total != 2 {
|
||||||
|
t.Errorf("Complete() = %d, %d; want 0, 2", complete, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
if percent := tracker.Percent(); percent != 0.5 {
|
||||||
|
t.Errorf("Percent() = %v, want 0.5", percent)
|
||||||
|
}
|
||||||
|
|
||||||
|
tracker.ApplyLine("c0ffee123456: Pull complete")
|
||||||
|
if len(tracker.Layers()) != 3 {
|
||||||
|
t.Fatalf("expected 3 layers after ApplyLine, got %d", len(tracker.Layers()))
|
||||||
|
}
|
||||||
|
complete, total = tracker.Complete()
|
||||||
|
if complete != 1 || total != 3 {
|
||||||
|
t.Errorf("Complete() = %d, %d; want 1, 3", complete, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTrackerStepsAndProgress(t *testing.T) {
|
||||||
|
tracker := NewBuildTracker()
|
||||||
|
|
||||||
|
tracker.ApplyLine("#0 building with \"default\" instance using docker driver")
|
||||||
|
if len(tracker.Steps()) != 0 {
|
||||||
|
t.Fatalf("expected step #0 to be skipped, got %d steps", len(tracker.Steps()))
|
||||||
|
}
|
||||||
|
|
||||||
|
tracker.ApplyLine("#1 [internal] load build definition from Dockerfile")
|
||||||
|
tracker.ApplyLine("#1 transferring dockerfile: 3.42kB done")
|
||||||
|
tracker.ApplyLine("#1 DONE 0.0s")
|
||||||
|
|
||||||
|
tracker.ApplyLine("#2 [1/3] FROM docker.io/library/alpine:3.20")
|
||||||
|
tracker.ApplyLine("#2 CACHED")
|
||||||
|
|
||||||
|
tracker.ApplyLine("#3 [2/3] RUN echo step2")
|
||||||
|
tracker.ApplyLine("#3 0.123 hello from build")
|
||||||
|
|
||||||
|
steps := tracker.Steps()
|
||||||
|
if len(steps) != 3 {
|
||||||
|
t.Fatalf("expected 3 steps, got %d", len(steps))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !steps[0].Done {
|
||||||
|
t.Errorf("step #1 should be marked Done")
|
||||||
|
}
|
||||||
|
if !steps[1].Cached || !steps[1].Done {
|
||||||
|
t.Errorf("step #2 should be marked Cached and Done")
|
||||||
|
}
|
||||||
|
if steps[2].Done {
|
||||||
|
t.Errorf("step #3 should not be Done yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
active, running := tracker.Active()
|
||||||
|
if !running || active.ID != "3" {
|
||||||
|
t.Errorf("Active() = %+v, %v; want ID 3, true", active, running)
|
||||||
|
}
|
||||||
|
|
||||||
|
complete, total := tracker.Complete()
|
||||||
|
if complete != 2 || total < 3 {
|
||||||
|
t.Errorf("Complete() = %d, %d; want 2, >=3", complete, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
if last := tracker.LastMessage(); last != "hello from build" {
|
||||||
|
t.Errorf("LastMessage() = %q, want 'hello from build'", last)
|
||||||
|
}
|
||||||
|
|
||||||
|
tracker.ApplyLine("#3 CANCELED")
|
||||||
|
steps = tracker.Steps()
|
||||||
|
if !steps[2].Canceled {
|
||||||
|
t.Errorf("step #3 should be marked Canceled")
|
||||||
|
}
|
||||||
|
complete, _ = tracker.Complete()
|
||||||
|
if complete != 2 {
|
||||||
|
t.Errorf("Complete() after cancel = %d, want 2", complete)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StreamLine is one line of output from a streamed command. Err is set only on
|
||||||
|
// the final line, which also has Done set.
|
||||||
|
type StreamLine struct {
|
||||||
|
Text string
|
||||||
|
Err error
|
||||||
|
Done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitLines is a bufio.SplitFunc that treats both newline and carriage return
|
||||||
|
// as terminators so in-place progress updates arrive as discrete lines.
|
||||||
|
func splitLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||||
|
for index, char := range data {
|
||||||
|
if char == '\n' || char == '\r' {
|
||||||
|
return index + 1, data[:index], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if atEOF && len(data) > 0 {
|
||||||
|
return len(data), data, nil
|
||||||
|
}
|
||||||
|
return 0, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamCommand runs cmd with stdout and stderr merged and delivers its output
|
||||||
|
// line by line. The channel is closed after a final Done line carrying the exit
|
||||||
|
// status. Cancelling the command's context terminates the process.
|
||||||
|
func StreamCommand(cmd *exec.Cmd) (<-chan StreamLine, error) {
|
||||||
|
reader, writer := io.Pipe()
|
||||||
|
cmd.Stdout = writer
|
||||||
|
cmd.Stderr = writer
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
_ = reader.Close()
|
||||||
|
_ = writer.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait runs on its own goroutine and closes the write end, which is what
|
||||||
|
// gives the scanner an EOF. Waiting first and closing afterwards deadlocks:
|
||||||
|
// the scanner would block forever on a pipe this process still holds open.
|
||||||
|
// Wait only returns once every byte has been copied into the pipe, so no
|
||||||
|
// output can be lost to the close.
|
||||||
|
waited := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
err := cmd.Wait()
|
||||||
|
_ = writer.Close()
|
||||||
|
waited <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
lines := make(chan StreamLine, 256)
|
||||||
|
go func() {
|
||||||
|
defer close(lines)
|
||||||
|
scanner := bufio.NewScanner(reader)
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
scanner.Split(splitLines)
|
||||||
|
last := ""
|
||||||
|
for scanner.Scan() {
|
||||||
|
text := strings.TrimRight(scanner.Text(), "\r\n")
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
last = text
|
||||||
|
lines <- StreamLine{Text: text}
|
||||||
|
}
|
||||||
|
_ = reader.Close()
|
||||||
|
err := <-waited
|
||||||
|
if err != nil && last != "" {
|
||||||
|
err = fmt.Errorf("%w: %s", err, strings.TrimSpace(last))
|
||||||
|
}
|
||||||
|
lines <- StreamLine{Done: true, Err: err}
|
||||||
|
}()
|
||||||
|
return lines, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamContainerLogs follows a container's logs, replaying the last tail lines
|
||||||
|
// first. The stream ends when the container stops or ctx is cancelled.
|
||||||
|
func StreamContainerLogs(ctx context.Context, name string, tail int) (<-chan StreamLine, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "docker", "logs", "--follow", "--tail", fmt.Sprint(tail), name)
|
||||||
|
cmd.Cancel = func() error { return cmd.Process.Kill() }
|
||||||
|
return StreamCommand(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildCommandStreaming returns a build command configured for BuildKit's plain
|
||||||
|
// progress output, which is the only format that can be parsed line by line.
|
||||||
|
func BuildCommandStreaming(ctx context.Context, options BuildOptions) (*exec.Cmd, error) {
|
||||||
|
cmd, err := BuildCommand(ctx, options)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cmd.Args = append(cmd.Args[:len(cmd.Args)-1], "--progress=plain", cmd.Args[len(cmd.Args)-1])
|
||||||
|
cmd.Env = append(cmd.Environ(), "BUILDKIT_PROGRESS=plain", "DOCKER_BUILDKIT=1")
|
||||||
|
return cmd, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Volume is a Docker volume that belongs to a Homarr instance.
|
||||||
|
type Volume struct {
|
||||||
|
Name string
|
||||||
|
Driver string
|
||||||
|
Mountpoint string
|
||||||
|
Size string
|
||||||
|
InUse bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRNumber returns the pull request a volume stores data for, or zero.
|
||||||
|
func (v Volume) PRNumber() int {
|
||||||
|
var number int
|
||||||
|
if _, err := fmt.Sscanf(v.Name, "homarr_pr_%d_data", &number); err == nil {
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag returns the local image tag a volume stores data for, or an empty string.
|
||||||
|
func (v Volume) Tag() string {
|
||||||
|
if v.PRNumber() > 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
name := strings.TrimPrefix(v.Name, "homarr_")
|
||||||
|
if name == v.Name {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(name, "_data")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListVolumes returns the Homarr data volumes, optionally annotated with disk
|
||||||
|
// usage when includeSizes is true.
|
||||||
|
func ListVolumes(ctx context.Context, includeSizes bool) ([]Volume, error) {
|
||||||
|
out, err := exec.CommandContext(ctx, "docker", "volume", "ls", "--filter", "name=homarr", "--format", "{{json .}}").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list Homarr volumes: %w", err)
|
||||||
|
}
|
||||||
|
volumes, err := parseVolumeList(out)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var sizes map[string]string
|
||||||
|
if includeSizes {
|
||||||
|
sizes = volumeSizes(ctx)
|
||||||
|
}
|
||||||
|
inUse := volumesInUse(ctx)
|
||||||
|
for index := range volumes {
|
||||||
|
if sizes != nil {
|
||||||
|
if size, known := sizes[volumes[index].Name]; known {
|
||||||
|
volumes[index].Size = size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
volumes[index].InUse = inUse[volumes[index].Name]
|
||||||
|
}
|
||||||
|
return volumes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseVolumeList(output []byte) ([]Volume, error) {
|
||||||
|
volumes := make([]Volume, 0)
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var row struct {
|
||||||
|
Name string
|
||||||
|
Driver string
|
||||||
|
Mountpoint string
|
||||||
|
Size string
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(line), &row); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse Docker volume: %w", err)
|
||||||
|
}
|
||||||
|
// buildx keeps its cache in volumes that match the same name filter.
|
||||||
|
if !strings.HasPrefix(row.Name, "homarr_") && row.Name != "homarr" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
size := row.Size
|
||||||
|
if size == "N/A" {
|
||||||
|
size = ""
|
||||||
|
}
|
||||||
|
volumes = append(volumes, Volume{Name: row.Name, Driver: row.Driver, Mountpoint: row.Mountpoint, Size: size})
|
||||||
|
}
|
||||||
|
return volumes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// volumeSizes reads reclaimable space per volume. `docker system df -v` is slow
|
||||||
|
// and optional, so failures degrade to unknown sizes rather than an error.
|
||||||
|
func volumeSizes(ctx context.Context) map[string]string {
|
||||||
|
sizes := make(map[string]string)
|
||||||
|
out, err := exec.CommandContext(ctx, "docker", "system", "df", "-v", "--format", "{{json .Volumes}}").Output()
|
||||||
|
if err != nil {
|
||||||
|
return sizes
|
||||||
|
}
|
||||||
|
var rows []struct {
|
||||||
|
Name string
|
||||||
|
Size string
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &rows); err != nil {
|
||||||
|
return sizes
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Size != "" && row.Size != "N/A" {
|
||||||
|
sizes[row.Name] = row.Size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sizes
|
||||||
|
}
|
||||||
|
|
||||||
|
func volumesInUse(ctx context.Context) map[string]bool {
|
||||||
|
inUse := make(map[string]bool)
|
||||||
|
out, err := exec.CommandContext(ctx, "docker", "ps", "-a", "--filter", "name=homarr", "--format", "{{.Mounts}}").Output()
|
||||||
|
if err != nil {
|
||||||
|
return inUse
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
|
for _, mount := range strings.Split(line, ",") {
|
||||||
|
if mount = strings.TrimSpace(mount); mount != "" {
|
||||||
|
inUse[mount] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return inUse
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveVolume deletes a volume and the instance data it holds.
|
||||||
|
func RemoveVolume(ctx context.Context, name string) error {
|
||||||
|
out, err := exec.CommandContext(ctx, "docker", "volume", "rm", name).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("remove volume %s: %w: %s", name, err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveImage force-deletes a local image, including any tags pointing at it.
|
||||||
|
func RemoveImage(ctx context.Context, reference string) error {
|
||||||
|
out, err := exec.CommandContext(ctx, "docker", "image", "rm", "-f", reference).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("remove image %s: %w: %s", reference, err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -197,3 +198,151 @@ func GetPR(ctx context.Context, number int) (*PR, error) {
|
|||||||
UpdatedAt: r.UpdatedAt,
|
UpdatedAt: r.UpdatedAt,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Check struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Bucket string `json:"bucket"`
|
||||||
|
Workflow string `json:"workflow"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
StartedAt string `json:"startedAt"`
|
||||||
|
CompletedAt string `json:"completedAt"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Event string `json:"event"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var checkBucketKeys = map[string]string{
|
||||||
|
"pass": "pass",
|
||||||
|
"fail": "fail",
|
||||||
|
"pending": "pending",
|
||||||
|
"skipping": "skipping",
|
||||||
|
"cancel": "cancel",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Check) BucketKey() string {
|
||||||
|
if key, found := checkBucketKeys[strings.ToLower(c.Bucket)]; found {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return "pending"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Check) IsPending() bool {
|
||||||
|
return c.Bucket == "pending" || c.State == "PENDING" || c.State == "IN_PROGRESS" || c.State == "STARTING"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Check) IsSuccess() bool {
|
||||||
|
return c.Bucket == "pass" || c.State == "SUCCESS"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Check) IsFailure() bool {
|
||||||
|
return c.Bucket == "fail" || c.State == "FAILURE" || c.State == "ERROR"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Check) IsSkipped() bool {
|
||||||
|
return c.Bucket == "skipping" || c.Bucket == "cancel" || c.State == "SKIPPED" || c.State == "CANCELLED"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Check) Duration() string {
|
||||||
|
if c.StartedAt == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
start, err := time.Parse(time.RFC3339, c.StartedAt)
|
||||||
|
if err != nil || start.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
end := time.Now()
|
||||||
|
if c.CompletedAt != "" {
|
||||||
|
if tEnd, err := time.Parse(time.RFC3339, c.CompletedAt); err == nil && !tEnd.IsZero() {
|
||||||
|
end = tEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d := end.Sub(start).Round(time.Second)
|
||||||
|
if d < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if d < time.Minute {
|
||||||
|
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||||
|
}
|
||||||
|
if d < time.Hour {
|
||||||
|
return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
|
||||||
|
}
|
||||||
|
|
||||||
|
var prChecksCache = struct {
|
||||||
|
sync.Mutex
|
||||||
|
entries map[int]cachedChecks
|
||||||
|
}{entries: make(map[int]cachedChecks)}
|
||||||
|
|
||||||
|
type cachedChecks struct {
|
||||||
|
checks []Check
|
||||||
|
fetchedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPRChecks(ctx context.Context, number int, refresh bool) ([]Check, error) {
|
||||||
|
if number <= 0 {
|
||||||
|
return nil, fmt.Errorf("invalid PR number: %d", number)
|
||||||
|
}
|
||||||
|
prChecksCache.Lock()
|
||||||
|
entry, found := prChecksCache.entries[number]
|
||||||
|
prChecksCache.Unlock()
|
||||||
|
if !refresh && found && time.Since(entry.fetchedAt) < 15*time.Second {
|
||||||
|
return append([]Check(nil), entry.checks...), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "gh", "pr", "checks", fmt.Sprint(number),
|
||||||
|
"--repo", Repo,
|
||||||
|
"--json", "name,state,bucket,workflow,link,startedAt,completedAt,description,event",
|
||||||
|
)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil && len(out) == 0 {
|
||||||
|
return nil, commandError(err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
var checks []Check
|
||||||
|
if jsonErr := json.Unmarshal(out, &checks); jsonErr != nil {
|
||||||
|
if err != nil {
|
||||||
|
return nil, commandError(err, out)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("parse checks JSON: %w", jsonErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
prChecksCache.Lock()
|
||||||
|
prChecksCache.entries[number] = cachedChecks{checks: checks, fetchedAt: time.Now()}
|
||||||
|
prChecksCache.Unlock()
|
||||||
|
|
||||||
|
return checks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WatchPRChecks(ctx context.Context, number int) error {
|
||||||
|
cmd := exec.CommandContext(ctx, "gh", "pr", "checks", fmt.Sprint(number), "--repo", Repo, "--watch")
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func CurrentBranchPR(ctx context.Context) (*PR, error) {
|
||||||
|
out, err := exec.CommandContext(ctx, "gh", "pr", "view",
|
||||||
|
"--repo", Repo,
|
||||||
|
"--json", "number,title,author,headRefName,headRefOid,updatedAt,isDraft,statusCheckRollup",
|
||||||
|
).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return nil, commandError(err, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
var r rawPR
|
||||||
|
if err := json.Unmarshal(out, &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &PR{
|
||||||
|
Number: r.Number,
|
||||||
|
Title: r.Title,
|
||||||
|
Author: r.Author.Login,
|
||||||
|
HeadRef: r.HeadRefName,
|
||||||
|
HeadSHA: r.HeadRefOID,
|
||||||
|
CIState: rollupState(r.StatusCheckRollup),
|
||||||
|
IsDraft: r.IsDraft,
|
||||||
|
UpdatedAt: r.UpdatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -89,3 +89,47 @@ func TestListPRsExcludesBotsIntegration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckHelpersAndDuration(t *testing.T) {
|
||||||
|
c1 := Check{
|
||||||
|
Name: "Test Job",
|
||||||
|
State: "SUCCESS",
|
||||||
|
Bucket: "pass",
|
||||||
|
StartedAt: "2026-08-21T10:00:00Z",
|
||||||
|
CompletedAt: "2026-08-21T10:05:30Z",
|
||||||
|
}
|
||||||
|
if !c1.IsSuccess() || c1.IsFailure() || c1.IsPending() || c1.IsSkipped() {
|
||||||
|
t.Fatalf("unexpected state helpers for c1: success=%v, fail=%v, pend=%v, skip=%v", c1.IsSuccess(), c1.IsFailure(), c1.IsPending(), c1.IsSkipped())
|
||||||
|
}
|
||||||
|
if d := c1.Duration(); d != "5m30s" {
|
||||||
|
t.Fatalf("Duration() = %q, want 5m30s", d)
|
||||||
|
}
|
||||||
|
|
||||||
|
c2 := Check{
|
||||||
|
Name: "Fail Job",
|
||||||
|
State: "FAILURE",
|
||||||
|
Bucket: "fail",
|
||||||
|
}
|
||||||
|
if !c2.IsFailure() {
|
||||||
|
t.Fatalf("expected c2 to be failure")
|
||||||
|
}
|
||||||
|
if c2.Duration() != "" {
|
||||||
|
t.Fatalf("expected empty duration for missing timestamps")
|
||||||
|
}
|
||||||
|
|
||||||
|
c3 := Check{
|
||||||
|
Name: "Pending Job",
|
||||||
|
Bucket: "pending",
|
||||||
|
}
|
||||||
|
if !c3.IsPending() {
|
||||||
|
t.Fatalf("expected c3 to be pending")
|
||||||
|
}
|
||||||
|
|
||||||
|
c4 := Check{
|
||||||
|
Name: "Skipped Job",
|
||||||
|
Bucket: "skipping",
|
||||||
|
}
|
||||||
|
if !c4.IsSkipped() {
|
||||||
|
t.Fatalf("expected c4 to be skipped")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
// Package logs keeps live `docker logs --follow` streams for running
|
||||||
|
// containers so the sidebar can show output as it happens instead of polling.
|
||||||
|
package logs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// retainedLines bounds memory per container. Homarr is chatty at boot, so the
|
||||||
|
// buffer has to be deep enough to still contain the startup banner once the
|
||||||
|
// app settles.
|
||||||
|
const retainedLines = 2000
|
||||||
|
|
||||||
|
// maxStreams bounds how many containers are followed at once. Each stream is a
|
||||||
|
// `docker logs --follow` process plus a buffer, which is cheap for the handful
|
||||||
|
// of instances a developer normally runs but not for thirty. The least recently
|
||||||
|
// viewed stream is dropped when the cap is reached; re-attaching replays the
|
||||||
|
// tail, so nothing is lost beyond the scrollback.
|
||||||
|
const maxStreams = 6
|
||||||
|
|
||||||
|
// State describes what a stream is currently doing.
|
||||||
|
type State int
|
||||||
|
|
||||||
|
const (
|
||||||
|
StateStarting State = iota
|
||||||
|
StateStreaming
|
||||||
|
StateStopped
|
||||||
|
StateFailed
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stream is the retained output of one container.
|
||||||
|
type Stream struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
name string
|
||||||
|
lines []string
|
||||||
|
state State
|
||||||
|
err error
|
||||||
|
cancel context.CancelFunc
|
||||||
|
since time.Time
|
||||||
|
used time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry owns every active log stream and notifies a single listener when new
|
||||||
|
// output arrives.
|
||||||
|
type Registry struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
streams map[string]*Stream
|
||||||
|
notify chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegistry() *Registry {
|
||||||
|
return &Registry{streams: make(map[string]*Stream), notify: make(chan struct{}, 1)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changed is a coalescing wake-up channel, mirroring task.Manager. A container
|
||||||
|
// logging a thousand lines a second still costs one redraw per frame.
|
||||||
|
func (r *Registry) Changed() <-chan struct{} { return r.notify }
|
||||||
|
|
||||||
|
func (r *Registry) touch() {
|
||||||
|
select {
|
||||||
|
case r.notify <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach starts following a container's logs if it is not already being
|
||||||
|
// followed. It returns immediately; output arrives on the Changed channel.
|
||||||
|
func (r *Registry) Attach(name string) {
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
if _, exists := r.streams[name]; exists {
|
||||||
|
r.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
stream := &Stream{name: name, state: StateStarting, cancel: cancel, since: now, used: now}
|
||||||
|
r.streams[name] = stream
|
||||||
|
evicted := r.evictLocked()
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
for _, stale := range evicted {
|
||||||
|
stale.cancel()
|
||||||
|
}
|
||||||
|
go r.follow(ctx, stream)
|
||||||
|
r.touch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// evictLocked drops the least recently viewed streams above the cap. The caller
|
||||||
|
// holds the lock and cancels the returned streams outside it.
|
||||||
|
func (r *Registry) evictLocked() []*Stream {
|
||||||
|
evicted := make([]*Stream, 0)
|
||||||
|
for len(r.streams) > maxStreams {
|
||||||
|
var oldest *Stream
|
||||||
|
for _, stream := range r.streams {
|
||||||
|
if oldest == nil || stream.used.Before(oldest.used) {
|
||||||
|
oldest = stream
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if oldest == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
delete(r.streams, oldest.name)
|
||||||
|
evicted = append(evicted, oldest)
|
||||||
|
}
|
||||||
|
return evicted
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) follow(ctx context.Context, stream *Stream) {
|
||||||
|
lines, err := docker.StreamContainerLogs(ctx, stream.name, 400)
|
||||||
|
if err != nil {
|
||||||
|
stream.set(StateFailed, err)
|
||||||
|
r.touch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stream.set(StateStreaming, nil)
|
||||||
|
r.touch()
|
||||||
|
for line := range lines {
|
||||||
|
if line.Done {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
stream.set(StateStopped, nil)
|
||||||
|
} else if line.Err != nil {
|
||||||
|
stream.set(StateFailed, line.Err)
|
||||||
|
} else {
|
||||||
|
stream.set(StateStopped, nil)
|
||||||
|
}
|
||||||
|
r.touch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stream.append(line.Text)
|
||||||
|
r.touch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) append(line string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.lines = append(s.lines, line)
|
||||||
|
if len(s.lines) > retainedLines {
|
||||||
|
s.lines = s.lines[len(s.lines)-retainedLines:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stream) set(state State, err error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.state, s.err = state, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detach stops following a container and drops its buffer.
|
||||||
|
func (r *Registry) Detach(name string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
stream, exists := r.streams[name]
|
||||||
|
delete(r.streams, name)
|
||||||
|
r.mu.Unlock()
|
||||||
|
if exists && stream.cancel != nil {
|
||||||
|
stream.cancel()
|
||||||
|
}
|
||||||
|
r.touch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retain stops streams for containers that no longer exist, so a removed
|
||||||
|
// instance does not keep a dead `docker logs` process around.
|
||||||
|
func (r *Registry) Retain(names []string) {
|
||||||
|
live := make(map[string]bool, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
live[name] = true
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
stale := make([]*Stream, 0)
|
||||||
|
for name, stream := range r.streams {
|
||||||
|
if !live[name] {
|
||||||
|
stale = append(stale, stream)
|
||||||
|
delete(r.streams, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
for _, stream := range stale {
|
||||||
|
if stream.cancel != nil {
|
||||||
|
stream.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(stale) > 0 {
|
||||||
|
r.touch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops every stream.
|
||||||
|
func (r *Registry) Close() { r.Retain(nil) }
|
||||||
|
|
||||||
|
// Snapshot is an immutable view of one stream.
|
||||||
|
type Snapshot struct {
|
||||||
|
Name string
|
||||||
|
Lines []string
|
||||||
|
State State
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text joins the retained lines for rendering into a viewport.
|
||||||
|
func (s Snapshot) Text() string { return strings.Join(s.Lines, "\n") }
|
||||||
|
|
||||||
|
// Snapshot returns the retained output for a container. The second result is
|
||||||
|
// false when the container is not being followed.
|
||||||
|
func (r *Registry) Snapshot(name string) (Snapshot, bool) {
|
||||||
|
r.mu.Lock()
|
||||||
|
stream, exists := r.streams[name]
|
||||||
|
if exists {
|
||||||
|
// Viewing a stream is what keeps it alive under the cap.
|
||||||
|
stream.used = time.Now()
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
if !exists {
|
||||||
|
return Snapshot{}, false
|
||||||
|
}
|
||||||
|
stream.mu.Lock()
|
||||||
|
defer stream.mu.Unlock()
|
||||||
|
return Snapshot{
|
||||||
|
Name: stream.name,
|
||||||
|
Lines: append([]string(nil), stream.lines...),
|
||||||
|
State: stream.state,
|
||||||
|
Err: stream.err,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attached lists the containers currently being followed.
|
||||||
|
func (r *Registry) Attached() []string {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
names := make([]string, 0, len(r.streams))
|
||||||
|
for name := range r.streams {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package logs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRegistryAttachAndSnapshot(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
defer reg.Close()
|
||||||
|
|
||||||
|
snap, attached := reg.Snapshot("nonexistent")
|
||||||
|
if attached {
|
||||||
|
t.Fatalf("expected attached=false for unattached container")
|
||||||
|
}
|
||||||
|
|
||||||
|
reg.Attach("test-container")
|
||||||
|
snap, attached = reg.Snapshot("test-container")
|
||||||
|
if !attached {
|
||||||
|
t.Fatalf("expected attached=true after Attach")
|
||||||
|
}
|
||||||
|
if snap.State != StateStarting && snap.State != StateStreaming {
|
||||||
|
t.Fatalf("expected StateStarting or StateStreaming, got %v", snap.State)
|
||||||
|
}
|
||||||
|
|
||||||
|
reg.Detach("test-container")
|
||||||
|
snap, attached = reg.Snapshot("test-container")
|
||||||
|
if attached {
|
||||||
|
t.Fatalf("expected attached=false after Detach")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryChangedNotification(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
defer reg.Close()
|
||||||
|
|
||||||
|
ch := reg.Changed()
|
||||||
|
if ch == nil {
|
||||||
|
t.Fatal("expected non-nil Changed channel")
|
||||||
|
}
|
||||||
|
|
||||||
|
reg.touch()
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
t.Fatal("expected notification on Changed channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamLineBufferingAndBounds(t *testing.T) {
|
||||||
|
s := &Stream{
|
||||||
|
name: "test",
|
||||||
|
since: time.Now(),
|
||||||
|
used: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < retainedLines+50; i++ {
|
||||||
|
s.append(fmt.Sprintf("line %d", i))
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
lines := make([]string, len(s.lines))
|
||||||
|
copy(lines, s.lines)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if len(lines) != retainedLines {
|
||||||
|
t.Fatalf("expected %d retained lines, got %d", retainedLines, len(lines))
|
||||||
|
}
|
||||||
|
|
||||||
|
if lines[0] != "line 50" {
|
||||||
|
t.Errorf("first retained line = %q, want 'line 50'", lines[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistryRetainAndEviction(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
defer reg.Close()
|
||||||
|
|
||||||
|
reg.Attach("c1")
|
||||||
|
reg.Attach("c2")
|
||||||
|
reg.Attach("c3")
|
||||||
|
|
||||||
|
// Retain only c1 and c3
|
||||||
|
reg.Retain([]string{"c1", "c3"})
|
||||||
|
|
||||||
|
if _, attached := reg.Snapshot("c2"); attached {
|
||||||
|
t.Errorf("c2 should have been dropped by Retain")
|
||||||
|
}
|
||||||
|
if _, attached := reg.Snapshot("c1"); !attached {
|
||||||
|
t.Errorf("c1 should still be attached")
|
||||||
|
}
|
||||||
|
if _, attached := reg.Snapshot("c3"); !attached {
|
||||||
|
t.Errorf("c3 should still be attached")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,15 +14,16 @@ func OpenURL(url string) error {
|
|||||||
return exec.Command(name, args...).Start()
|
return exec.Command(name, args...).Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
func openCommand(goos, url string) (string, []string, error) {
|
var platformOpeners = map[string]func(string) (string, []string){
|
||||||
switch goos {
|
"darwin": func(u string) (string, []string) { return "open", []string{u} },
|
||||||
case "darwin":
|
"linux": func(u string) (string, []string) { return "xdg-open", []string{u} },
|
||||||
return "open", []string{url}, nil
|
"windows": func(u string) (string, []string) { return "rundll32", []string{"url.dll,FileProtocolHandler", u} },
|
||||||
case "linux":
|
}
|
||||||
return "xdg-open", []string{url}, nil
|
|
||||||
case "windows":
|
func openCommand(goos, url string) (string, []string, error) {
|
||||||
return "rundll32", []string{"url.dll,FileProtocolHandler", url}, nil
|
if opener, supported := platformOpeners[goos]; supported {
|
||||||
default:
|
name, args := opener(url)
|
||||||
return "", nil, fmt.Errorf("opening URLs is not supported on %s", goos)
|
return name, args, nil
|
||||||
}
|
}
|
||||||
|
return "", nil, fmt.Errorf("opening URLs is not supported on %s", goos)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Package registry queries GHCR for the pull-request test images Homarr's CI
|
||||||
|
// publishes.
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestImageRepository is where CI pushes one image per open pull request.
|
||||||
|
const TestImageRepository = "homarr-labs/homarr-test"
|
||||||
|
|
||||||
|
// ImageReference returns the GHCR reference for a pull request's test image.
|
||||||
|
func ImageReference(pr int) string {
|
||||||
|
return fmt.Sprintf("ghcr.io/%s:pr-%d", TestImageRepository, pr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
tagCacheTTL = 2 * time.Minute
|
||||||
|
httpTimeout = 20 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var tagCache struct {
|
||||||
|
sync.Mutex
|
||||||
|
tags map[string]bool
|
||||||
|
fetchedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = &http.Client{Timeout: httpTimeout}
|
||||||
|
|
||||||
|
// PublishedTags lists every tag in the test image repository in a single
|
||||||
|
// request. Checking availability one pull request at a time with `docker
|
||||||
|
// manifest inspect` costs one network round trip per row, which dominates
|
||||||
|
// startup on a busy repository; the registry's own tag listing answers the same
|
||||||
|
// question for every row at once.
|
||||||
|
func PublishedTags(ctx context.Context, refresh bool) (map[string]bool, error) {
|
||||||
|
tagCache.Lock()
|
||||||
|
cached, fetchedAt := tagCache.tags, tagCache.fetchedAt
|
||||||
|
tagCache.Unlock()
|
||||||
|
if !refresh && cached != nil && time.Since(fetchedAt) < tagCacheTTL {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := anonymousToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tags, err := listTags(ctx, token)
|
||||||
|
if err != nil {
|
||||||
|
if cached != nil {
|
||||||
|
return cached, err
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tagCache.Lock()
|
||||||
|
tagCache.tags, tagCache.fetchedAt = tags, time.Now()
|
||||||
|
tagCache.Unlock()
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPRImage reports whether CI has published an image for a pull request.
|
||||||
|
func HasPRImage(tags map[string]bool, pr int) bool {
|
||||||
|
return tags["pr-"+strconv.Itoa(pr)]
|
||||||
|
}
|
||||||
|
|
||||||
|
func anonymousToken(ctx context.Context) (string, error) {
|
||||||
|
url := fmt.Sprintf("https://ghcr.io/token?scope=repository:%s:pull&service=ghcr.io", TestImageRepository)
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("request GHCR token: %w", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("request GHCR token: %s", response.Status)
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||||
|
return "", fmt.Errorf("decode GHCR token: %w", err)
|
||||||
|
}
|
||||||
|
return payload.Token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listTags(ctx context.Context, token string) (map[string]bool, error) {
|
||||||
|
tags := make(map[string]bool)
|
||||||
|
next := fmt.Sprintf("https://ghcr.io/v2/%s/tags/list?n=1000", TestImageRepository)
|
||||||
|
for page := 0; next != "" && page < 20; page++ {
|
||||||
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
response, err := client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list GHCR tags: %w", err)
|
||||||
|
}
|
||||||
|
if response.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
||||||
|
_ = response.Body.Close()
|
||||||
|
return nil, fmt.Errorf("list GHCR tags: %s: %s", response.Status, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
err = json.NewDecoder(response.Body).Decode(&payload)
|
||||||
|
linkHeader := response.Header.Get("Link")
|
||||||
|
_ = response.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode GHCR tags: %w", err)
|
||||||
|
}
|
||||||
|
for _, tag := range payload.Tags {
|
||||||
|
tags[tag] = true
|
||||||
|
}
|
||||||
|
next = nextPageURL(linkHeader)
|
||||||
|
}
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextPageURL follows the RFC 5988 Link header the registry uses for paging.
|
||||||
|
func nextPageURL(link string) string {
|
||||||
|
for _, part := range strings.Split(link, ",") {
|
||||||
|
if !strings.Contains(part, `rel="next"`) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start := strings.Index(part, "<")
|
||||||
|
end := strings.Index(part, ">")
|
||||||
|
if start < 0 || end <= start {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target := part[start+1 : end]
|
||||||
|
if strings.HasPrefix(target, "/") {
|
||||||
|
return "https://ghcr.io" + target
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManifestExists falls back to the Docker CLI for a single reference, which
|
||||||
|
// also works for private repositories where the anonymous listing is refused.
|
||||||
|
func ManifestExists(ctx context.Context, reference string) (bool, error) {
|
||||||
|
out, err := exec.CommandContext(ctx, "docker", "manifest", "inspect", reference).CombinedOutput()
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
message := strings.ToLower(string(out))
|
||||||
|
if strings.Contains(message, "manifest unknown") || strings.Contains(message, "no such manifest") {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("check %s: %w: %s", reference, err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublishedTagsIntegration(t *testing.T) {
|
||||||
|
if testing.Short() || os.Getenv("HOMARR_INTEGRATION_TEST") != "1" {
|
||||||
|
t.Skip("skipping GHCR integration test; set HOMARR_INTEGRATION_TEST=1 to run")
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
start := time.Now()
|
||||||
|
tags, err := PublishedTags(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("GHCR unreachable: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("fetched %d tags in %s", len(tags), time.Since(start))
|
||||||
|
if len(tags) == 0 {
|
||||||
|
t.Fatal("expected at least one published tag")
|
||||||
|
}
|
||||||
|
found := 0
|
||||||
|
for tag := range tags {
|
||||||
|
if found < 5 {
|
||||||
|
t.Log(" ", tag)
|
||||||
|
found++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
"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 {
|
func BuildCurrentImage(tag, directory string) error {
|
||||||
@@ -19,11 +17,11 @@ func BuildCurrentImage(tag, directory string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildCurrentImage(ctx context.Context, tag, directory string, stdout, stderr io.Writer) error {
|
func buildCurrentImage(ctx context.Context, tag, directory string, stdout, stderr io.Writer) error {
|
||||||
root, revision, err := checkoutDetails(ctx, directory)
|
options, err := CurrentBuildOptions(ctx, tag, directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return docker.BuildContext(ctx, docker.BuildOptions{Context: root, Tag: tag, Source: root, Revision: revision}, stdout, stderr)
|
return docker.BuildContext(ctx, options, stdout, stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildPRImage(number int, tag string) error {
|
func BuildPRImage(number int, tag string) error {
|
||||||
@@ -31,44 +29,18 @@ func BuildPRImage(number int, tag string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildPRImage(ctx context.Context, number int, tag string, stdout, stderr io.Writer) error {
|
func buildPRImage(ctx context.Context, number int, tag string, stdout, stderr io.Writer) error {
|
||||||
if number <= 0 {
|
options, cleanup, err := PRBuildOptions(ctx, number, tag, func(line string) {
|
||||||
return fmt.Errorf("PR number must be positive")
|
_, _ = fmt.Fprintln(stdout, line)
|
||||||
}
|
})
|
||||||
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return docker.BuildContext(ctx, docker.BuildOptions{
|
defer cleanup()
|
||||||
Context: checkout,
|
return docker.BuildContext(ctx, options, stdout, stderr)
|
||||||
Tag: tag,
|
|
||||||
Source: fmt.Sprintf("https://github.com/%s/pull/%d", gh.Repo, number),
|
|
||||||
Revision: revision,
|
|
||||||
PRNumber: number,
|
|
||||||
}, stdout, stderr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func prCloneArgs(checkout string) []string {
|
func prFetchArgs(repoURL, refSpec string) []string {
|
||||||
return []string{"repo", "clone", gh.Repo, checkout, "--", "--depth=1", "--filter=blob:none"}
|
return []string{"fetch", "--depth=1", repoURL, refSpec}
|
||||||
}
|
}
|
||||||
|
|
||||||
func RebuildImage(image docker.Image) error {
|
func RebuildImage(image docker.Image) error {
|
||||||
@@ -89,7 +61,16 @@ func RebuildImageContext(ctx context.Context, image docker.Image, stdout, stderr
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkoutDetails(ctx context.Context, directory string) (root string, revision string, err error) {
|
func checkoutDetails(ctx context.Context, directory string) (root string, revision string, err error) {
|
||||||
rootOutput, err := exec.CommandContext(ctx, "git", "-C", directory, "rev-parse", "--show-toplevel").CombinedOutput()
|
cmdRoot := exec.CommandContext(ctx, "git", "-C", directory, "rev-parse", "--show-toplevel")
|
||||||
|
cmdRoot.Env = append(
|
||||||
|
os.Environ(),
|
||||||
|
"MISE_TRUSTED_CONFIG_PATHS=*",
|
||||||
|
"MISE_QUIET=1",
|
||||||
|
"MISE_SILENT=1",
|
||||||
|
"MISE_YES=1",
|
||||||
|
"MISE_OVERRIDE_CONFIG_FILENAMES=",
|
||||||
|
)
|
||||||
|
rootOutput, err := cmdRoot.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("find Homarr checkout: %w: %s", err, strings.TrimSpace(string(rootOutput)))
|
return "", "", fmt.Errorf("find Homarr checkout: %w: %s", err, strings.TrimSpace(string(rootOutput)))
|
||||||
}
|
}
|
||||||
@@ -97,7 +78,16 @@ func checkoutDetails(ctx context.Context, directory string) (root string, revisi
|
|||||||
if _, err := os.Stat(filepath.Join(root, "Dockerfile")); err != nil {
|
if _, err := os.Stat(filepath.Join(root, "Dockerfile")); err != nil {
|
||||||
return "", "", fmt.Errorf("%s is not a Homarr checkout", root)
|
return "", "", fmt.Errorf("%s is not a Homarr checkout", root)
|
||||||
}
|
}
|
||||||
revisionOutput, err := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "HEAD").CombinedOutput()
|
cmdRev := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "HEAD")
|
||||||
|
cmdRev.Env = append(
|
||||||
|
os.Environ(),
|
||||||
|
"MISE_TRUSTED_CONFIG_PATHS=*",
|
||||||
|
"MISE_QUIET=1",
|
||||||
|
"MISE_SILENT=1",
|
||||||
|
"MISE_YES=1",
|
||||||
|
"MISE_OVERRIDE_CONFIG_FILENAMES=",
|
||||||
|
)
|
||||||
|
revisionOutput, err := cmdRev.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("read checkout revision: %w: %s", err, strings.TrimSpace(string(revisionOutput)))
|
return "", "", fmt.Errorf("read checkout revision: %w: %s", err, strings.TrimSpace(string(revisionOutput)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package run
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cleanup releases a temporary checkout. It is always safe to call.
|
||||||
|
type Cleanup func()
|
||||||
|
|
||||||
|
func noCleanup() {}
|
||||||
|
|
||||||
|
// CurrentBuildOptions describes a build of the checkout that contains
|
||||||
|
// directory, recording the source and revision on the image so it can be
|
||||||
|
// rebuilt later without the user remembering where it came from.
|
||||||
|
func CurrentBuildOptions(ctx context.Context, tag, directory string) (docker.BuildOptions, error) {
|
||||||
|
root, revision, err := checkoutDetails(ctx, directory)
|
||||||
|
if err != nil {
|
||||||
|
return docker.BuildOptions{}, err
|
||||||
|
}
|
||||||
|
return docker.BuildOptions{Context: root, Tag: tag, Source: root, Revision: revision}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRBuildOptions clones a pull request into a temporary checkout and describes
|
||||||
|
// a build of it. The returned Cleanup must run once the build has finished.
|
||||||
|
//
|
||||||
|
// Progress is reported through log rather than written to stdout, because the
|
||||||
|
// caller is a background job rendering inside a TUI, not a shell.
|
||||||
|
func PRBuildOptions(ctx context.Context, number int, tag string, log func(string)) (docker.BuildOptions, Cleanup, error) {
|
||||||
|
if number <= 0 {
|
||||||
|
return docker.BuildOptions{}, noCleanup, fmt.Errorf("PR number must be positive")
|
||||||
|
}
|
||||||
|
if log == nil {
|
||||||
|
log = func(string) {}
|
||||||
|
}
|
||||||
|
temporaryRoot, err := os.MkdirTemp("", "homarr-pr-*")
|
||||||
|
if err != nil {
|
||||||
|
return docker.BuildOptions{}, noCleanup, fmt.Errorf("create temporary checkout: %w", err)
|
||||||
|
}
|
||||||
|
cleanup := func() { _ = os.RemoveAll(temporaryRoot) }
|
||||||
|
|
||||||
|
checkout := filepath.Join(temporaryRoot, "homarr")
|
||||||
|
if err := os.MkdirAll(checkout, 0o755); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return docker.BuildOptions{}, noCleanup, fmt.Errorf("create checkout directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repoURL := fmt.Sprintf("https://github.com/%s.git", gh.Repo)
|
||||||
|
branchName := fmt.Sprintf("pr-%d", number)
|
||||||
|
refSpec := fmt.Sprintf("refs/pull/%d/head:%s", number, branchName)
|
||||||
|
|
||||||
|
log(fmt.Sprintf("fetching PR #%d (shallow) from %s", number, gh.Repo))
|
||||||
|
initCmd := exec.CommandContext(ctx, "git", "init", checkout)
|
||||||
|
if err := runLogged(initCmd, log); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return docker.BuildOptions{}, noCleanup, fmt.Errorf("init git repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCmd := exec.CommandContext(ctx, "git", "-C", checkout, "fetch", "--depth", "1", repoURL, refSpec)
|
||||||
|
if err := runLogged(fetchCmd, log); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return docker.BuildOptions{}, noCleanup, fmt.Errorf("fetch PR #%d: %w", number, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkoutCmd := exec.CommandContext(ctx, "git", "-C", checkout, "checkout", branchName)
|
||||||
|
if err := runLogged(checkoutCmd, log); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return docker.BuildOptions{}, noCleanup, fmt.Errorf("checkout PR #%d: %w", number, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, revision, err := checkoutDetails(ctx, checkout)
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
return docker.BuildOptions{}, noCleanup, err
|
||||||
|
}
|
||||||
|
log("building " + revision[:min(len(revision), 12)])
|
||||||
|
return docker.BuildOptions{
|
||||||
|
Context: checkout,
|
||||||
|
Tag: tag,
|
||||||
|
Source: fmt.Sprintf("https://github.com/%s/pull/%d", gh.Repo, number),
|
||||||
|
Revision: revision,
|
||||||
|
PRNumber: number,
|
||||||
|
}, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RebuildOptions describes a rebuild of an existing local image from the
|
||||||
|
// provenance recorded on it, whether that is a pull request or a checkout.
|
||||||
|
func RebuildOptions(ctx context.Context, image docker.Image, log func(string)) (docker.BuildOptions, Cleanup, error) {
|
||||||
|
if image.PRNumber > 0 {
|
||||||
|
return PRBuildOptions(ctx, image.PRNumber, image.Tag, log)
|
||||||
|
}
|
||||||
|
if image.Source == "" {
|
||||||
|
return docker.BuildOptions{}, noCleanup, fmt.Errorf("homarr:%s has no build source metadata", image.Tag)
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(image.Source); err != nil || !info.IsDir() {
|
||||||
|
return docker.BuildOptions{}, noCleanup, fmt.Errorf("build source %s is no longer available", image.Source)
|
||||||
|
}
|
||||||
|
options, err := CurrentBuildOptions(ctx, image.Tag, image.Source)
|
||||||
|
return options, noCleanup, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLogged(command *exec.Cmd, log func(string)) error {
|
||||||
|
command.Env = append(
|
||||||
|
os.Environ(),
|
||||||
|
"MISE_TRUSTED_CONFIG_PATHS=*",
|
||||||
|
"MISE_QUIET=1",
|
||||||
|
"MISE_SILENT=1",
|
||||||
|
"MISE_YES=1",
|
||||||
|
"MISE_OVERRIDE_CONFIG_FILENAMES=",
|
||||||
|
)
|
||||||
|
lines, err := docker.StreamCommand(command)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for line := range lines {
|
||||||
|
if line.Done {
|
||||||
|
return line.Err
|
||||||
|
}
|
||||||
|
log(line.Text)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -19,10 +19,10 @@ func TestPRPlanUsesAMD64(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPRCloneIsShallow(t *testing.T) {
|
func TestPRFetchIsShallow(t *testing.T) {
|
||||||
args := prCloneArgs("/tmp/checkout")
|
args := prFetchArgs("https://github.com/homarr-labs/homarr.git", "refs/pull/1/head:pr-1")
|
||||||
if !slices.Contains(args, "--depth=1") || !slices.Contains(args, "--filter=blob:none") {
|
if !slices.Contains(args, "--depth=1") {
|
||||||
t.Fatalf("clone args = %v", args)
|
t.Fatalf("fetch args = %v", args)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exercises the real BuildKit streaming and parsing path against a small
|
||||||
|
// Dockerfile, so the step tracker is validated on genuine output.
|
||||||
|
func TestBuildImageStreamsRealSteps(t *testing.T) {
|
||||||
|
if testing.Short() || os.Getenv("HOMARR_INTEGRATION_TEST") != "1" {
|
||||||
|
t.Skip("skipping docker build probe test; set HOMARR_INTEGRATION_TEST=1 to run")
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
dockerfile := `FROM alpine:3.20 AS base
|
||||||
|
RUN echo "hello from step two" && sleep 1
|
||||||
|
FROM base AS final
|
||||||
|
RUN echo "third step" > /out.txt
|
||||||
|
RUN cat /out.txt
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager := NewManager()
|
||||||
|
task := manager.Build("build homarr:cli-probe", "homarr:cli-probe", docker.BuildOptions{
|
||||||
|
Context: dir, Tag: "cli-probe", Source: dir, Revision: "probe",
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { _ = docker.RemoveImage(context.Background(), "homarr:cli-probe") })
|
||||||
|
|
||||||
|
for range 600 {
|
||||||
|
<-manager.Changed()
|
||||||
|
if snapshot, _ := manager.Snapshot(task.ID()); snapshot.State.Done() {
|
||||||
|
t.Logf("state=%s percent=%.2f detail=%q err=%v", snapshot.State, snapshot.Percent, snapshot.Detail, snapshot.Err)
|
||||||
|
for _, step := range snapshot.Steps {
|
||||||
|
t.Logf(" #%-3s done=%-5v %-46s %s", step.ID, step.Done, truncate(step.Label, 46), step.Note)
|
||||||
|
}
|
||||||
|
if snapshot.State != StateSucceeded {
|
||||||
|
t.Fatalf("build did not succeed: %v", snapshot.Err)
|
||||||
|
}
|
||||||
|
if len(snapshot.Steps) < 4 {
|
||||||
|
t.Fatalf("expected several build steps, got %d", len(snapshot.Steps))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("build did not finish")
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(value string, width int) string {
|
||||||
|
if len(value) <= width {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return value[:width]
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pull downloads an image, preferring the Docker Engine API because it reports
|
||||||
|
// per-layer byte progress even when the CLI would not. If the socket is
|
||||||
|
// unreachable the plain `docker pull` output is parsed instead, which still
|
||||||
|
// yields per-layer state, just without byte counters.
|
||||||
|
func (m *Manager) Pull(title, target, image, platform string) *Task {
|
||||||
|
return m.Start(KindPull, title, target, func(ctx context.Context, report *Reporter) error {
|
||||||
|
return PullImage(ctx, report, image, platform)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullImage is the body of a pull, exported so callers can compose it with
|
||||||
|
// follow-up work — pulling and then starting a container reads better as one
|
||||||
|
// job than as two.
|
||||||
|
func PullImage(ctx context.Context, report *Reporter, image, platform string) error {
|
||||||
|
tracker := docker.NewPullTracker()
|
||||||
|
publish := throttled(report, func() {
|
||||||
|
report.SetSteps(pullSteps(tracker))
|
||||||
|
report.SetPercent(tracker.Percent())
|
||||||
|
report.SetDetail(pullDetail(tracker))
|
||||||
|
})
|
||||||
|
|
||||||
|
messages, err := docker.PullStream(ctx, image, platform)
|
||||||
|
if err != nil {
|
||||||
|
report.Log("engine API unavailable (" + err.Error() + "), falling back to docker pull")
|
||||||
|
return pullWithCLI(ctx, report, tracker, publish, image, platform)
|
||||||
|
}
|
||||||
|
for message := range messages {
|
||||||
|
if message.Error != "" {
|
||||||
|
return fmt.Errorf("pull %s: %s", image, message.Error)
|
||||||
|
}
|
||||||
|
tracker.Apply(message)
|
||||||
|
if message.ID == "" && message.Status != "" {
|
||||||
|
report.Log(message.Status)
|
||||||
|
}
|
||||||
|
publish(false)
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
publish(true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pullWithCLI(ctx context.Context, report *Reporter, tracker *docker.PullTracker, publish func(bool), image, platform string) error {
|
||||||
|
command := docker.PullCommandContext(ctx, image, platform)
|
||||||
|
command.Cancel = func() error { return command.Process.Kill() }
|
||||||
|
lines, err := docker.StreamCommand(command)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for line := range lines {
|
||||||
|
if line.Done {
|
||||||
|
if line.Err != nil {
|
||||||
|
return line.Err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tracker.ApplyLine(line.Text)
|
||||||
|
publish(false)
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
publish(true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pullSteps(tracker *docker.PullTracker) []Step {
|
||||||
|
layers := tracker.Layers()
|
||||||
|
steps := make([]Step, 0, len(layers))
|
||||||
|
for _, layer := range layers {
|
||||||
|
note := ""
|
||||||
|
if layer.Total > 0 && !layer.Done() {
|
||||||
|
note = docker.FormatSize(layer.Current) + "/" + docker.FormatSize(layer.Total)
|
||||||
|
} else if layer.Total > 0 {
|
||||||
|
note = docker.FormatSize(layer.Total)
|
||||||
|
}
|
||||||
|
steps = append(steps, Step{
|
||||||
|
ID: layer.ID,
|
||||||
|
Label: layer.ID,
|
||||||
|
Status: layer.Status,
|
||||||
|
Current: layer.Current,
|
||||||
|
Total: layer.Total,
|
||||||
|
Percent: layer.Fraction(),
|
||||||
|
Done: layer.Done(),
|
||||||
|
Note: note,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return steps
|
||||||
|
}
|
||||||
|
|
||||||
|
func pullDetail(tracker *docker.PullTracker) string {
|
||||||
|
complete, total := tracker.Complete()
|
||||||
|
parts := []string{fmt.Sprintf("%d/%d layers", complete, total)}
|
||||||
|
if current, size := tracker.Bytes(); size > 0 {
|
||||||
|
parts = append(parts, docker.FormatSize(current)+"/"+docker.FormatSize(size))
|
||||||
|
}
|
||||||
|
if rate := tracker.Rate(); rate > 0 {
|
||||||
|
parts = append(parts, docker.FormatSize(int64(rate))+"/s")
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build compiles an image, streaming BuildKit's plain progress so the UI can
|
||||||
|
// show which Dockerfile step is running and what it is printing.
|
||||||
|
func (m *Manager) Build(title, target string, options docker.BuildOptions) *Task {
|
||||||
|
return m.Start(KindBuild, title, target, func(ctx context.Context, report *Reporter) error {
|
||||||
|
return BuildImage(ctx, report, options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildFrom runs a caller-supplied preparation step (cloning a pull request,
|
||||||
|
// for example) before building. Preparation output is reported the same way as
|
||||||
|
// build output so the job reads as one continuous stream.
|
||||||
|
func (m *Manager) BuildFrom(title, target string, prepare func(ctx context.Context, report *Reporter) (docker.BuildOptions, error)) *Task {
|
||||||
|
return m.Start(KindBuild, title, target, func(ctx context.Context, report *Reporter) error {
|
||||||
|
options, err := prepare(ctx, report)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return BuildImage(ctx, report, options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildImage is the body of a build, exported for the same reason as PullImage.
|
||||||
|
func BuildImage(ctx context.Context, report *Reporter, options docker.BuildOptions) error {
|
||||||
|
command, err := docker.BuildCommandStreaming(ctx, options)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
command.Cancel = func() error { return command.Process.Kill() }
|
||||||
|
lines, err := docker.StreamCommand(command)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tracker := docker.NewBuildTracker()
|
||||||
|
publish := throttled(report, func() {
|
||||||
|
report.SetSteps(buildSteps(tracker))
|
||||||
|
report.SetPercent(tracker.Percent())
|
||||||
|
report.SetDetail(buildDetail(tracker))
|
||||||
|
})
|
||||||
|
for line := range lines {
|
||||||
|
if line.Done {
|
||||||
|
if line.Err != nil {
|
||||||
|
if detail := tracker.LastMessage(); detail != "" && !strings.Contains(line.Err.Error(), detail) {
|
||||||
|
return fmt.Errorf("%w: %s", line.Err, detail)
|
||||||
|
}
|
||||||
|
return line.Err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
tracker.ApplyLine(line.Text)
|
||||||
|
report.Log(line.Text)
|
||||||
|
publish(false)
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
publish(true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSteps(tracker *docker.BuildTracker) []Step {
|
||||||
|
steps := tracker.Steps()
|
||||||
|
converted := make([]Step, 0, len(steps))
|
||||||
|
for _, step := range steps {
|
||||||
|
label := step.Label
|
||||||
|
if label == "" {
|
||||||
|
label = "#" + step.ID
|
||||||
|
}
|
||||||
|
percent := 0.0
|
||||||
|
if step.Done && !step.Canceled && !step.Failed {
|
||||||
|
percent = 1
|
||||||
|
}
|
||||||
|
note := step.Elapsed
|
||||||
|
if step.Cached {
|
||||||
|
note = "cached"
|
||||||
|
}
|
||||||
|
converted = append(converted, Step{
|
||||||
|
ID: step.ID,
|
||||||
|
Label: label,
|
||||||
|
Status: step.Status,
|
||||||
|
Percent: percent,
|
||||||
|
Done: step.Done && !step.Canceled,
|
||||||
|
Failed: step.Failed,
|
||||||
|
Note: note,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return converted
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildDetail(tracker *docker.BuildTracker) string {
|
||||||
|
complete, total := tracker.Complete()
|
||||||
|
detail := fmt.Sprintf("%d/%d steps", complete, total)
|
||||||
|
if active, running := tracker.Active(); running {
|
||||||
|
detail += " · " + active.Label
|
||||||
|
}
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action runs a short operation such as stopping a container. It still goes
|
||||||
|
// through the manager so a slow daemon can never freeze the interface.
|
||||||
|
func (m *Manager) Action(title, target string, body func(ctx context.Context) error) *Task {
|
||||||
|
return m.Start(KindAction, title, target, func(ctx context.Context, report *Reporter) error {
|
||||||
|
return body(ctx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// throttled limits how often a progress publisher runs. Docker emits progress
|
||||||
|
// far faster than a terminal can redraw, and every publish copies slices, so
|
||||||
|
// coalescing here keeps the cost proportional to the frame rate instead of to
|
||||||
|
// the daemon's chattiness. Passing force publishes regardless, which the caller
|
||||||
|
// does once at the end so the final state is never dropped.
|
||||||
|
func throttled(report *Reporter, publish func()) func(force bool) {
|
||||||
|
const interval = 80 * time.Millisecond
|
||||||
|
last := time.Time{}
|
||||||
|
return func(force bool) {
|
||||||
|
now := time.Now()
|
||||||
|
if !force && now.Sub(last) < interval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
last = now
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
// Package task runs the CLI's slow work — image pulls, local builds, container
|
||||||
|
// actions — on background goroutines and exposes it as immutable snapshots.
|
||||||
|
//
|
||||||
|
// Nothing in this package blocks the caller. A Manager owns every running job,
|
||||||
|
// coalesces their updates into a single wake-up channel, and hands the UI a
|
||||||
|
// consistent view whenever it asks for one. That separation is what keeps the
|
||||||
|
// terminal responsive while a fifteen minute Docker build streams output.
|
||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kind classifies a job so the UI can pick an icon and a detail renderer.
|
||||||
|
type Kind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindPull Kind = iota
|
||||||
|
KindBuild
|
||||||
|
KindAction
|
||||||
|
)
|
||||||
|
|
||||||
|
var kindNames = map[Kind]string{
|
||||||
|
KindPull: "pull",
|
||||||
|
KindBuild: "build",
|
||||||
|
KindAction: "action",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k Kind) String() string {
|
||||||
|
if name, found := kindNames[k]; found {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return "action"
|
||||||
|
}
|
||||||
|
|
||||||
|
// State is the lifecycle stage of a job.
|
||||||
|
type State int
|
||||||
|
|
||||||
|
const (
|
||||||
|
StateRunning State = iota
|
||||||
|
StateSucceeded
|
||||||
|
StateFailed
|
||||||
|
StateCanceled
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s State) Done() bool { return s != StateRunning }
|
||||||
|
|
||||||
|
var stateNames = map[State]string{
|
||||||
|
StateRunning: "running",
|
||||||
|
StateSucceeded: "succeeded",
|
||||||
|
StateFailed: "failed",
|
||||||
|
StateCanceled: "canceled",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s State) String() string {
|
||||||
|
if name, found := stateNames[s]; found {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return "canceled"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step is one unit of progress inside a job: a Docker layer during a pull, or a
|
||||||
|
// BuildKit vertex during a build.
|
||||||
|
type Step struct {
|
||||||
|
ID string
|
||||||
|
Label string
|
||||||
|
Status string
|
||||||
|
Current int64
|
||||||
|
Total int64
|
||||||
|
Percent float64
|
||||||
|
Done bool
|
||||||
|
Failed bool
|
||||||
|
Note string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot is an immutable view of a job. The UI only ever sees snapshots, so
|
||||||
|
// it can render without holding a lock or racing the worker goroutine.
|
||||||
|
type Snapshot struct {
|
||||||
|
ID int
|
||||||
|
Kind Kind
|
||||||
|
Title string
|
||||||
|
Target string
|
||||||
|
Detail string
|
||||||
|
State State
|
||||||
|
Err error
|
||||||
|
Percent float64
|
||||||
|
Steps []Step
|
||||||
|
Lines []string
|
||||||
|
Started time.Time
|
||||||
|
Ended time.Time
|
||||||
|
Cancellable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elapsed reports how long the job ran, or has been running.
|
||||||
|
func (s Snapshot) Elapsed() time.Duration {
|
||||||
|
if s.Ended.IsZero() {
|
||||||
|
return time.Since(s.Started)
|
||||||
|
}
|
||||||
|
return s.Ended.Sub(s.Started)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reporter is the handle a job body uses to publish progress. Every method is
|
||||||
|
// safe to call from any goroutine and never blocks on the UI.
|
||||||
|
type Reporter struct {
|
||||||
|
task *Task
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log appends a line of output. Only the most recent lines are retained.
|
||||||
|
func (r *Reporter) Log(line string) {
|
||||||
|
r.task.mutate(func(t *Task) { t.lines = appendLine(t.lines, line) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSteps replaces the job's progress breakdown.
|
||||||
|
func (r *Reporter) SetSteps(steps []Step) {
|
||||||
|
r.task.mutate(func(t *Task) { t.steps = steps })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPercent records overall progress in the range [0,1].
|
||||||
|
func (r *Reporter) SetPercent(percent float64) {
|
||||||
|
r.task.mutate(func(t *Task) { t.percent = clamp(percent) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDetail replaces the single-line summary shown next to the job.
|
||||||
|
func (r *Reporter) SetDetail(detail string) {
|
||||||
|
r.task.mutate(func(t *Task) { t.detail = detail })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lines returns the retained output, newest last.
|
||||||
|
func (r *Reporter) Lines() []string {
|
||||||
|
r.task.mu.Lock()
|
||||||
|
defer r.task.mu.Unlock()
|
||||||
|
return append([]string(nil), r.task.lines...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body is the work a job performs. Returning an error fails the job; returning
|
||||||
|
// ctx.Err() after cancellation marks it canceled instead.
|
||||||
|
type Body func(ctx context.Context, report *Reporter) error
|
||||||
|
|
||||||
|
// Task is a single background job.
|
||||||
|
type Task struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
manager *Manager
|
||||||
|
|
||||||
|
id int
|
||||||
|
kind Kind
|
||||||
|
title string
|
||||||
|
target string
|
||||||
|
detail string
|
||||||
|
state State
|
||||||
|
err error
|
||||||
|
percent float64
|
||||||
|
steps []Step
|
||||||
|
lines []string
|
||||||
|
started time.Time
|
||||||
|
ended time.Time
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
const retainedLines = 500
|
||||||
|
|
||||||
|
func appendLine(lines []string, line string) []string {
|
||||||
|
lines = append(lines, line)
|
||||||
|
if len(lines) > retainedLines {
|
||||||
|
lines = lines[len(lines)-retainedLines:]
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp(value float64) float64 {
|
||||||
|
if value < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if value > 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) mutate(apply func(*Task)) {
|
||||||
|
t.mu.Lock()
|
||||||
|
apply(t)
|
||||||
|
t.mu.Unlock()
|
||||||
|
t.manager.touch()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Task) snapshot() Snapshot {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
return Snapshot{
|
||||||
|
ID: t.id,
|
||||||
|
Kind: t.kind,
|
||||||
|
Title: t.title,
|
||||||
|
Target: t.target,
|
||||||
|
Detail: t.detail,
|
||||||
|
State: t.state,
|
||||||
|
Err: t.err,
|
||||||
|
Percent: t.percent,
|
||||||
|
Steps: append([]Step(nil), t.steps...),
|
||||||
|
Lines: append([]string(nil), t.lines...),
|
||||||
|
Started: t.started,
|
||||||
|
Ended: t.ended,
|
||||||
|
Cancellable: t.cancel != nil && t.state == StateRunning,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID returns the job's identifier, stable for the lifetime of the process.
|
||||||
|
func (t *Task) ID() int { return t.id }
|
||||||
|
|
||||||
|
// Manager owns every background job and notifies a single listener when any of
|
||||||
|
// them changes.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
tasks []*Task
|
||||||
|
nextID int
|
||||||
|
notify chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewManager() *Manager {
|
||||||
|
return &Manager{notify: make(chan struct{}, 1)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changed is a coalescing wake-up channel. It receives at most one pending
|
||||||
|
// notification, so a job that emits thousands of progress updates per second
|
||||||
|
// still costs the UI a single redraw per frame.
|
||||||
|
func (m *Manager) Changed() <-chan struct{} { return m.notify }
|
||||||
|
|
||||||
|
func (m *Manager) touch() {
|
||||||
|
select {
|
||||||
|
case m.notify <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start launches a job and returns immediately.
|
||||||
|
func (m *Manager) Start(kind Kind, title, target string, body Body) *Task {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
m.mu.Lock()
|
||||||
|
m.nextID++
|
||||||
|
task := &Task{
|
||||||
|
manager: m,
|
||||||
|
id: m.nextID,
|
||||||
|
kind: kind,
|
||||||
|
title: title,
|
||||||
|
target: target,
|
||||||
|
state: StateRunning,
|
||||||
|
started: time.Now(),
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
m.tasks = append(m.tasks, task)
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
err := body(ctx, &Reporter{task: task})
|
||||||
|
task.mu.Lock()
|
||||||
|
task.ended = time.Now()
|
||||||
|
task.cancel = nil
|
||||||
|
switch {
|
||||||
|
case ctx.Err() != nil:
|
||||||
|
task.state = StateCanceled
|
||||||
|
case err != nil:
|
||||||
|
task.state, task.err = StateFailed, err
|
||||||
|
default:
|
||||||
|
task.state, task.percent = StateSucceeded, 1
|
||||||
|
}
|
||||||
|
task.mu.Unlock()
|
||||||
|
cancel()
|
||||||
|
m.touch()
|
||||||
|
}()
|
||||||
|
|
||||||
|
m.touch()
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshots returns every job, oldest first.
|
||||||
|
func (m *Manager) Snapshots() []Snapshot {
|
||||||
|
m.mu.Lock()
|
||||||
|
tasks := append([]*Task(nil), m.tasks...)
|
||||||
|
m.mu.Unlock()
|
||||||
|
snapshots := make([]Snapshot, 0, len(tasks))
|
||||||
|
for _, task := range tasks {
|
||||||
|
snapshots = append(snapshots, task.snapshot())
|
||||||
|
}
|
||||||
|
return snapshots
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns a single job by identifier.
|
||||||
|
func (m *Manager) Snapshot(id int) (Snapshot, bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for _, task := range m.tasks {
|
||||||
|
if task.id == id {
|
||||||
|
return task.snapshot(), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Snapshot{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel asks a running job to stop.
|
||||||
|
func (m *Manager) Cancel(id int) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for _, task := range m.tasks {
|
||||||
|
if task.id != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
task.mu.Lock()
|
||||||
|
cancel := task.cancel
|
||||||
|
task.mu.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelAll stops every running job, used when the program exits.
|
||||||
|
func (m *Manager) CancelAll() {
|
||||||
|
for _, snapshot := range m.Snapshots() {
|
||||||
|
if snapshot.State == StateRunning {
|
||||||
|
m.Cancel(snapshot.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running counts jobs that have not finished.
|
||||||
|
func (m *Manager) Running() int {
|
||||||
|
count := 0
|
||||||
|
for _, snapshot := range m.Snapshots() {
|
||||||
|
if snapshot.State == StateRunning {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunningFor reports whether a job is already working on the given target, so
|
||||||
|
// the UI can refuse to start a duplicate build or pull.
|
||||||
|
func (m *Manager) RunningFor(target string) (Snapshot, bool) {
|
||||||
|
for _, snapshot := range m.Snapshots() {
|
||||||
|
if snapshot.State == StateRunning && snapshot.Target == target {
|
||||||
|
return snapshot, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Snapshot{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune drops finished jobs, keeping the history short.
|
||||||
|
func (m *Manager) Prune() {
|
||||||
|
m.mu.Lock()
|
||||||
|
kept := m.tasks[:0]
|
||||||
|
for _, task := range m.tasks {
|
||||||
|
if task.snapshot().State == StateRunning {
|
||||||
|
kept = append(kept, task)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.tasks = kept
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.touch()
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/platform"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/registry"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/run"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/task"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// target is everything the actions need to know about the selected row,
|
||||||
|
// resolved once so the same code can act on a pull request, a local image, or a
|
||||||
|
// bare container without caring which screen the user is on.
|
||||||
|
type target struct {
|
||||||
|
label string
|
||||||
|
container string
|
||||||
|
localImage string
|
||||||
|
volume string
|
||||||
|
pr int
|
||||||
|
running bool
|
||||||
|
port string
|
||||||
|
provenance docker.Image
|
||||||
|
buildable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) currentTarget() (target, bool) {
|
||||||
|
if m.screen == screenInstances {
|
||||||
|
row, found := m.selectedInstance()
|
||||||
|
if !found {
|
||||||
|
return target{}, false
|
||||||
|
}
|
||||||
|
return m.instanceTarget(row), true
|
||||||
|
}
|
||||||
|
row, found := m.selectedDev()
|
||||||
|
if !found {
|
||||||
|
return target{}, false
|
||||||
|
}
|
||||||
|
return m.devTarget(row), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) devTarget(row devRow) target {
|
||||||
|
result := target{
|
||||||
|
label: row.label(),
|
||||||
|
container: row.containerName(),
|
||||||
|
volume: row.volumeName(),
|
||||||
|
running: row.running,
|
||||||
|
port: row.port,
|
||||||
|
buildable: true,
|
||||||
|
}
|
||||||
|
if row.kind == rowLocal {
|
||||||
|
result.localImage = row.local.Reference()
|
||||||
|
result.provenance = row.local
|
||||||
|
result.pr = row.local.PRNumber
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result.pr = row.pr.Number
|
||||||
|
result.localImage = row.localReference()
|
||||||
|
// A pull request always has a rebuild recipe even without a local image:
|
||||||
|
// clone the head into a temporary checkout and build it as homarr:pr-N.
|
||||||
|
result.provenance = docker.Image{Tag: fmt.Sprintf("pr-%d", row.pr.Number), PRNumber: row.pr.Number}
|
||||||
|
if row.local.Tag != "" {
|
||||||
|
result.provenance = row.local
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) instanceTarget(row instanceRow) target {
|
||||||
|
result := target{
|
||||||
|
label: row.container.Name,
|
||||||
|
container: row.container.Name,
|
||||||
|
volume: row.container.Name + "_data",
|
||||||
|
running: row.container.Running(),
|
||||||
|
port: row.container.HostPort(),
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(row.container.Image, "homarr:") {
|
||||||
|
result.localImage = row.container.Image
|
||||||
|
tag := strings.TrimPrefix(row.container.Image, "homarr:")
|
||||||
|
for _, image := range m.images {
|
||||||
|
if image.Tag == tag {
|
||||||
|
result.provenance, result.buildable = image, true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if number := row.prNumber(); number > 0 {
|
||||||
|
result.pr = number
|
||||||
|
if !result.buildable {
|
||||||
|
result.provenance = docker.Image{Tag: fmt.Sprintf("pr-%d", number), PRNumber: number}
|
||||||
|
result.buildable = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// primaryAction is Enter: stop what is running, start what is not. A matching
|
||||||
|
// local build and a published registry image open a small source selector;
|
||||||
|
// otherwise the only available source is started directly.
|
||||||
|
func (m Model) primaryAction() (Model, tea.Cmd) {
|
||||||
|
if m.screen == screenInstances {
|
||||||
|
row, found := m.selectedInstance()
|
||||||
|
if !found {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if row.container.Running() {
|
||||||
|
return m.stopContainer(row.container.Name)
|
||||||
|
}
|
||||||
|
return m.restartContainer(row.container.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
row, found := m.selectedDev()
|
||||||
|
if !found {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if row.running {
|
||||||
|
return m.stopContainer(row.containerName())
|
||||||
|
}
|
||||||
|
if row.kind == rowLocal {
|
||||||
|
return m.startLocal(row)
|
||||||
|
}
|
||||||
|
if row.hasCurrentLocalImage() {
|
||||||
|
if m.remoteImageAvailable(row) {
|
||||||
|
return m.selectImage(row)
|
||||||
|
}
|
||||||
|
return m.startLocalPR(row)
|
||||||
|
}
|
||||||
|
switch row.image {
|
||||||
|
case ui.ImageUnknown, ui.ImageChecking:
|
||||||
|
m.status, m.statusLevel = "still checking whether CI published an image for "+row.label(), levelWarn
|
||||||
|
return m, nil
|
||||||
|
case ui.ImageMissing:
|
||||||
|
m.status, m.statusLevel = fmt.Sprintf("CI has not published an image for PR #%d yet — press R to build it locally", row.pr.Number), levelWarn
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m.deployRemotePR(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) remoteImageAvailable(row devRow) bool {
|
||||||
|
return row.kind == rowRemote && m.tagsKnown && registry.HasPRImage(m.tags, row.pr.Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) selectImage(row devRow) (Model, tea.Cmd) {
|
||||||
|
m.imageSelection = imageSelection{rowKey: row.key(), choice: imageChoiceLocal}
|
||||||
|
m.mode = modeImageSelect
|
||||||
|
m.status, m.statusLevel = "choose an image source for "+row.label(), levelInfo
|
||||||
|
m.relayout()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) startLocal(row devRow) (Model, tea.Cmd) {
|
||||||
|
plan, err := run.BuildPlan(run.Options{Context: context.Background(), Tag: row.local.Tag, Demo: m.demo})
|
||||||
|
if err != nil {
|
||||||
|
m.status, m.statusLevel = err.Error(), levelError
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m.startPlan("start "+row.local.Reference(), row.containerName(), plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) startLocalPR(row devRow) (Model, tea.Cmd) {
|
||||||
|
plan, err := localPRPlan(row, m.demo)
|
||||||
|
if err != nil {
|
||||||
|
m.status, m.statusLevel = err.Error(), levelError
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m.startPlan(fmt.Sprintf("start PR #%d from local build", row.pr.Number), row.containerName(), plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// localPRPlan runs a locally built image under the pull request's container and
|
||||||
|
// volume identity, so switching between a local build and the registry build of
|
||||||
|
// the same pull request keeps the same data and port.
|
||||||
|
func localPRPlan(row devRow, demo bool) (*run.Plan, error) {
|
||||||
|
plan, err := run.BuildPlan(run.Options{Context: context.Background(), Tag: row.local.Tag, Demo: demo})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
plan.Name = row.containerName()
|
||||||
|
plan.Volume = row.volumeName()
|
||||||
|
plan.Label = fmt.Sprintf("PR #%d · local %s", row.pr.Number, truncateRevision(row.local.Revision))
|
||||||
|
plan.PRNumber = row.pr.Number
|
||||||
|
return plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateRevision(revision string) string {
|
||||||
|
if len(revision) > 12 {
|
||||||
|
return revision[:12]
|
||||||
|
}
|
||||||
|
return revision
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) startPlan(title, container string, plan *run.Plan) (Model, tea.Cmd) {
|
||||||
|
m.tasks.Action(title, container, func(ctx context.Context) error {
|
||||||
|
plan.Pull = false
|
||||||
|
return run.StartDetached(plan)
|
||||||
|
})
|
||||||
|
m.status, m.statusLevel = fmt.Sprintf("%s → http://localhost:%d", title, plan.HostPort), levelInfo
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deployRemotePR pulls the registry image and starts it as one job, so the
|
||||||
|
// sidebar shows a single continuous progress stream rather than two.
|
||||||
|
func (m Model) deployRemotePR(row devRow) (Model, tea.Cmd) {
|
||||||
|
if existing, running := m.tasks.RunningFor(row.containerName()); running {
|
||||||
|
m.status, m.statusLevel = existing.Title+" is already running", levelWarn
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
plan, err := run.BuildPlan(run.Options{Context: context.Background(), PR: row.pr.Number, Demo: m.demo})
|
||||||
|
if err != nil {
|
||||||
|
m.status, m.statusLevel = err.Error(), levelError
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
// Reuse the port a running instance already holds so a redeploy does not
|
||||||
|
// move the URL out from under an open browser tab.
|
||||||
|
if row.running && row.port != "" {
|
||||||
|
if port, err := strconv.Atoi(row.port); err == nil {
|
||||||
|
plan.HostPort = port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title := fmt.Sprintf("pull and start PR #%d", row.pr.Number)
|
||||||
|
created := m.tasks.Start(task.KindPull, title, row.containerName(), func(ctx context.Context, report *task.Reporter) error {
|
||||||
|
if err := task.PullImage(ctx, report, plan.Image, plan.Platform); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
report.SetDetail("starting container on port " + strconv.Itoa(plan.HostPort))
|
||||||
|
plan.Pull = false
|
||||||
|
return run.StartDetached(plan)
|
||||||
|
})
|
||||||
|
return m.focusOn(created.ID(), title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildLocally rebuilds the selection in the background. A pull request without
|
||||||
|
// a local image is cloned into a temporary checkout first.
|
||||||
|
func (m Model) buildLocally(subject target) (Model, tea.Cmd) {
|
||||||
|
if !subject.buildable {
|
||||||
|
m.status, m.statusLevel = subject.label+" has no build source to rebuild from", levelWarn
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
reference := "homarr:" + subject.provenance.Tag
|
||||||
|
if existing, running := m.tasks.RunningFor(reference); running {
|
||||||
|
m.status, m.statusLevel = existing.Title+" is already running", levelWarn
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
image := subject.provenance
|
||||||
|
title := "build " + reference
|
||||||
|
created := m.tasks.BuildFrom(title, reference, func(ctx context.Context, report *task.Reporter) (docker.BuildOptions, error) {
|
||||||
|
options, cleanup, err := run.RebuildOptions(ctx, image, report.Log)
|
||||||
|
if err != nil {
|
||||||
|
return docker.BuildOptions{}, err
|
||||||
|
}
|
||||||
|
// The checkout has to outlive option construction but not the build,
|
||||||
|
// and BuildFrom runs both inside one job, so releasing it when the job
|
||||||
|
// goroutine unwinds is both correct and the simplest thing available.
|
||||||
|
context.AfterFunc(ctx, cleanup)
|
||||||
|
return options, nil
|
||||||
|
})
|
||||||
|
return m.focusOn(created.ID(), title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) pullRemote(row devRow) (Model, tea.Cmd) {
|
||||||
|
if row.kind != rowRemote {
|
||||||
|
m.status, m.statusLevel = "pulling only applies to pull-request images", levelWarn
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m.deployRemotePR(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// focusOn points the tray and sidebar at a freshly started job so its progress
|
||||||
|
// is visible without any further keystrokes.
|
||||||
|
func (m Model) focusOn(id int, title string) (Model, tea.Cmd) {
|
||||||
|
m.focusTask = id
|
||||||
|
m.sidebar.visible = true
|
||||||
|
m.sidebar.source = sourceTask
|
||||||
|
m.sidebar.follow = true
|
||||||
|
m.status, m.statusLevel = title+" started in the background", levelInfo
|
||||||
|
m.relayout()
|
||||||
|
cmd := m.refreshSidebar()
|
||||||
|
clocks := m.animate()
|
||||||
|
return m, tea.Batch(cmd, clocks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) stopContainer(name string) (Model, tea.Cmd) {
|
||||||
|
m.tasks.Action("stop "+name, name, func(ctx context.Context) error { return docker.StopContext(ctx, name) })
|
||||||
|
m.status, m.statusLevel = "stopping "+name+"…", levelInfo
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) restartContainer(name string) (Model, tea.Cmd) {
|
||||||
|
m.tasks.Action("restart "+name, name, func(ctx context.Context) error { return docker.RestartContext(ctx, name) })
|
||||||
|
m.status, m.statusLevel = "restarting "+name+"…", levelInfo
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) removeContainer(name string) (Model, tea.Cmd) {
|
||||||
|
m.tasks.Action("remove "+name, name, func(ctx context.Context) error { return docker.RemoveContext(ctx, name) })
|
||||||
|
m.status, m.statusLevel = "removing "+name+"…", levelInfo
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) deleteImage(reference string) (Model, tea.Cmd) {
|
||||||
|
m.tasks.Action("delete "+reference, reference, func(ctx context.Context) error {
|
||||||
|
return docker.RemoveImage(ctx, reference)
|
||||||
|
})
|
||||||
|
m.status, m.statusLevel = "deleting "+reference+"…", levelInfo
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteData removes an instance's volume. Docker refuses to delete a volume
|
||||||
|
// that is still attached, so the container is removed first — the data is going
|
||||||
|
// away either way, and failing halfway would leave the user to work out why.
|
||||||
|
func (m Model) deleteData(subject target) (Model, tea.Cmd) {
|
||||||
|
container, volume := subject.container, subject.volume
|
||||||
|
m.tasks.Action("delete data "+volume, volume, func(ctx context.Context) error {
|
||||||
|
_ = docker.RemoveContext(ctx, container)
|
||||||
|
return docker.RemoveVolume(ctx, volume)
|
||||||
|
})
|
||||||
|
m.status, m.statusLevel = "deleting "+volume+"…", levelInfo
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) pruneStopped() (Model, tea.Cmd) {
|
||||||
|
names := make([]string, 0)
|
||||||
|
for _, container := range m.containers {
|
||||||
|
if !container.Running() {
|
||||||
|
names = append(names, container.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
m.status, m.statusLevel = "no stopped Homarr containers to prune", levelInfo
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.tasks.Action(fmt.Sprintf("prune %d stopped containers", len(names)), "prune", func(ctx context.Context) error {
|
||||||
|
for _, name := range names {
|
||||||
|
if err := docker.RemoveContext(ctx, name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
m.status, m.statusLevel = fmt.Sprintf("pruning %d stopped containers…", len(names)), levelInfo
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) openPullRequest() (Model, tea.Cmd) {
|
||||||
|
row, found := m.selectedDev()
|
||||||
|
if !found || row.kind != rowRemote {
|
||||||
|
m.status, m.statusLevel = "select a pull request to open it on GitHub", levelWarn
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m.openPullRequestNumber(row.pr.Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) openPullRequestNumber(number int) (Model, tea.Cmd) {
|
||||||
|
if number <= 0 {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
url := fmt.Sprintf("https://github.com/%s/pull/%d", gitHubRepo, number)
|
||||||
|
if err := platform.OpenURL(url); err != nil {
|
||||||
|
m.status, m.statusLevel = "open failed: "+err.Error(), levelError
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.status, m.statusLevel = "opened "+url, levelOK
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitHubRepo = "homarr-labs/homarr"
|
||||||
|
|
||||||
|
func (m Model) openApp() (Model, tea.Cmd) {
|
||||||
|
subject, found := m.currentTarget()
|
||||||
|
if !found || !subject.running || subject.port == "" {
|
||||||
|
m.status, m.statusLevel = "selection is not running", levelWarn
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
url := "http://localhost:" + subject.port
|
||||||
|
if err := platform.OpenURL(url); err != nil {
|
||||||
|
m.status, m.statusLevel = "open failed: "+err.Error(), levelError
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.status, m.statusLevel = "opened "+url, levelOK
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) copyURL() (Model, tea.Cmd) {
|
||||||
|
subject, found := m.currentTarget()
|
||||||
|
if !found || subject.port == "" {
|
||||||
|
m.status, m.statusLevel = "selection has no published port", levelWarn
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
url := "http://localhost:" + subject.port
|
||||||
|
if err := platform.CopyText(url); err != nil {
|
||||||
|
m.status, m.statusLevel = "copy failed: "+err.Error(), levelError
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.status, m.statusLevel = "copied "+url, levelOK
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// remoteImageFor is used by the detail view to name the registry image a row
|
||||||
|
// would pull.
|
||||||
|
func remoteImageFor(row devRow) string {
|
||||||
|
if row.kind != rowRemote {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return registry.ImageReference(row.pr.Number)
|
||||||
|
}
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"charm.land/bubbles/v2/spinner"
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
type appModel struct {
|
|
||||||
dev prsModel
|
|
||||||
dashboard dashModel
|
|
||||||
showDashboard bool
|
|
||||||
width int
|
|
||||||
height int
|
|
||||||
}
|
|
||||||
|
|
||||||
func newAppModel(showDashboard bool) appModel {
|
|
||||||
return appModel{dev: newPRsModel(), dashboard: newDashModel(), showDashboard: showDashboard}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m appModel) Init() tea.Cmd {
|
|
||||||
if m.showDashboard {
|
|
||||||
return m.dashboard.Init()
|
|
||||||
}
|
|
||||||
return m.dev.Init()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m appModel) canSwitch() bool {
|
|
||||||
if m.showDashboard {
|
|
||||||
return !m.dashboard.filtering
|
|
||||||
}
|
|
||||||
return !m.dev.filtering && !m.dev.pulling
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m appModel) switchScreen() (tea.Model, tea.Cmd) {
|
|
||||||
m.showDashboard = !m.showDashboard
|
|
||||||
commands := make([]tea.Cmd, 0, 2)
|
|
||||||
if m.width > 0 && m.height > 0 {
|
|
||||||
resize := tea.WindowSizeMsg{Width: m.width, Height: m.height}
|
|
||||||
if m.showDashboard {
|
|
||||||
updated, command := m.dashboard.Update(resize)
|
|
||||||
m.dashboard = updated.(dashModel)
|
|
||||||
commands = append(commands, command)
|
|
||||||
} else {
|
|
||||||
updated, command := m.dev.Update(resize)
|
|
||||||
m.dev = updated.(prsModel)
|
|
||||||
commands = append(commands, command)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if m.showDashboard {
|
|
||||||
commands = append(commands, m.dashboard.Init())
|
|
||||||
} else {
|
|
||||||
commands = append(commands, m.dev.Init())
|
|
||||||
}
|
|
||||||
return m, tea.Batch(commands...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m appModel) updateDev(msg tea.Msg) (appModel, tea.Cmd) {
|
|
||||||
updated, command := m.dev.Update(msg)
|
|
||||||
m.dev = updated.(prsModel)
|
|
||||||
return m, command
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m appModel) updateDashboard(msg tea.Msg) (appModel, tea.Cmd) {
|
|
||||||
updated, command := m.dashboard.Update(msg)
|
|
||||||
m.dashboard = updated.(dashModel)
|
|
||||||
return m, command
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
if window, ok := msg.(tea.WindowSizeMsg); ok {
|
|
||||||
m.width = window.Width
|
|
||||||
m.height = window.Height
|
|
||||||
m, devCommand := m.updateDev(msg)
|
|
||||||
m, dashboardCommand := m.updateDashboard(msg)
|
|
||||||
return m, tea.Batch(devCommand, dashboardCommand)
|
|
||||||
}
|
|
||||||
if key, ok := msg.(tea.KeyPressMsg); ok {
|
|
||||||
if key.String() == "d" && m.canSwitch() {
|
|
||||||
return m.switchScreen()
|
|
||||||
}
|
|
||||||
if m.showDashboard {
|
|
||||||
return m.updateDashboard(msg)
|
|
||||||
}
|
|
||||||
return m.updateDev(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch msg.(type) {
|
|
||||||
case prsLoadedMsg, imageCheckedMsg, prActionMsg, rebuildDoneMsg, runningRefreshedMsg, pullReadyMsg, pullEvent, spinner.TickMsg:
|
|
||||||
return m.updateDev(msg)
|
|
||||||
case tickMsg, actionMsg, rowsMsg:
|
|
||||||
if m.showDashboard {
|
|
||||||
return m.updateDashboard(msg)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
case prLogTickMsg:
|
|
||||||
if !m.showDashboard {
|
|
||||||
return m.updateDev(msg)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
case logsMsg:
|
|
||||||
m, devCommand := m.updateDev(msg)
|
|
||||||
m, dashboardCommand := m.updateDashboard(msg)
|
|
||||||
return m, tea.Batch(devCommand, dashboardCommand)
|
|
||||||
}
|
|
||||||
if m.showDashboard {
|
|
||||||
return m.updateDashboard(msg)
|
|
||||||
}
|
|
||||||
return m.updateDev(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m appModel) View() tea.View {
|
|
||||||
if m.showDashboard {
|
|
||||||
return m.dashboard.View()
|
|
||||||
}
|
|
||||||
return m.dev.View()
|
|
||||||
}
|
|
||||||
|
|
||||||
func runApp(showDashboard bool) error {
|
|
||||||
program := tea.NewProgram(newAppModel(showDashboard))
|
|
||||||
_, err := program.Run()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAppTogglePreservesDevelopmentState(t *testing.T) {
|
|
||||||
m := newAppModel(false)
|
|
||||||
m.dev.rows = []prRow{{kind: "remote", pr: gh.PR{Number: 6502}}}
|
|
||||||
|
|
||||||
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"}))
|
|
||||||
dashboard := updated.(appModel)
|
|
||||||
if !dashboard.showDashboard {
|
|
||||||
t.Fatal("d did not open the instance dashboard")
|
|
||||||
}
|
|
||||||
updated, _ = dashboard.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"}))
|
|
||||||
development := updated.(appModel)
|
|
||||||
if development.showDashboard || len(development.dev.rows) != 1 || development.dev.rows[0].pr.Number != 6502 {
|
|
||||||
t.Fatalf("development state was not preserved: %#v", development.dev.rows)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAppToggleDoesNotInterceptFilterInput(t *testing.T) {
|
|
||||||
m := newAppModel(false)
|
|
||||||
m.dev.filtering = true
|
|
||||||
m.dev.filter.Focus()
|
|
||||||
|
|
||||||
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"}))
|
|
||||||
got := updated.(appModel)
|
|
||||||
if got.showDashboard || got.dev.filter.Value() != "d" {
|
|
||||||
t.Fatalf("filter input was intercepted: dashboard=%v filter=%q", got.showDashboard, got.dev.filter.Value())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAppCanOpenInstancesDuringBuild(t *testing.T) {
|
|
||||||
m := newAppModel(false)
|
|
||||||
m.dev.rebuilding = true
|
|
||||||
|
|
||||||
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"}))
|
|
||||||
if !updated.(appModel).showDashboard {
|
|
||||||
t.Fatal("background build blocked dashboard navigation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"charm.land/bubbles/v2/table"
|
|
||||||
"charm.land/bubbles/v2/textinput"
|
|
||||||
"charm.land/bubbles/v2/viewport"
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
"charm.land/lipgloss/v2"
|
|
||||||
|
|
||||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
|
||||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/platform"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212"))
|
|
||||||
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
|
||||||
logHeadSt = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
|
||||||
runningLegend = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Render("● running")
|
|
||||||
stoppedLegend = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("○ stopped")
|
|
||||||
)
|
|
||||||
|
|
||||||
type dashModel struct {
|
|
||||||
table table.Model
|
|
||||||
allRows []table.Row
|
|
||||||
filter textinput.Model
|
|
||||||
filtering bool
|
|
||||||
logs viewport.Model
|
|
||||||
showLogs bool
|
|
||||||
follow bool
|
|
||||||
status string
|
|
||||||
confirmRemove string
|
|
||||||
width int
|
|
||||||
height int
|
|
||||||
}
|
|
||||||
|
|
||||||
type tickMsg time.Time
|
|
||||||
type actionMsg string
|
|
||||||
type rowsMsg []table.Row
|
|
||||||
type logsMsg struct {
|
|
||||||
name string
|
|
||||||
content string
|
|
||||||
}
|
|
||||||
|
|
||||||
func tick() tea.Cmd {
|
|
||||||
return tea.Tick(3*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchLogs(name string) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
if name == "" {
|
|
||||||
return logsMsg{}
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
out, err := exec.CommandContext(ctx, "docker", "logs", "--tail", "200", name).CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return logsMsg{name: name, content: "logs unavailable: " + err.Error()}
|
|
||||||
}
|
|
||||||
return logsMsg{name: name, content: string(out)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadRows() tea.Cmd {
|
|
||||||
return func() tea.Msg { return rowsMsg(refreshRows()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func refreshRows() []table.Row {
|
|
||||||
containers, _ := docker.List()
|
|
||||||
rows := make([]table.Row, 0, len(containers))
|
|
||||||
for _, c := range containers {
|
|
||||||
port := c.HostPort()
|
|
||||||
url := ""
|
|
||||||
if port != "" && c.Running() {
|
|
||||||
url = "http://localhost:" + port
|
|
||||||
}
|
|
||||||
state := "● " + c.State
|
|
||||||
if !c.Running() {
|
|
||||||
state = "○ " + c.State
|
|
||||||
}
|
|
||||||
img := strings.TrimPrefix(c.Image, "ghcr.io/homarr-labs/")
|
|
||||||
rows = append(rows, table.Row{c.Name, state, img, port, url, c.Status})
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
func dashboardColumns(width int) []table.Column {
|
|
||||||
nameWidth := 26
|
|
||||||
imageWidth := 30
|
|
||||||
urlWidth := 24
|
|
||||||
if width < 100 {
|
|
||||||
nameWidth = 22
|
|
||||||
imageWidth = max(width-54, 18)
|
|
||||||
urlWidth = 0
|
|
||||||
}
|
|
||||||
return []table.Column{
|
|
||||||
{Title: "NAME", Width: nameWidth},
|
|
||||||
{Title: "STATE", Width: 14},
|
|
||||||
{Title: "IMAGE", Width: imageWidth},
|
|
||||||
{Title: "PORT", Width: 6},
|
|
||||||
{Title: "URL", Width: urlWidth},
|
|
||||||
{Title: "STATUS", Width: 0},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newDashModel() dashModel {
|
|
||||||
t := table.New(
|
|
||||||
table.WithColumns(dashboardColumns(104)),
|
|
||||||
table.WithFocused(true),
|
|
||||||
table.WithHeight(10),
|
|
||||||
table.WithWidth(104),
|
|
||||||
)
|
|
||||||
s := table.DefaultStyles()
|
|
||||||
s.Header = s.Header.Bold(true)
|
|
||||||
s.Selected = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("57"))
|
|
||||||
t.SetStyles(s)
|
|
||||||
logs := viewport.New(viewport.WithWidth(104), viewport.WithHeight(12))
|
|
||||||
logs.SoftWrap = true
|
|
||||||
filter := textinput.New()
|
|
||||||
filter.Prompt = "/ "
|
|
||||||
filter.Placeholder = "filter name, image, port, state…"
|
|
||||||
filter.SetWidth(50)
|
|
||||||
styles := textinput.DefaultDarkStyles()
|
|
||||||
styles.Focused.Prompt = styles.Focused.Prompt.Foreground(lipgloss.Color("212")).Bold(true)
|
|
||||||
styles.Focused.Text = styles.Focused.Text.Foreground(lipgloss.Color("229"))
|
|
||||||
filter.SetStyles(styles)
|
|
||||||
return dashModel{table: t, filter: filter, logs: logs, showLogs: true, follow: true, width: 104, height: 30}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *dashModel) applyFilter() {
|
|
||||||
query := strings.ToLower(strings.TrimSpace(m.filter.Value()))
|
|
||||||
if query == "" {
|
|
||||||
m.table.SetRows(m.allRows)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rows := make([]table.Row, 0, len(m.allRows))
|
|
||||||
for _, row := range m.allRows {
|
|
||||||
if strings.Contains(strings.ToLower(strings.Join(row, " ")), query) {
|
|
||||||
rows = append(rows, row)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.table.SetRows(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m dashModel) selected() string {
|
|
||||||
row := m.table.SelectedRow()
|
|
||||||
if len(row) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return row[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m dashModel) selectedPort() string {
|
|
||||||
row := m.table.SelectedRow()
|
|
||||||
if len(row) < 4 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return row[3]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m dashModel) Init() tea.Cmd {
|
|
||||||
return tea.Batch(tick(), loadRows(), fetchLogs(m.selected()))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m dashModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
||||||
switch msg := msg.(type) {
|
|
||||||
case tea.WindowSizeMsg:
|
|
||||||
m.width = msg.Width
|
|
||||||
m.height = msg.Height
|
|
||||||
m.table.SetColumns(dashboardColumns(msg.Width))
|
|
||||||
m.table.SetWidth(msg.Width)
|
|
||||||
m.logs.SetWidth(msg.Width)
|
|
||||||
m.filter.SetWidth(max(min(msg.Width-4, 60), 10))
|
|
||||||
m.layout()
|
|
||||||
return m, nil
|
|
||||||
case tickMsg:
|
|
||||||
return m, tea.Batch(tick(), loadRows())
|
|
||||||
case rowsMsg:
|
|
||||||
m.allRows = []table.Row(msg)
|
|
||||||
m.applyFilter()
|
|
||||||
return m, fetchLogs(m.selected())
|
|
||||||
case logsMsg:
|
|
||||||
if msg.name != "" && msg.name != m.selected() {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.logs.SetContent(strings.TrimRight(msg.content, "\n"))
|
|
||||||
if m.follow {
|
|
||||||
m.logs.GotoBottom()
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
case actionMsg:
|
|
||||||
m.status = string(msg)
|
|
||||||
m.confirmRemove = ""
|
|
||||||
return m, loadRows()
|
|
||||||
case tea.KeyPressMsg:
|
|
||||||
if m.filtering {
|
|
||||||
switch msg.Key().Code {
|
|
||||||
case tea.KeyEscape:
|
|
||||||
m.filtering = false
|
|
||||||
m.filter.Reset()
|
|
||||||
m.filter.Blur()
|
|
||||||
m.applyFilter()
|
|
||||||
return m, fetchLogs(m.selected())
|
|
||||||
case tea.KeyEnter:
|
|
||||||
m.filtering = false
|
|
||||||
m.filter.Blur()
|
|
||||||
return m, fetchLogs(m.selected())
|
|
||||||
}
|
|
||||||
var cmd tea.Cmd
|
|
||||||
m.filter, cmd = m.filter.Update(msg)
|
|
||||||
m.applyFilter()
|
|
||||||
return m, tea.Batch(cmd, fetchLogs(m.selected()))
|
|
||||||
}
|
|
||||||
name := m.selected()
|
|
||||||
switch msg.Key().Code {
|
|
||||||
case tea.KeyUp, tea.KeyKpUp:
|
|
||||||
m.table.MoveUp(1)
|
|
||||||
return m, fetchLogs(m.selected())
|
|
||||||
case tea.KeyDown, tea.KeyKpDown:
|
|
||||||
m.table.MoveDown(1)
|
|
||||||
return m, fetchLogs(m.selected())
|
|
||||||
case tea.KeyPgUp, tea.KeyKpPgUp:
|
|
||||||
m.follow = false
|
|
||||||
m.logs.PageUp()
|
|
||||||
return m, nil
|
|
||||||
case tea.KeyPgDown, tea.KeyKpPgDown:
|
|
||||||
m.logs.PageDown()
|
|
||||||
m.follow = m.logs.AtBottom()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
switch msg.String() {
|
|
||||||
case "q", "ctrl+c", "esc":
|
|
||||||
return m, tea.Quit
|
|
||||||
case "l":
|
|
||||||
m.showLogs = !m.showLogs
|
|
||||||
m.layout()
|
|
||||||
if m.showLogs {
|
|
||||||
return m, fetchLogs(name)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
case "/":
|
|
||||||
m.filtering = true
|
|
||||||
return m, m.filter.Focus()
|
|
||||||
case "f":
|
|
||||||
m.follow = !m.follow
|
|
||||||
if m.follow {
|
|
||||||
m.logs.GotoBottom()
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
case "s":
|
|
||||||
if name != "" {
|
|
||||||
m.status = "stopping " + name + "…"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
if err := docker.Stop(name); err != nil {
|
|
||||||
return actionMsg("stop failed: " + err.Error())
|
|
||||||
}
|
|
||||||
return actionMsg("stopped " + name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "x":
|
|
||||||
if name != "" {
|
|
||||||
if m.confirmRemove != name {
|
|
||||||
m.confirmRemove = name
|
|
||||||
m.status = "press x again to remove " + name
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
m.status = "removing " + name + "…"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
if err := docker.Remove(name); err != nil {
|
|
||||||
return actionMsg("remove failed: " + err.Error())
|
|
||||||
}
|
|
||||||
return actionMsg("removed " + name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "R":
|
|
||||||
if name != "" {
|
|
||||||
m.status = "restarting " + name + "…"
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
if err := docker.Restart(name); err != nil {
|
|
||||||
return actionMsg("restart failed: " + err.Error())
|
|
||||||
}
|
|
||||||
return actionMsg("restarted " + name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "o":
|
|
||||||
if name != "" {
|
|
||||||
if port := m.selectedPort(); port != "" {
|
|
||||||
if err := platform.OpenURL("http://localhost:" + port); err != nil {
|
|
||||||
m.status = "open failed: " + err.Error()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "c":
|
|
||||||
if name != "" {
|
|
||||||
if port := m.selectedPort(); port != "" {
|
|
||||||
url := "http://localhost:" + port
|
|
||||||
if err := platform.CopyText(url); err != nil {
|
|
||||||
m.status = "copy failed: " + err.Error()
|
|
||||||
} else {
|
|
||||||
m.status = "copied " + url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case "r":
|
|
||||||
return m, loadRows()
|
|
||||||
case "k":
|
|
||||||
m.table.MoveUp(1)
|
|
||||||
return m, fetchLogs(m.selected())
|
|
||||||
case "j":
|
|
||||||
m.table.MoveDown(1)
|
|
||||||
return m, fetchLogs(m.selected())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var cmd tea.Cmd
|
|
||||||
m.table, cmd = m.table.Update(msg)
|
|
||||||
return m, cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *dashModel) layout() {
|
|
||||||
available := m.height - 11
|
|
||||||
if m.filtering || m.filter.Value() != "" {
|
|
||||||
available -= 2
|
|
||||||
}
|
|
||||||
if !m.showLogs {
|
|
||||||
m.table.SetHeight(max(available, 5))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tableH := min(12, max(available/2, 5))
|
|
||||||
m.table.SetHeight(tableH)
|
|
||||||
logsH := available - tableH
|
|
||||||
if logsH < 3 {
|
|
||||||
logsH = 3
|
|
||||||
}
|
|
||||||
m.logs.SetHeight(logsH)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m dashModel) selectedDetailView() string {
|
|
||||||
row := m.table.SelectedRow()
|
|
||||||
if len(row) < 6 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")).Render(row[0])
|
|
||||||
meta := row[2] + " · " + row[1]
|
|
||||||
if row[3] != "" {
|
|
||||||
meta += " · port " + row[3]
|
|
||||||
}
|
|
||||||
if row[4] != "" {
|
|
||||||
meta += " · " + row[4]
|
|
||||||
}
|
|
||||||
if row[5] != "" {
|
|
||||||
meta += " · " + row[5]
|
|
||||||
}
|
|
||||||
return title + "\n" + helpStyle.Render(meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m dashModel) View() tea.View {
|
|
||||||
var b strings.Builder
|
|
||||||
running := 0
|
|
||||||
for _, row := range m.allRows {
|
|
||||||
if len(row) > 1 && strings.Contains(row[1], "running") {
|
|
||||||
running++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.WriteString(titleStyle.Render("🦞 homarr instances") + " " + helpStyle.Render(fmt.Sprintf("%d total · %d running · %d shown", len(m.allRows), running, len(m.table.Rows()))))
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
if m.filtering || m.filter.Value() != "" {
|
|
||||||
b.WriteString(m.filter.View() + "\n\n")
|
|
||||||
}
|
|
||||||
if len(m.table.Rows()) == 0 {
|
|
||||||
message := "no homarr containers — start one with `homarr run <tag>` or `homarr run --pr N`"
|
|
||||||
if len(m.allRows) > 0 {
|
|
||||||
message = "no instances match " + m.filter.Value()
|
|
||||||
}
|
|
||||||
b.WriteString(helpStyle.Render(" " + message + "\n"))
|
|
||||||
} else {
|
|
||||||
b.WriteString(m.table.View())
|
|
||||||
b.WriteString("\n")
|
|
||||||
}
|
|
||||||
if detail := m.selectedDetailView(); detail != "" {
|
|
||||||
b.WriteString("\n" + detail + "\n")
|
|
||||||
}
|
|
||||||
if m.showLogs && len(m.table.Rows()) > 0 {
|
|
||||||
mode := "paused"
|
|
||||||
if m.follow {
|
|
||||||
mode = "following"
|
|
||||||
}
|
|
||||||
b.WriteString("\n" + logHeadSt.Render("── logs: "+m.selected()+" ("+mode+") ──") + "\n")
|
|
||||||
b.WriteString(m.logs.View())
|
|
||||||
}
|
|
||||||
if m.status != "" {
|
|
||||||
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
|
||||||
lower := strings.ToLower(m.status)
|
|
||||||
if strings.Contains(lower, "fail") || strings.Contains(lower, "error") {
|
|
||||||
statusStyle = statusStyle.Foreground(lipgloss.Color("196"))
|
|
||||||
} else if strings.Contains(lower, "press x") {
|
|
||||||
statusStyle = statusStyle.Foreground(lipgloss.Color("214"))
|
|
||||||
}
|
|
||||||
b.WriteString("\n" + statusStyle.Render(m.status))
|
|
||||||
}
|
|
||||||
b.WriteString("\n\n" + helpStyle.Render("/ filter · ↑/↓ select · l logs · f follow · pgup/pgdn scroll · s stop · R restart · x remove · o open · c copy URL · r refresh · d development · q quit"))
|
|
||||||
b.WriteString("\n" + helpStyle.Render(runningLegend+" · "+stoppedLegend))
|
|
||||||
v := tea.NewView(b.String())
|
|
||||||
v.AltScreen = true
|
|
||||||
v.WindowTitle = "🦞 Homarr instances"
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func RunDashboard() error {
|
|
||||||
return runApp(true)
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"charm.land/bubbles/v2/table"
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDashboardRendersRowsAndLogs(t *testing.T) {
|
|
||||||
m := newDashModel()
|
|
||||||
m.table.SetRows([]table.Row{{
|
|
||||||
"homarr_pr_6441",
|
|
||||||
"running",
|
|
||||||
"homarr-test:pr-6441",
|
|
||||||
"7575",
|
|
||||||
"http://localhost:7575",
|
|
||||||
"Up 2 minutes",
|
|
||||||
}})
|
|
||||||
m.logs.SetWidth(104)
|
|
||||||
m.logs.SetHeight(5)
|
|
||||||
m.logs.SetContent("server ready")
|
|
||||||
|
|
||||||
got := m.View().Content
|
|
||||||
for _, want := range []string{"homarr_pr_6441", "server ready"} {
|
|
||||||
if !strings.Contains(got, want) {
|
|
||||||
t.Fatalf("dashboard omitted %q: %q", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !m.View().AltScreen {
|
|
||||||
t.Fatal("dashboard must use the alternate screen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDashboardArrowNavigation(t *testing.T) {
|
|
||||||
m := newDashModel()
|
|
||||||
m.table.SetRows([]table.Row{
|
|
||||||
{"homarr_first", "running", "homarr:first", "7575", "", "Up 1 minute"},
|
|
||||||
{"homarr_second", "running", "homarr:second", "7576", "", "Up 1 minute"},
|
|
||||||
})
|
|
||||||
|
|
||||||
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown}))
|
|
||||||
got := updated.(dashModel).selected()
|
|
||||||
if got != "homarr_second" {
|
|
||||||
t.Fatalf("down arrow selected %q, want homarr_second", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDashboardIgnoresStaleLogs(t *testing.T) {
|
|
||||||
m := newDashModel()
|
|
||||||
m.table.SetRows([]table.Row{{"homarr_current", "running", "homarr:current", "7575", "", "Up 1 minute"}})
|
|
||||||
m.logs.SetWidth(104)
|
|
||||||
m.logs.SetHeight(5)
|
|
||||||
m.logs.SetContent("current logs")
|
|
||||||
|
|
||||||
updated, _ := m.Update(logsMsg{name: "homarr_old", content: "stale logs"})
|
|
||||||
got := updated.(dashModel).View().Content
|
|
||||||
if strings.Contains(got, "stale logs") || !strings.Contains(got, "current logs") {
|
|
||||||
t.Fatalf("stale log result replaced current content: %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/logs"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/registry"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/task"
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadedMsg carries a full refresh. Every source is fetched concurrently and
|
||||||
|
// each failure is reported separately, so a missing GitHub CLI degrades the
|
||||||
|
// pull-request list without hiding local images.
|
||||||
|
type loadedMsg struct {
|
||||||
|
generation int
|
||||||
|
prs []gh.PR
|
||||||
|
images []docker.Image
|
||||||
|
containers []docker.Container
|
||||||
|
volumes []docker.Volume
|
||||||
|
tags map[string]bool
|
||||||
|
prErr error
|
||||||
|
imageErr error
|
||||||
|
tagErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// containersMsg is the cheap periodic refresh; it skips GitHub and the registry.
|
||||||
|
type containersMsg struct {
|
||||||
|
generation int
|
||||||
|
containers []docker.Container
|
||||||
|
volumes []docker.Volume
|
||||||
|
}
|
||||||
|
|
||||||
|
type (
|
||||||
|
tasksChangedMsg struct{}
|
||||||
|
logsChangedMsg struct{}
|
||||||
|
ciChecksMsg struct {
|
||||||
|
pr int
|
||||||
|
checks []gh.Check
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
pollCIChecksMsg struct {
|
||||||
|
pr int
|
||||||
|
}
|
||||||
|
frameMsg time.Time
|
||||||
|
refreshMsg time.Time
|
||||||
|
statusMsg struct {
|
||||||
|
text string
|
||||||
|
level statusLevel
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
loadTimeout = 45 * time.Second
|
||||||
|
refreshEvery = 3 * time.Second
|
||||||
|
frameEvery = 90 * time.Millisecond
|
||||||
|
prListingLimit = 60
|
||||||
|
)
|
||||||
|
|
||||||
|
// loadAll fetches every data source in parallel and reports them as one
|
||||||
|
// message, so the table is only rebuilt once per refresh.
|
||||||
|
func loadAll(generation int, includeBots, refresh bool) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), loadTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
group sync.WaitGroup
|
||||||
|
message = loadedMsg{generation: generation}
|
||||||
|
)
|
||||||
|
group.Add(3)
|
||||||
|
go func() {
|
||||||
|
defer group.Done()
|
||||||
|
if refresh {
|
||||||
|
message.prs, message.prErr = gh.RefreshPRs(ctx, prListingLimit, includeBots)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.prs, message.prErr = gh.ListPRs(ctx, prListingLimit, includeBots)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer group.Done()
|
||||||
|
message.images, message.imageErr = docker.ListLocalImages(ctx)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer group.Done()
|
||||||
|
message.tags, message.tagErr = registry.PublishedTags(ctx, refresh)
|
||||||
|
}()
|
||||||
|
group.Wait()
|
||||||
|
|
||||||
|
message.containers, _ = docker.List()
|
||||||
|
message.volumes, _ = docker.ListVolumes(ctx, true)
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadContainers is the lightweight poll that keeps running state current
|
||||||
|
// without paying for GitHub, registry or disk-usage round trips.
|
||||||
|
func loadContainers(generation int) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
containers, _ := docker.List()
|
||||||
|
volumes, _ := docker.ListVolumes(ctx, false)
|
||||||
|
return containersMsg{generation: generation, containers: containers, volumes: volumes}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForTasks parks a goroutine on the task manager's coalescing wake-up
|
||||||
|
// channel. Exactly one of these is in flight at a time; each wake-up re-arms
|
||||||
|
// itself, which is what lets background work drive redraws without polling.
|
||||||
|
func waitForTasks(manager *task.Manager) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
<-manager.Changed()
|
||||||
|
return tasksChangedMsg{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForLogs mirrors waitForTasks for the live container log streams.
|
||||||
|
func waitForLogs(registry *logs.Registry) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
<-registry.Changed()
|
||||||
|
return logsChangedMsg{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tickRefresh() tea.Cmd {
|
||||||
|
return tea.Tick(refreshEvery, func(now time.Time) tea.Msg { return refreshMsg(now) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// tickFrame drives spinner and progress-bar animation. It only runs while
|
||||||
|
// something is actually moving, so an idle CLI costs no wake-ups at all.
|
||||||
|
func tickFrame() tea.Cmd {
|
||||||
|
return tea.Tick(frameEvery, func(now time.Time) tea.Msg { return frameMsg(now) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func notify(text string, level statusLevel) tea.Cmd {
|
||||||
|
return func() tea.Msg { return statusMsg{text: text, level: level} }
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchCIChecks(pr int, refresh bool) tea.Cmd {
|
||||||
|
if pr <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return func() tea.Msg {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
checks, err := gh.GetPRChecks(ctx, pr, refresh)
|
||||||
|
return ciChecksMsg{pr: pr, checks: checks, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,228 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
|
||||||
|
|
||||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
|
||||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPRTableRendersRows(t *testing.T) {
|
|
||||||
m := newPRsModel()
|
|
||||||
m.rows = []prRow{{
|
|
||||||
kind: "remote",
|
|
||||||
pr: gh.PR{Number: 6441, Title: "Human PR", Author: "ajnart", CIState: "SUCCESS"},
|
|
||||||
imageState: "yes",
|
|
||||||
}}
|
|
||||||
m.table.SetRows(buildTableRows(m.rows))
|
|
||||||
|
|
||||||
if got := m.View().Content; !strings.Contains(got, "Human PR") {
|
|
||||||
t.Fatalf("table view omitted rows: %q", got)
|
|
||||||
}
|
|
||||||
if !m.View().AltScreen {
|
|
||||||
t.Fatal("development browser must use the alternate screen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPRTableArrowNavigation(t *testing.T) {
|
|
||||||
m := newPRsModel()
|
|
||||||
m.rows = []prRow{
|
|
||||||
{kind: "remote", pr: gh.PR{Number: 1, Title: "First"}},
|
|
||||||
{kind: "remote", pr: gh.PR{Number: 2, Title: "Second"}},
|
|
||||||
}
|
|
||||||
m.table.SetRows(buildTableRows(m.rows))
|
|
||||||
|
|
||||||
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown}))
|
|
||||||
got := updated.(prsModel).table.SelectedRow()
|
|
||||||
if len(got) < 3 || got[2] != "2" {
|
|
||||||
t.Fatalf("down arrow selected %v, want PR 2", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPRImageChecksAreBounded(t *testing.T) {
|
|
||||||
m := newPRsModel()
|
|
||||||
prs := make([]gh.PR, 20)
|
|
||||||
for i := range prs {
|
|
||||||
prs[i] = gh.PR{Number: i + 1, Title: "PR"}
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, cmd := m.Update(prsLoadedMsg{prs: prs})
|
|
||||||
got := updated.(prsModel)
|
|
||||||
if cmd == nil {
|
|
||||||
t.Fatal("expected image check commands")
|
|
||||||
}
|
|
||||||
if got.imageActive != 6 || len(got.imageQueue) != 14 {
|
|
||||||
t.Fatalf("active=%d queued=%d, want active=6 queued=14", got.imageActive, len(got.imageQueue))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPRLoadedReusesImageChecksUnlessRefreshIsForced(t *testing.T) {
|
|
||||||
m := newPRsModel()
|
|
||||||
m.rows = []prRow{{kind: "remote", pr: gh.PR{Number: 1}, imageState: "yes"}}
|
|
||||||
|
|
||||||
updated, _ := m.Update(prsLoadedMsg{prs: []gh.PR{{Number: 1}, {Number: 2}}})
|
|
||||||
cached := updated.(prsModel)
|
|
||||||
if cached.rows[0].imageState != "yes" || len(cached.imageQueue) != 0 || cached.imageActive != 1 {
|
|
||||||
t.Fatalf("cached states were not reused: rows=%#v queue=%v active=%d", cached.rows, cached.imageQueue, cached.imageActive)
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, _ = cached.Update(prsLoadedMsg{prs: []gh.PR{{Number: 1}, {Number: 2}}, refresh: true})
|
|
||||||
refreshed := updated.(prsModel)
|
|
||||||
if refreshed.rows[0].imageState != "checking" || refreshed.imageActive != 2 {
|
|
||||||
t.Fatalf("forced refresh reused image states: rows=%#v active=%d", refreshed.rows, refreshed.imageActive)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDevelopmentRowsFoldCurrentLocalPRImage(t *testing.T) {
|
|
||||||
prs := []gh.PR{{Number: 6502, HeadSHA: "current"}}
|
|
||||||
images := []docker.Image{
|
|
||||||
{Tag: "pr-6502", PRNumber: 6502, Revision: "current"},
|
|
||||||
{Tag: "pr-6502-stale", PRNumber: 6502, Revision: "stale"},
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, queue := developmentRows(prs, images, nil)
|
|
||||||
if len(rows) != 2 || !rows[0].hasCurrentLocalImage() || rows[0].local.Tag != "pr-6502" {
|
|
||||||
t.Fatalf("current image was not folded into PR row: %#v", rows)
|
|
||||||
}
|
|
||||||
if rows[1].kind != "local" || rows[1].local.Tag != "pr-6502-stale" {
|
|
||||||
t.Fatalf("stale image should remain separate: %#v", rows[1])
|
|
||||||
}
|
|
||||||
if got := buildTableRows(rows)[0][6]; got != imgLocal {
|
|
||||||
t.Fatalf("image marker = %q, want %q", got, imgLocal)
|
|
||||||
}
|
|
||||||
if len(queue) != 0 {
|
|
||||||
t.Fatalf("current local image queued unnecessary registry checks: %v", queue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLocalPRPlanUsesPRContainerIdentity(t *testing.T) {
|
|
||||||
row := prRow{
|
|
||||||
kind: "remote",
|
|
||||||
pr: gh.PR{Number: 6502, HeadSHA: "abcdef1234567890"},
|
|
||||||
local: docker.Image{Tag: "pr-6502", PRNumber: 6502, Revision: "abcdef1234567890"},
|
|
||||||
}
|
|
||||||
plan, err := localPRPlan(row, false, func(int) int { return 7575 })
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if plan.Image != "homarr:pr-6502" || plan.Name != "homarr_pr_6502" || plan.Volume != "homarr_pr_6502_data" || plan.Pull || plan.PRNumber != 6502 {
|
|
||||||
t.Fatalf("unexpected local PR plan: %#v", plan)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRemotePRBuildUsesTemporaryPRWorkflow(t *testing.T) {
|
|
||||||
image, action, status := localBuildForRow(prRow{kind: "remote", pr: gh.PR{Number: 6441}})
|
|
||||||
if image.Tag != "pr-6441" || image.PRNumber != 6441 || action != "built" || !strings.Contains(status, "building PR #6441 locally") {
|
|
||||||
t.Fatalf("image=%#v action=%q status=%q", image, action, status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPRImageCheckInProgressDoesNotLaunch(t *testing.T) {
|
|
||||||
m := newPRsModel()
|
|
||||||
m.rows = []prRow{{kind: "remote", pr: gh.PR{Number: 6441, Title: "Pending image"}, imageState: "checking"}}
|
|
||||||
m.table.SetRows(buildTableRows(m.rows))
|
|
||||||
|
|
||||||
updated, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter}))
|
|
||||||
got := updated.(prsModel)
|
|
||||||
if cmd != nil {
|
|
||||||
t.Fatal("unavailable image returned a launch command")
|
|
||||||
}
|
|
||||||
if !strings.Contains(got.status, "still in progress") {
|
|
||||||
t.Fatalf("status = %q, want launch rejection", got.status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPullErrorIncludesLastDockerMessage(t *testing.T) {
|
|
||||||
err := commandErrorWithDetail(errors.New("exit status 1"), "manifest unknown")
|
|
||||||
if !strings.Contains(err.Error(), "manifest unknown") {
|
|
||||||
t.Fatalf("pull error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOutputTailRetainsBuildFailure(t *testing.T) {
|
|
||||||
tail := &outputTail{}
|
|
||||||
_, _ = tail.Write([]byte("building\r\nERROR: Dockerfile not found\n"))
|
|
||||||
if got := tail.lastLine(); got != "ERROR: Dockerfile not found" {
|
|
||||||
t.Fatalf("last line = %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDevTableRendersLocalImageSource(t *testing.T) {
|
|
||||||
m := newPRsModel()
|
|
||||||
m.rows = []prRow{{
|
|
||||||
kind: "local",
|
|
||||||
local: docker.Image{Tag: "feature", Source: "/tmp/homarr", Revision: "abcdef1234567890"},
|
|
||||||
imageState: "yes",
|
|
||||||
}}
|
|
||||||
m.table.SetRows(buildTableRows(m.rows))
|
|
||||||
|
|
||||||
content := m.View().Content
|
|
||||||
for _, want := range []string{"local", "homarr:feature", "/tmp/homarr", "abcdef123456"} {
|
|
||||||
if !strings.Contains(content, want) {
|
|
||||||
t.Fatalf("development view omitted %q: %q", want, content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRebuildCompletionRestoresLoadingState(t *testing.T) {
|
|
||||||
m := newPRsModel()
|
|
||||||
m.rebuilding = true
|
|
||||||
image := docker.Image{Tag: "feature"}
|
|
||||||
|
|
||||||
updated, cmd := m.Update(rebuildDoneMsg{image: image})
|
|
||||||
got := updated.(prsModel)
|
|
||||||
if got.rebuilding || !got.loading || got.status != "rebuilt homarr:feature" || cmd == nil {
|
|
||||||
t.Fatalf("unexpected rebuild state: rebuilding=%v loading=%v status=%q", got.rebuilding, got.loading, got.status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRebuildCanBeCanceled(t *testing.T) {
|
|
||||||
m := newPRsModel()
|
|
||||||
canceled := false
|
|
||||||
m.rebuilding = true
|
|
||||||
m.rebuildCancel = func() { canceled = true }
|
|
||||||
|
|
||||||
updated, cmd := m.Update(tea.KeyPressMsg(tea.Key{Code: 'q'}))
|
|
||||||
got := updated.(prsModel)
|
|
||||||
if !canceled || cmd != nil || got.status != "canceling rebuild…" {
|
|
||||||
t.Fatalf("cancel state: canceled=%v status=%q", canceled, got.status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNavigationRemainsAvailableDuringBuild(t *testing.T) {
|
|
||||||
m := newPRsModel()
|
|
||||||
m.rebuilding = true
|
|
||||||
m.rows = []prRow{
|
|
||||||
{kind: "remote", pr: gh.PR{Number: 1}},
|
|
||||||
{kind: "remote", pr: gh.PR{Number: 2}},
|
|
||||||
}
|
|
||||||
m.table.SetRows(buildTableRows(m.rows))
|
|
||||||
|
|
||||||
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyDown}))
|
|
||||||
selected := updated.(prsModel).table.SelectedRow()
|
|
||||||
if len(selected) < 3 || selected[2] != "2" {
|
|
||||||
t.Fatalf("build blocked navigation: %v", selected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompletedPullWithoutPlanDoesNotPanic(t *testing.T) {
|
|
||||||
m := newPRsModel()
|
|
||||||
m.pulling = true
|
|
||||||
m.pullCancel = func() {}
|
|
||||||
|
|
||||||
updated, cmd := m.Update(pullEvent{done: true})
|
|
||||||
got := updated.(prsModel)
|
|
||||||
if got.pulling || cmd != nil {
|
|
||||||
t.Fatalf("pulling=%v cmd=%v", got.pulling, cmd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTruncateTextPreservesUTF8(t *testing.T) {
|
|
||||||
if got := truncateText("🦞homarr", 2); got != "🦞h" {
|
|
||||||
t.Fatalf("truncateText() = %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/task"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sample() (Model, tea.Cmd) {
|
||||||
|
m := New(false)
|
||||||
|
updated, _ := m.Update(tea.WindowSizeMsg{Width: 140, Height: 40})
|
||||||
|
m = updated.(Model)
|
||||||
|
loaded := loadedMsg{
|
||||||
|
prs: []gh.PR{
|
||||||
|
{Number: 6646, Title: "feat: rework the developer CLI from the ground up", Author: "ajnart", HeadRef: "excited-pelican", HeadSHA: "abc123", CIState: "SUCCESS"},
|
||||||
|
{Number: 6612, Title: "fix(board): keep the item modal aligned", Author: "meierschlurf", HeadRef: "fix/modal", HeadSHA: "def456", CIState: "PENDING"},
|
||||||
|
{Number: 6503, Title: "chore(deps): bump everything", Author: "renovate[bot]", HeadRef: "renovate/all", HeadSHA: "ghi789", CIState: "FAILURE", IsDraft: true},
|
||||||
|
},
|
||||||
|
images: []docker.Image{
|
||||||
|
{ID: "1", Tag: "pr-6646", PRNumber: 6646, Revision: "abc123", Size: "492MB", Created: "33 minutes ago", Source: "https://github.com/homarr-labs/homarr/pull/6646"},
|
||||||
|
{ID: "2", Tag: "dev", Revision: "zzz999", Size: "481MB", Created: "2 days ago", Source: "/Users/ajnart/homarr"},
|
||||||
|
},
|
||||||
|
containers: []docker.Container{
|
||||||
|
{ID: "c1", Name: "homarr_pr_6612", Image: "ghcr.io/homarr-labs/homarr-test:pr-6612", State: "running", Status: "Up 45 hours (healthy)", Ports: "0.0.0.0:7576->7575/tcp"},
|
||||||
|
{ID: "c2", Name: "homarr_dev", Image: "homarr:dev", State: "exited", Status: "Exited (0) 3 weeks ago"},
|
||||||
|
},
|
||||||
|
volumes: []docker.Volume{{Name: "homarr_pr_6612_data", Size: "6.8MB"}, {Name: "homarr_dev_data", Size: "12MB"}},
|
||||||
|
tags: map[string]bool{"pr-6612": true, "pr-6503": true},
|
||||||
|
}
|
||||||
|
next, cmd := m.Update(loaded)
|
||||||
|
return next.(Model), cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderDev(t *testing.T) {
|
||||||
|
m, _ := sample()
|
||||||
|
fmt.Println("=========== DEV ===========")
|
||||||
|
fmt.Println(m.View().Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderInstances(t *testing.T) {
|
||||||
|
m, _ := sample()
|
||||||
|
next, _ := m.switchScreen(screenInstances)
|
||||||
|
fmt.Println("=========== INSTANCES ===========")
|
||||||
|
fmt.Println(next.View().Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDebugSelection(t *testing.T) {
|
||||||
|
m, _ := sample()
|
||||||
|
t.Logf("devFiltered=%d cursor=%d rows=%d", len(m.devFiltered), m.devTable.Cursor(), len(m.devTable.Rows()))
|
||||||
|
row, found := m.selectedDev()
|
||||||
|
t.Logf("selectedDev found=%v key=%q", found, row.key())
|
||||||
|
t.Logf("detailVisible=%v", m.detailVisible())
|
||||||
|
t.Logf("detail=%q", m.detail())
|
||||||
|
t.Logf("bodyHeight=%d sidebarCols=%d height=%d", m.bodyHeight, m.sidebarCols, m.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fakePullTask(m Model) Model {
|
||||||
|
m.tasks.Start(task.KindPull, "pull and start PR #6612", "homarr_pr_6612", func(ctx context.Context, report *task.Reporter) error {
|
||||||
|
report.SetDetail("4/6 layers · 128.4MB/492.1MB · 12.4MB/s")
|
||||||
|
report.SetPercent(0.58)
|
||||||
|
report.SetSteps([]task.Step{
|
||||||
|
{ID: "a1b2c3d4e5f6", Label: "a1b2c3d4e5f6", Status: "Pull complete", Percent: 1, Done: true, Note: "48.2MB"},
|
||||||
|
{ID: "b2c3d4e5f6a7", Label: "b2c3d4e5f6a7", Status: "Already exists", Percent: 1, Done: true, Note: "12.1MB"},
|
||||||
|
{ID: "c3d4e5f6a7b8", Label: "c3d4e5f6a7b8", Status: "Extracting", Percent: 0.72, Note: "88.4MB/122.0MB"},
|
||||||
|
{ID: "d4e5f6a7b8c9", Label: "d4e5f6a7b8c9", Status: "Downloading", Percent: 0.21, Note: "18.2MB/86.5MB"},
|
||||||
|
{ID: "e5f6a7b8c9d0", Label: "e5f6a7b8c9d0", Status: "Waiting", Percent: 0},
|
||||||
|
})
|
||||||
|
<-ctx.Done()
|
||||||
|
return ctx.Err()
|
||||||
|
})
|
||||||
|
time.Sleep(60 * time.Millisecond)
|
||||||
|
next, _ := m.applyTasks()
|
||||||
|
next.focusTask = 1
|
||||||
|
next.sidebar.source = sourceTask
|
||||||
|
next.relayout()
|
||||||
|
next.refreshSidebar()
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderPullProgress(t *testing.T) {
|
||||||
|
m, _ := sample()
|
||||||
|
m = fakePullTask(m)
|
||||||
|
fmt.Println("=========== PULL ===========")
|
||||||
|
fmt.Println(m.View().Content)
|
||||||
|
m.tasks.CancelAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderManageAndConfirm(t *testing.T) {
|
||||||
|
m, _ := sample()
|
||||||
|
updated, _ := m.handleKey(tea.KeyPressMsg{Code: 'm', Text: "m"})
|
||||||
|
manage := updated.(Model)
|
||||||
|
fmt.Println("=========== MANAGE ===========")
|
||||||
|
fmt.Println(manage.View().Content)
|
||||||
|
|
||||||
|
updated, _ = manage.handleKey(tea.KeyPressMsg{Code: 'd', Text: "d"})
|
||||||
|
fmt.Println("=========== CONFIRM ===========")
|
||||||
|
fmt.Println(updated.(Model).View().Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderTaskOverlayAndHelp(t *testing.T) {
|
||||||
|
m, _ := sample()
|
||||||
|
m = fakePullTask(m)
|
||||||
|
updated, _ := m.handleKey(tea.KeyPressMsg{Code: 't', Text: "t"})
|
||||||
|
fmt.Println("=========== TASKS ===========")
|
||||||
|
fmt.Println(updated.(Model).View().Content)
|
||||||
|
m.tasks.CancelAll()
|
||||||
|
|
||||||
|
m2, _ := sample()
|
||||||
|
updated, _ = m2.handleKey(tea.KeyPressMsg{Code: '?', Text: "?"})
|
||||||
|
fmt.Println("=========== HELP ===========")
|
||||||
|
fmt.Println(updated.(Model).View().Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderNarrow(t *testing.T) {
|
||||||
|
m, _ := sample()
|
||||||
|
updated, _ := m.Update(tea.WindowSizeMsg{Width: 84, Height: 30})
|
||||||
|
fmt.Println("=========== NARROW (stacked sidebar) ===========")
|
||||||
|
fmt.Println(updated.(Model).View().Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSidebarTaskAutoSwitchToLogsOnSuccess(t *testing.T) {
|
||||||
|
m, _ := sample()
|
||||||
|
started := m.tasks.Start(task.KindPull, "pull PR #6612", "homarr_pr_6612", func(ctx context.Context, report *task.Reporter) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
next, _ := m.focusOn(started.ID(), "pull PR #6612")
|
||||||
|
if next.sidebar.source != sourceTask {
|
||||||
|
t.Fatalf("expected sidebar source to be sourceTask while task is focused, got %v", next.sidebar.source)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
next, _ = next.applyTasks()
|
||||||
|
|
||||||
|
if next.sidebar.source != sourceLogs {
|
||||||
|
t.Fatalf("expected sidebar source to auto-switch to sourceLogs on task success, got %v", next.sidebar.source)
|
||||||
|
}
|
||||||
|
if next.focusTask != 0 {
|
||||||
|
t.Fatalf("expected focusTask to reset to 0 on task success, got %d", next.focusTask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSidebarTaskAutoSwitchToLogsOnCancel(t *testing.T) {
|
||||||
|
m, _ := sample()
|
||||||
|
started := m.tasks.Start(task.KindBuild, "build homarr:dev", "homarr:dev", func(ctx context.Context, report *task.Reporter) error {
|
||||||
|
<-ctx.Done()
|
||||||
|
return ctx.Err()
|
||||||
|
})
|
||||||
|
next, _ := m.focusOn(started.ID(), "build homarr:dev")
|
||||||
|
if next.sidebar.source != sourceTask {
|
||||||
|
t.Fatalf("expected sidebar source to be sourceTask, got %v", next.sidebar.source)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.tasks.Cancel(started.ID())
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
next, _ = next.applyTasks()
|
||||||
|
|
||||||
|
if next.sidebar.source != sourceLogs {
|
||||||
|
t.Fatalf("expected sidebar source to auto-switch to sourceLogs on task cancel, got %v", next.sidebar.source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstanceScreenActionsAndManageChords(t *testing.T) {
|
||||||
|
m, _ := sample()
|
||||||
|
next, _ := m.switchScreen(screenInstances)
|
||||||
|
if next.screen != screenInstances {
|
||||||
|
t.Fatalf("expected screenInstances, got %v", next.screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test manage mode entry
|
||||||
|
updated, _ := next.handleKey(tea.KeyPressMsg{Code: 'm', Text: "m"})
|
||||||
|
manage := updated.(Model)
|
||||||
|
if manage.mode != modeManage {
|
||||||
|
t.Fatalf("expected modeManage, got %v", manage.mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Press Esc to return to normal mode
|
||||||
|
updated, _ = manage.handleKey(tea.KeyPressMsg{Code: tea.KeyEscape})
|
||||||
|
normal := updated.(Model)
|
||||||
|
if normal.mode != modeNormal {
|
||||||
|
t.Fatalf("expected modeNormal after Esc, got %v", normal.mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSidebarRelevanceOnNavigationAndPinning(t *testing.T) {
|
||||||
|
m, _ := sample()
|
||||||
|
|
||||||
|
// Row 0 is PR 6646 (not running, no container)
|
||||||
|
if m.sidebarRelevant() {
|
||||||
|
t.Fatalf("expected sidebar to NOT be relevant for row without container")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move down to Row 1 (PR 6612, which is running)
|
||||||
|
moved, _ := m.moveSelection(1)
|
||||||
|
if !moved.sidebarRelevant() {
|
||||||
|
t.Fatalf("expected sidebar to BE relevant for running container")
|
||||||
|
}
|
||||||
|
if moved.logTarget() != "homarr_pr_6612" {
|
||||||
|
t.Fatalf("expected logTarget=homarr_pr_6612, got %q", moved.logTarget())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move down to Row 2 (PR 6503, no container)
|
||||||
|
moved2, _ := moved.moveSelection(1)
|
||||||
|
if moved2.sidebarRelevant() {
|
||||||
|
t.Fatalf("expected sidebar to NOT be relevant for row without container")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin while on Row 1
|
||||||
|
pinned, _ := moved.togglePin()
|
||||||
|
if pinned.sidebar.pinned != "homarr_pr_6612" {
|
||||||
|
t.Fatalf("expected pinned container to be homarr_pr_6612, got %q", pinned.sidebar.pinned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to Row 2 while pinned -> sidebar remains relevant!
|
||||||
|
movedPinned, _ := pinned.moveSelection(1)
|
||||||
|
if !movedPinned.sidebarRelevant() {
|
||||||
|
t.Fatalf("expected sidebar to remain relevant when a valid container is pinned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSidebarTabCycleAndCIRendering(t *testing.T) {
|
||||||
|
m, _ := sample()
|
||||||
|
|
||||||
|
// Initial source is sourceLogs
|
||||||
|
if m.sidebar.source != sourceLogs {
|
||||||
|
t.Fatalf("expected sourceLogs, got %v", m.sidebar.source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Press Tab (no active tasks) -> cycles to sourceCI
|
||||||
|
tabbed, _ := m.handleKey(tea.KeyPressMsg{Code: tea.KeyTab})
|
||||||
|
mTabbed := tabbed.(Model)
|
||||||
|
if mTabbed.sidebar.source != sourceCI {
|
||||||
|
t.Fatalf("expected sourceCI after Tab, got %v", mTabbed.sidebar.source)
|
||||||
|
}
|
||||||
|
if !mTabbed.sidebarRelevant() {
|
||||||
|
t.Fatalf("expected sidebar to be relevant on PR row when in sourceCI mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supply mock CI checks for PR 6646
|
||||||
|
updated, _ := mTabbed.Update(ciChecksMsg{
|
||||||
|
pr: 6646,
|
||||||
|
checks: []gh.Check{
|
||||||
|
{Name: "Fast gate", Bucket: "pass", State: "SUCCESS", StartedAt: "2026-08-21T10:00:00Z", CompletedAt: "2026-08-21T10:12:00Z"},
|
||||||
|
{Name: "Build amd64", Bucket: "pass", State: "SUCCESS", StartedAt: "2026-08-21T10:12:00Z", CompletedAt: "2026-08-21T10:17:00Z"},
|
||||||
|
{Name: "E2E tests", Bucket: "pending", State: "PENDING", StartedAt: "2026-08-21T10:17:00Z"},
|
||||||
|
{Name: "publish", Bucket: "skipping", State: "SKIPPED"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
mCI := updated.(Model)
|
||||||
|
if mCI.sidebar.title != "ci · PR #6646" {
|
||||||
|
t.Fatalf("unexpected title: %q", mCI.sidebar.title)
|
||||||
|
}
|
||||||
|
if !strings.Contains(mCI.sidebar.subtitle, "2 pass") || !strings.Contains(mCI.sidebar.subtitle, "1 pending") || !strings.Contains(mCI.sidebar.subtitle, "1 skipped") {
|
||||||
|
t.Fatalf("unexpected subtitle: %q", mCI.sidebar.subtitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Press Tab again -> cycles back to sourceLogs
|
||||||
|
tabbedAgain, _ := mCI.handleKey(tea.KeyPressMsg{Code: tea.KeyTab})
|
||||||
|
mLogs := tabbedAgain.(Model)
|
||||||
|
if mLogs.sidebar.source != sourceLogs {
|
||||||
|
t.Fatalf("expected sourceLogs after Tab, got %v", mLogs.sidebar.source)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
"charm.land/bubbles/v2/table"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/task"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleKey routes by mode. Modes are what let one letter mean several things
|
||||||
|
// without ambiguity: `d` switches screens normally and deletes data once manage
|
||||||
|
// mode is armed, and neither reading can leak into the other.
|
||||||
|
func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch m.mode {
|
||||||
|
case modeFilter:
|
||||||
|
return m.handleFilterKey(msg)
|
||||||
|
case modeConfirm:
|
||||||
|
return m.handleConfirmKey(msg)
|
||||||
|
case modeImageSelect:
|
||||||
|
return m.handleImageSelectKey(msg)
|
||||||
|
case modeManage:
|
||||||
|
return m.handleManageKey(msg)
|
||||||
|
case modeHelp:
|
||||||
|
return m.handleOverlayKey(msg)
|
||||||
|
case modeTasks:
|
||||||
|
return m.handleTasksKey(msg)
|
||||||
|
default:
|
||||||
|
return m.handleNormalKey(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) handleImageSelectKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keys.Cancel):
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.imageSelection = imageSelection{}
|
||||||
|
m.status, m.statusLevel = "launch canceled", levelInfo
|
||||||
|
m.relayout()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Up), key.Matches(msg, m.keys.Down):
|
||||||
|
if m.imageSelection.choice == imageChoiceLocal {
|
||||||
|
m.imageSelection.choice = imageChoiceRemote
|
||||||
|
} else {
|
||||||
|
m.imageSelection.choice = imageChoiceLocal
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Enter):
|
||||||
|
selection := m.imageSelection
|
||||||
|
row, found := m.selectedDev()
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.imageSelection = imageSelection{}
|
||||||
|
if !found || row.key() != selection.rowKey {
|
||||||
|
m.status, m.statusLevel = "image selection expired; choose a row again", levelWarn
|
||||||
|
m.relayout()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.relayout()
|
||||||
|
if selection.choice == imageChoiceRemote {
|
||||||
|
return m.deployRemotePR(row)
|
||||||
|
}
|
||||||
|
return m.startLocalPR(row)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) handleNormalKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keys.Quit):
|
||||||
|
m.tasks.CancelAll()
|
||||||
|
m.logs.Close()
|
||||||
|
return m, tea.Quit
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Help):
|
||||||
|
m.mode = modeHelp
|
||||||
|
m.relayout()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Tasks):
|
||||||
|
m.mode = modeTasks
|
||||||
|
m.taskList = m.tasks.Snapshots()
|
||||||
|
m.taskCursor = max(len(m.taskList)-1, 0)
|
||||||
|
m.relayout()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Manage):
|
||||||
|
subject, found := m.currentTarget()
|
||||||
|
if !found {
|
||||||
|
m.status, m.statusLevel = "nothing selected to manage", levelWarn
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.mode = modeManage
|
||||||
|
m.status, m.statusLevel = "manage "+subject.label, levelInfo
|
||||||
|
m.relayout()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Filter):
|
||||||
|
m.mode = modeFilter
|
||||||
|
m.relayout()
|
||||||
|
return m, m.filter.Focus()
|
||||||
|
|
||||||
|
// A submitted filter keeps narrowing the table after the input loses focus,
|
||||||
|
// so escape has to be able to clear it from normal mode too.
|
||||||
|
case key.Matches(msg, m.keys.Cancel):
|
||||||
|
if m.filter.Value() == "" {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.filter.Reset()
|
||||||
|
m.applyFilter()
|
||||||
|
m.relayout()
|
||||||
|
cmd := m.refreshSidebar()
|
||||||
|
m.status, m.statusLevel = "filter cleared", levelInfo
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Refresh):
|
||||||
|
m.generation++
|
||||||
|
m.loading = true
|
||||||
|
m.status, m.statusLevel = "refreshing…", levelInfo
|
||||||
|
clocks := m.animate()
|
||||||
|
return m, tea.Batch(loadAll(m.generation, m.includeBots, true), clocks)
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Dev):
|
||||||
|
return m.switchScreen(screenDev)
|
||||||
|
case key.Matches(msg, m.keys.Instances):
|
||||||
|
return m.switchScreen(screenInstances)
|
||||||
|
case key.Matches(msg, m.keys.Cycle):
|
||||||
|
if m.screen == screenDev {
|
||||||
|
return m.switchScreen(screenInstances)
|
||||||
|
}
|
||||||
|
return m.switchScreen(screenDev)
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Sidebar):
|
||||||
|
m.sidebar.visible = !m.sidebar.visible
|
||||||
|
m.relayout()
|
||||||
|
cmd := m.refreshSidebar()
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.SidebarTab):
|
||||||
|
switch m.sidebar.source {
|
||||||
|
case sourceLogs:
|
||||||
|
if m.tasks.Running() > 0 || len(m.taskList) > 0 {
|
||||||
|
m.sidebar.source = sourceTask
|
||||||
|
} else {
|
||||||
|
m.sidebar.source = sourceCI
|
||||||
|
}
|
||||||
|
case sourceTask:
|
||||||
|
m.sidebar.source = sourceCI
|
||||||
|
case sourceCI:
|
||||||
|
m.sidebar.source = sourceLogs
|
||||||
|
default:
|
||||||
|
m.sidebar.source = sourceLogs
|
||||||
|
}
|
||||||
|
m.sidebar.visible = true
|
||||||
|
m.relayout()
|
||||||
|
cmd := m.refreshSidebar()
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Pin):
|
||||||
|
return m.togglePin()
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Follow):
|
||||||
|
m.sidebar.toggleFollow()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.PageUp):
|
||||||
|
m.sidebar.pageUp()
|
||||||
|
return m, nil
|
||||||
|
case key.Matches(msg, m.keys.PageDown):
|
||||||
|
m.sidebar.pageDown()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Enter):
|
||||||
|
return m.primaryAction()
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Build):
|
||||||
|
subject, found := m.currentTarget()
|
||||||
|
if !found {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m.buildLocally(subject)
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Up):
|
||||||
|
return m.moveSelection(-1)
|
||||||
|
case key.Matches(msg, m.keys.Down):
|
||||||
|
return m.moveSelection(1)
|
||||||
|
case key.Matches(msg, m.keys.Top):
|
||||||
|
m.activeTable().GotoTop()
|
||||||
|
if m.tasks.Running() == 0 && m.sidebar.source == sourceTask && m.focusTask == 0 {
|
||||||
|
m.sidebar.source = sourceLogs
|
||||||
|
}
|
||||||
|
m.relayout()
|
||||||
|
cmd := m.refreshSidebar()
|
||||||
|
return m, cmd
|
||||||
|
case key.Matches(msg, m.keys.Bottom):
|
||||||
|
m.activeTable().GotoBottom()
|
||||||
|
if m.tasks.Running() == 0 && m.sidebar.source == sourceTask && m.focusTask == 0 {
|
||||||
|
m.sidebar.source = sourceLogs
|
||||||
|
}
|
||||||
|
m.relayout()
|
||||||
|
cmd := m.refreshSidebar()
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.OpenApp):
|
||||||
|
return m.openApp()
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.screen == screenDev {
|
||||||
|
return m.handleDevKey(msg)
|
||||||
|
}
|
||||||
|
return m.handleInstanceKey(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) handleDevKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keys.Pull):
|
||||||
|
row, found := m.selectedDev()
|
||||||
|
if !found {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m.pullRemote(row)
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Demo):
|
||||||
|
m.demo = !m.demo
|
||||||
|
if m.demo {
|
||||||
|
m.status, m.statusLevel = "demo mode on — the next launch mocks integrations", levelWarn
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.status, m.statusLevel = "demo mode off", levelInfo
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Bots):
|
||||||
|
m.includeBots = !m.includeBots
|
||||||
|
m.generation++
|
||||||
|
m.loading = true
|
||||||
|
if m.includeBots {
|
||||||
|
m.status, m.statusLevel = "including bot pull requests…", levelInfo
|
||||||
|
} else {
|
||||||
|
m.status, m.statusLevel = "hiding bot pull requests…", levelInfo
|
||||||
|
}
|
||||||
|
clocks := m.animate()
|
||||||
|
return m, tea.Batch(loadAll(m.generation, m.includeBots, false), clocks)
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.OpenPR):
|
||||||
|
return m.openPullRequest()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) handleInstanceKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
row, found := m.selectedInstance()
|
||||||
|
if !found {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keys.Stop):
|
||||||
|
return m.stopContainer(row.container.Name)
|
||||||
|
case key.Matches(msg, m.keys.Restart):
|
||||||
|
return m.restartContainer(row.container.Name)
|
||||||
|
case key.Matches(msg, m.keys.Remove):
|
||||||
|
return m.arm(actionRemoveContainer, m.instanceTarget(row))
|
||||||
|
case key.Matches(msg, m.keys.Copy):
|
||||||
|
return m.copyURL()
|
||||||
|
case key.Matches(msg, m.keys.OpenPR):
|
||||||
|
if row.container.Running() && row.container.HostPort() != "" {
|
||||||
|
return m.openApp()
|
||||||
|
}
|
||||||
|
if number := row.prNumber(); number > 0 {
|
||||||
|
return m.openPullRequestNumber(number)
|
||||||
|
}
|
||||||
|
return m.openApp()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) handleFilterKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.Key().Code {
|
||||||
|
case tea.KeyEscape:
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.filter.Reset()
|
||||||
|
m.filter.Blur()
|
||||||
|
m.relayout()
|
||||||
|
cmd := m.refreshSidebar()
|
||||||
|
return m, cmd
|
||||||
|
case tea.KeyEnter:
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.filter.Blur()
|
||||||
|
m.relayout()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
var command tea.Cmd
|
||||||
|
m.filter, command = m.filter.Update(msg)
|
||||||
|
m.applyFilter()
|
||||||
|
cmd := m.refreshSidebar()
|
||||||
|
return m, tea.Batch(command, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) handleOverlayKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
if key.Matches(msg, m.keys.Quit) {
|
||||||
|
m.tasks.CancelAll()
|
||||||
|
m.logs.Close()
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.relayout()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) handleTasksKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.Cancel), key.Matches(msg, m.keys.Tasks):
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.relayout()
|
||||||
|
if key.Matches(msg, m.keys.Quit) {
|
||||||
|
m.tasks.CancelAll()
|
||||||
|
m.logs.Close()
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Up):
|
||||||
|
m.taskCursor = max(m.taskCursor-1, 0)
|
||||||
|
return m, nil
|
||||||
|
case key.Matches(msg, m.keys.Down):
|
||||||
|
m.taskCursor = min(m.taskCursor+1, max(len(m.taskList)-1, 0))
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Enter):
|
||||||
|
if snapshot, found := m.taskAt(m.taskCursor); found {
|
||||||
|
m.mode = modeNormal
|
||||||
|
return m.focusOn(snapshot.ID, snapshot.Title)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.Remove):
|
||||||
|
if snapshot, found := m.taskAt(m.taskCursor); found && snapshot.Cancellable {
|
||||||
|
m.tasks.Cancel(snapshot.ID)
|
||||||
|
m.status, m.statusLevel = "canceling "+snapshot.Title+"…", levelWarn
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.tasks.Prune()
|
||||||
|
m.taskList = m.tasks.Snapshots()
|
||||||
|
m.taskCursor = 0
|
||||||
|
m.status, m.statusLevel = "cleared finished tasks", levelInfo
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) taskAt(index int) (task.Snapshot, bool) {
|
||||||
|
if index < 0 || index >= len(m.taskList) {
|
||||||
|
return task.Snapshot{}, false
|
||||||
|
}
|
||||||
|
return m.taskList[index], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) activeTable() *table.Model {
|
||||||
|
if m.screen == screenInstances {
|
||||||
|
return &m.instTable
|
||||||
|
}
|
||||||
|
return &m.devTable
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) moveSelection(delta int) (Model, tea.Cmd) {
|
||||||
|
if delta < 0 {
|
||||||
|
m.activeTable().MoveUp(-delta)
|
||||||
|
} else {
|
||||||
|
m.activeTable().MoveDown(delta)
|
||||||
|
}
|
||||||
|
if m.tasks.Running() == 0 && m.sidebar.source == sourceTask && m.focusTask == 0 {
|
||||||
|
m.sidebar.source = sourceLogs
|
||||||
|
}
|
||||||
|
cmd := m.refreshSidebar()
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) switchScreen(next screen) (Model, tea.Cmd) {
|
||||||
|
if m.screen == next {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.screen = next
|
||||||
|
if m.tasks.Running() == 0 && m.sidebar.source == sourceTask && m.focusTask == 0 {
|
||||||
|
m.sidebar.source = sourceLogs
|
||||||
|
}
|
||||||
|
m.relayout()
|
||||||
|
cmd := m.refreshSidebar()
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// togglePin locks the sidebar onto one container so the user can browse other
|
||||||
|
// rows while still watching the instance they care about.
|
||||||
|
func (m Model) togglePin() (Model, tea.Cmd) {
|
||||||
|
if m.sidebar.pinned != "" {
|
||||||
|
m.status, m.statusLevel = "unpinned "+m.sidebar.pinned, levelInfo
|
||||||
|
m.sidebar.pinned = ""
|
||||||
|
m.relayout()
|
||||||
|
cmd := m.refreshSidebar()
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
name := m.logTarget()
|
||||||
|
if name == "" {
|
||||||
|
m.status, m.statusLevel = "select a container to pin its logs", levelWarn
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.sidebar.pinned = name
|
||||||
|
m.sidebar.source = sourceLogs
|
||||||
|
m.sidebar.visible = true
|
||||||
|
m.status, m.statusLevel = fmt.Sprintf("pinned logs of %s", name), levelOK
|
||||||
|
m.relayout()
|
||||||
|
cmd := m.refreshSidebar()
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
type manageAction int
|
||||||
|
|
||||||
|
const (
|
||||||
|
actionNone manageAction = iota
|
||||||
|
actionBuild
|
||||||
|
actionDeleteImage
|
||||||
|
actionDeleteData
|
||||||
|
actionRemoveContainer
|
||||||
|
actionPrune
|
||||||
|
)
|
||||||
|
|
||||||
|
// confirmation is the armed destructive action awaiting a yes or no.
|
||||||
|
type confirmation struct {
|
||||||
|
action manageAction
|
||||||
|
subject target
|
||||||
|
prompt string
|
||||||
|
detail string
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleManageKey is the second half of the `m` chord. Non-destructive actions
|
||||||
|
// run immediately; anything that deletes state goes through a confirmation, so
|
||||||
|
// a mistyped chord cannot destroy an instance.
|
||||||
|
func (m Model) handleManageKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
if key.Matches(msg, m.keys.Quit) {
|
||||||
|
m.tasks.CancelAll()
|
||||||
|
m.logs.Close()
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
if key.Matches(msg, m.keys.ManageAbort) || key.Matches(msg, m.keys.Manage) {
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.status, m.statusLevel = "manage mode closed", levelInfo
|
||||||
|
m.relayout()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
subject, found := m.currentTarget()
|
||||||
|
if !found {
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.relayout()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keys.ManageBuild):
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.relayout()
|
||||||
|
return m.buildLocally(subject)
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.ManageImage):
|
||||||
|
if subject.localImage == "" {
|
||||||
|
m.status, m.statusLevel = subject.label+" has no local image to delete", levelWarn
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m.arm(actionDeleteImage, subject)
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.ManageData):
|
||||||
|
return m.arm(actionDeleteData, subject)
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.ManageStop):
|
||||||
|
return m.arm(actionRemoveContainer, subject)
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.ManagePrune):
|
||||||
|
return m.arm(actionPrune, subject)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// arm moves into the confirmation mode with a prompt describing exactly what is
|
||||||
|
// about to be destroyed.
|
||||||
|
func (m Model) arm(action manageAction, subject target) (Model, tea.Cmd) {
|
||||||
|
confirm := confirmation{action: action, subject: subject}
|
||||||
|
switch action {
|
||||||
|
case actionDeleteImage:
|
||||||
|
confirm.prompt = "Delete image " + subject.localImage + "?"
|
||||||
|
confirm.detail = "The image is removed from Docker. Rebuild it later with R."
|
||||||
|
case actionDeleteData:
|
||||||
|
confirm.prompt = "Delete data volume " + subject.volume + "?"
|
||||||
|
confirm.detail = "Boards, users and integrations in this instance are lost. The container is removed first."
|
||||||
|
if size := m.volumeSize(subject.volume); size != "" {
|
||||||
|
confirm.detail += " (" + size + " reclaimed)"
|
||||||
|
}
|
||||||
|
case actionRemoveContainer:
|
||||||
|
confirm.prompt = "Remove container " + subject.container + "?"
|
||||||
|
confirm.detail = "The container is force-removed. Its data volume is kept."
|
||||||
|
case actionPrune:
|
||||||
|
stopped := 0
|
||||||
|
for _, container := range m.containers {
|
||||||
|
if !container.Running() {
|
||||||
|
stopped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
confirm.prompt = fmt.Sprintf("Remove %d stopped Homarr containers?", stopped)
|
||||||
|
confirm.detail = "Data volumes are kept."
|
||||||
|
default:
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.confirm = confirm
|
||||||
|
m.mode = modeConfirm
|
||||||
|
// The confirmation is the only prompt that should be speaking.
|
||||||
|
m.status = ""
|
||||||
|
m.relayout()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) volumeSize(name string) string {
|
||||||
|
for _, volume := range m.volumes {
|
||||||
|
if volume.Name == name {
|
||||||
|
return volume.Size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) handleConfirmKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keys.ManageAbort):
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.confirm = confirmation{}
|
||||||
|
m.status, m.statusLevel = "canceled", levelInfo
|
||||||
|
m.relayout()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case key.Matches(msg, m.keys.ManageConfirm):
|
||||||
|
confirm := m.confirm
|
||||||
|
m.mode = modeNormal
|
||||||
|
m.confirm = confirmation{}
|
||||||
|
m.relayout()
|
||||||
|
switch confirm.action {
|
||||||
|
case actionDeleteImage:
|
||||||
|
return m.deleteImage(confirm.subject.localImage)
|
||||||
|
case actionDeleteData:
|
||||||
|
return m.deleteData(confirm.subject)
|
||||||
|
case actionRemoveContainer:
|
||||||
|
return m.removeContainer(confirm.subject.container)
|
||||||
|
case actionPrune:
|
||||||
|
return m.pruneStopped()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// manageBar is the hint strip shown while the chord is armed.
|
||||||
|
func (m Model) manageBar() string {
|
||||||
|
subject, found := m.currentTarget()
|
||||||
|
label := "selection"
|
||||||
|
if found {
|
||||||
|
label = subject.label
|
||||||
|
}
|
||||||
|
return clip(ui.ModeBadge("MANAGE", ui.Warning)+" "+
|
||||||
|
ui.Body.Render(label)+" "+
|
||||||
|
m.helpView.ShortHelpView(m.keys.ManageHelp()), m.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmBar is the destructive-action prompt. It is deliberately loud.
|
||||||
|
func (m Model) confirmBar() string {
|
||||||
|
prompt := ui.ModeBadge("CONFIRM", ui.Danger) + " " + ui.Heading.Render(m.confirm.prompt)
|
||||||
|
detail := ui.Help.Render(m.confirm.detail + " y confirm · n cancel")
|
||||||
|
return clip(prompt, m.width) + "\n" + clip(detail, m.width)
|
||||||
|
}
|
||||||
@@ -0,0 +1,788 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/help"
|
||||||
|
"charm.land/bubbles/v2/key"
|
||||||
|
"charm.land/bubbles/v2/progress"
|
||||||
|
"charm.land/bubbles/v2/spinner"
|
||||||
|
"charm.land/bubbles/v2/table"
|
||||||
|
"charm.land/bubbles/v2/textinput"
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/logs"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/task"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
type screen int
|
||||||
|
|
||||||
|
const (
|
||||||
|
screenDev screen = iota
|
||||||
|
screenInstances
|
||||||
|
)
|
||||||
|
|
||||||
|
// mode is the interaction state. Every key press is routed by mode first, which
|
||||||
|
// is what makes chords like `m` then `i` unambiguous: the same physical key
|
||||||
|
// means different things depending on which mode armed it.
|
||||||
|
type mode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
modeNormal mode = iota
|
||||||
|
modeFilter
|
||||||
|
modeManage
|
||||||
|
modeConfirm
|
||||||
|
modeImageSelect
|
||||||
|
modeHelp
|
||||||
|
modeTasks
|
||||||
|
)
|
||||||
|
|
||||||
|
type imageChoice int
|
||||||
|
|
||||||
|
const (
|
||||||
|
imageChoiceLocal imageChoice = iota
|
||||||
|
imageChoiceRemote
|
||||||
|
)
|
||||||
|
|
||||||
|
type imageSelection struct {
|
||||||
|
rowKey string
|
||||||
|
choice imageChoice
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusLevel int
|
||||||
|
|
||||||
|
const (
|
||||||
|
levelInfo statusLevel = iota
|
||||||
|
levelOK
|
||||||
|
levelWarn
|
||||||
|
levelError
|
||||||
|
)
|
||||||
|
|
||||||
|
type ciCheckState struct {
|
||||||
|
loading bool
|
||||||
|
checks []gh.Check
|
||||||
|
err error
|
||||||
|
updated time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model is the whole application. It owns the data, the two screens, the
|
||||||
|
// sidebar, and the background task manager; the panes are rendering concerns
|
||||||
|
// rather than separate Bubble Tea models, which keeps the message routing flat
|
||||||
|
// and means any key can act on any part of the state.
|
||||||
|
type Model struct {
|
||||||
|
keys ui.KeyMap
|
||||||
|
tasks *task.Manager
|
||||||
|
logs *logs.Registry
|
||||||
|
|
||||||
|
screen screen
|
||||||
|
mode mode
|
||||||
|
|
||||||
|
prs []gh.PR
|
||||||
|
images []docker.Image
|
||||||
|
containers []docker.Container
|
||||||
|
volumes []docker.Volume
|
||||||
|
tags map[string]bool
|
||||||
|
tagsKnown bool
|
||||||
|
|
||||||
|
devRows []devRow
|
||||||
|
devFiltered []devRow
|
||||||
|
instRows []instanceRow
|
||||||
|
instFiltered []instanceRow
|
||||||
|
|
||||||
|
devTable table.Model
|
||||||
|
instTable table.Model
|
||||||
|
filter textinput.Model
|
||||||
|
helpView help.Model
|
||||||
|
spinner spinner.Model
|
||||||
|
progress progress.Model
|
||||||
|
sidebar sidebar
|
||||||
|
ciChecks map[int]ciCheckState
|
||||||
|
|
||||||
|
taskList []task.Snapshot
|
||||||
|
taskStates map[int]task.State
|
||||||
|
taskCursor int
|
||||||
|
focusTask int
|
||||||
|
|
||||||
|
confirm confirmation
|
||||||
|
imageSelection imageSelection
|
||||||
|
|
||||||
|
includeBots bool
|
||||||
|
demo bool
|
||||||
|
loading bool
|
||||||
|
animating bool
|
||||||
|
spinning bool
|
||||||
|
generation int
|
||||||
|
|
||||||
|
status string
|
||||||
|
statusLevel statusLevel
|
||||||
|
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
bodyHeight int
|
||||||
|
sidebarCols int
|
||||||
|
sidebarStacked bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds the application model. showInstances picks the starting screen.
|
||||||
|
func New(showInstances bool) Model {
|
||||||
|
styles := table.DefaultStyles()
|
||||||
|
styles.Header = styles.Header.Bold(true).Foreground(ui.Muted)
|
||||||
|
styles.Selected = ui.Selected
|
||||||
|
|
||||||
|
devTable := table.New(table.WithColumns(devColumns(110)), table.WithFocused(true), table.WithHeight(16), table.WithWidth(110))
|
||||||
|
devTable.SetStyles(styles)
|
||||||
|
instTable := table.New(table.WithColumns(instanceColumns(110)), table.WithFocused(true), table.WithHeight(16), table.WithWidth(110))
|
||||||
|
instTable.SetStyles(styles)
|
||||||
|
|
||||||
|
filter := textinput.New()
|
||||||
|
filter.Prompt = "/ "
|
||||||
|
filter.Placeholder = "filter by PR, title, author, branch, image, state, port…"
|
||||||
|
filter.SetWidth(60)
|
||||||
|
filterStyles := textinput.DefaultDarkStyles()
|
||||||
|
filterStyles.Focused.Prompt = filterStyles.Focused.Prompt.Foreground(ui.Accent).Bold(true)
|
||||||
|
filterStyles.Focused.Text = filterStyles.Focused.Text.Foreground(ui.Bright)
|
||||||
|
filter.SetStyles(filterStyles)
|
||||||
|
|
||||||
|
helpModel := help.New()
|
||||||
|
helpModel.Styles = help.DefaultStyles(true)
|
||||||
|
|
||||||
|
spin := spinner.New(spinner.WithSpinner(spinner.Dot), spinner.WithStyle(lipgloss.NewStyle().Foreground(ui.Accent)))
|
||||||
|
|
||||||
|
start := screenDev
|
||||||
|
if showInstances {
|
||||||
|
start = screenInstances
|
||||||
|
}
|
||||||
|
|
||||||
|
return Model{
|
||||||
|
keys: ui.DefaultKeyMap(),
|
||||||
|
tasks: task.NewManager(),
|
||||||
|
logs: logs.NewRegistry(),
|
||||||
|
screen: start,
|
||||||
|
devTable: devTable,
|
||||||
|
instTable: instTable,
|
||||||
|
filter: filter,
|
||||||
|
helpView: helpModel,
|
||||||
|
spinner: spin,
|
||||||
|
progress: progress.New(progress.WithWidth(24), progress.WithoutPercentage(), progress.WithColors(ui.Accent)),
|
||||||
|
sidebar: newSidebar(),
|
||||||
|
ciChecks: make(map[int]ciCheckState),
|
||||||
|
taskStates: make(map[int]task.State),
|
||||||
|
loading: true,
|
||||||
|
// Init cannot persist state because it takes the model by value, so the
|
||||||
|
// clocks it arms are recorded as running here instead.
|
||||||
|
spinning: true,
|
||||||
|
animating: true,
|
||||||
|
status: "loading pull requests, images and instances…",
|
||||||
|
width: 110,
|
||||||
|
height: 34,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Init() tea.Cmd {
|
||||||
|
return tea.Batch(
|
||||||
|
loadAll(m.generation, m.includeBots, false),
|
||||||
|
waitForTasks(m.tasks),
|
||||||
|
waitForLogs(m.logs),
|
||||||
|
tickRefresh(),
|
||||||
|
m.spinner.Tick,
|
||||||
|
tickFrame(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// animate arms the animation clocks that are needed and not already ticking.
|
||||||
|
// Both clocks stop themselves once nothing is in flight, so an idle CLI does no
|
||||||
|
// work at all, and because arming is centralised a handler cannot start a second
|
||||||
|
// competing tick chain. It mutates the receiver, so callers must hoist the call
|
||||||
|
// out of a `return m, m.animate()` — the order of evaluation there is not
|
||||||
|
// specified, and getting it wrong is exactly how a duplicate chain appears.
|
||||||
|
func (m *Model) animate() tea.Cmd {
|
||||||
|
if !m.busy() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
commands := make([]tea.Cmd, 0, 2)
|
||||||
|
if !m.spinning {
|
||||||
|
m.spinning = true
|
||||||
|
commands = append(commands, m.spinner.Tick)
|
||||||
|
}
|
||||||
|
if !m.animating {
|
||||||
|
m.animating = true
|
||||||
|
commands = append(commands, tickFrame())
|
||||||
|
}
|
||||||
|
return tea.Batch(commands...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width, m.height = msg.Width, msg.Height
|
||||||
|
m.filter.SetWidth(clampInt(msg.Width-6, 20, 72))
|
||||||
|
m.helpView.SetWidth(msg.Width)
|
||||||
|
m.relayout()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case loadedMsg:
|
||||||
|
if msg.generation != m.generation {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
return m.applyLoaded(msg)
|
||||||
|
|
||||||
|
case containersMsg:
|
||||||
|
if msg.generation != m.generation {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
sizesByName := make(map[string]string, len(m.volumes))
|
||||||
|
for _, v := range m.volumes {
|
||||||
|
if v.Size != "" {
|
||||||
|
sizesByName[v.Name] = v.Size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range msg.volumes {
|
||||||
|
if msg.volumes[i].Size == "" {
|
||||||
|
msg.volumes[i].Size = sizesByName[msg.volumes[i].Name]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.containers, m.volumes = msg.containers, msg.volumes
|
||||||
|
m.rebuildRows()
|
||||||
|
m.relayout()
|
||||||
|
cmd := m.refreshSidebar()
|
||||||
|
return m, tea.Batch(cmd, m.syncLogStreams())
|
||||||
|
|
||||||
|
case tasksChangedMsg:
|
||||||
|
return m.applyTasks()
|
||||||
|
|
||||||
|
case logsChangedMsg:
|
||||||
|
cmd := m.refreshSidebar()
|
||||||
|
return m, tea.Batch(cmd, waitForLogs(m.logs))
|
||||||
|
|
||||||
|
case ciChecksMsg:
|
||||||
|
m.ciChecks[msg.pr] = ciCheckState{
|
||||||
|
loading: false,
|
||||||
|
checks: msg.checks,
|
||||||
|
err: msg.err,
|
||||||
|
updated: time.Now(),
|
||||||
|
}
|
||||||
|
cmd := m.refreshSidebar()
|
||||||
|
for _, c := range msg.checks {
|
||||||
|
if c.IsPending() {
|
||||||
|
pollCmd := tea.Tick(6*time.Second, func(t time.Time) tea.Msg {
|
||||||
|
return pollCIChecksMsg{pr: msg.pr}
|
||||||
|
})
|
||||||
|
return m, tea.Batch(cmd, pollCmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
|
case pollCIChecksMsg:
|
||||||
|
if m.sidebar.source == sourceCI && m.selectedPRNumber() == msg.pr {
|
||||||
|
return m, fetchCIChecks(msg.pr, true)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case refreshMsg:
|
||||||
|
return m, tea.Batch(tickRefresh(), loadContainers(m.generation))
|
||||||
|
|
||||||
|
case frameMsg:
|
||||||
|
cmd := m.refreshSidebar()
|
||||||
|
if !m.busy() {
|
||||||
|
m.animating = false
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
return m, tea.Batch(cmd, tickFrame())
|
||||||
|
|
||||||
|
case spinner.TickMsg:
|
||||||
|
if !m.busy() {
|
||||||
|
m.spinning = false
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.spinner, cmd = m.spinner.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
|
case statusMsg:
|
||||||
|
m.status, m.statusLevel = msg.text, msg.level
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case progress.FrameMsg:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.progress, cmd = m.progress.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
|
case tea.KeyPressMsg:
|
||||||
|
return m.handleKey(msg)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) busy() bool { return m.loading || m.tasks.Running() > 0 }
|
||||||
|
|
||||||
|
// applyLoaded folds a full refresh into the model, preserving the selected row
|
||||||
|
// so a background refresh never moves the cursor under the user's hands.
|
||||||
|
func (m Model) applyLoaded(msg loadedMsg) (Model, tea.Cmd) {
|
||||||
|
m.loading = false
|
||||||
|
if msg.prErr == nil {
|
||||||
|
m.prs = msg.prs
|
||||||
|
}
|
||||||
|
if msg.imageErr == nil {
|
||||||
|
m.images = msg.images
|
||||||
|
}
|
||||||
|
if msg.tagErr == nil && msg.tags != nil {
|
||||||
|
m.tags, m.tagsKnown = msg.tags, true
|
||||||
|
}
|
||||||
|
m.containers, m.volumes = msg.containers, msg.volumes
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case msg.prErr != nil && msg.imageErr != nil:
|
||||||
|
m.status, m.statusLevel = "cannot reach GitHub or Docker: "+msg.prErr.Error(), levelError
|
||||||
|
case msg.prErr != nil:
|
||||||
|
m.status, m.statusLevel = "pull requests unavailable: "+msg.prErr.Error(), levelWarn
|
||||||
|
case msg.imageErr != nil:
|
||||||
|
m.status, m.statusLevel = "local images unavailable: "+msg.imageErr.Error(), levelWarn
|
||||||
|
case msg.tagErr != nil:
|
||||||
|
m.status, m.statusLevel = "GHCR tag listing unavailable: "+msg.tagErr.Error(), levelWarn
|
||||||
|
}
|
||||||
|
m.rebuildRows()
|
||||||
|
if msg.prErr == nil && msg.imageErr == nil && msg.tagErr == nil {
|
||||||
|
m.status, m.statusLevel = m.summary(), levelInfo
|
||||||
|
}
|
||||||
|
m.relayout()
|
||||||
|
cmd := m.refreshSidebar()
|
||||||
|
return m, tea.Batch(cmd, m.syncLogStreams())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) summary() string {
|
||||||
|
remote, local, running := 0, 0, 0
|
||||||
|
for _, row := range m.devRows {
|
||||||
|
if row.kind == rowLocal {
|
||||||
|
local++
|
||||||
|
} else {
|
||||||
|
remote++
|
||||||
|
if row.hasCurrentLocalImage() {
|
||||||
|
local++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row.running {
|
||||||
|
running++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d pull requests · %d local images · %d running", remote, local, running)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyTasks reacts to background progress. Finished jobs trigger a data
|
||||||
|
// refresh so the table reflects the new image or container without the user
|
||||||
|
// having to press r.
|
||||||
|
func (m Model) applyTasks() (Model, tea.Cmd) {
|
||||||
|
m.taskList = m.tasks.Snapshots()
|
||||||
|
commands := []tea.Cmd{waitForTasks(m.tasks)}
|
||||||
|
|
||||||
|
for _, snapshot := range m.taskList {
|
||||||
|
previous, seen := m.taskStates[snapshot.ID]
|
||||||
|
if seen && previous == snapshot.State {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.taskStates[snapshot.ID] = snapshot.State
|
||||||
|
if !snapshot.State.Done() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch snapshot.State {
|
||||||
|
case task.StateSucceeded:
|
||||||
|
m.status, m.statusLevel = snapshot.Title+" finished", levelOK
|
||||||
|
if m.sidebar.source == sourceTask && (m.focusTask == 0 || m.focusTask == snapshot.ID) {
|
||||||
|
m.sidebar.source = sourceLogs
|
||||||
|
m.focusTask = 0
|
||||||
|
}
|
||||||
|
case task.StateFailed:
|
||||||
|
m.status, m.statusLevel = snapshot.Title+" failed: "+errorText(snapshot.Err), levelError
|
||||||
|
case task.StateCanceled:
|
||||||
|
m.status, m.statusLevel = snapshot.Title+" canceled", levelWarn
|
||||||
|
if m.sidebar.source == sourceTask && (m.focusTask == 0 || m.focusTask == snapshot.ID) {
|
||||||
|
m.sidebar.source = sourceLogs
|
||||||
|
m.focusTask = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.generation++
|
||||||
|
commands = append(commands, loadAll(m.generation, m.includeBots, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.tasks.Running() == 0 && m.sidebar.source == sourceTask && m.focusTask == 0 {
|
||||||
|
m.sidebar.source = sourceLogs
|
||||||
|
}
|
||||||
|
|
||||||
|
if focused, found := m.focusedTask(); found {
|
||||||
|
if command := m.progress.SetPercent(focused.Percent); command != nil {
|
||||||
|
commands = append(commands, command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if clocks := m.animate(); clocks != nil {
|
||||||
|
commands = append(commands, clocks)
|
||||||
|
}
|
||||||
|
m.relayout()
|
||||||
|
if cmd := m.refreshSidebar(); cmd != nil {
|
||||||
|
commands = append(commands, cmd)
|
||||||
|
}
|
||||||
|
return m, tea.Batch(commands...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorText(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return "unknown error"
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
// focusedTask is the job the tray and sidebar follow: the one the user last
|
||||||
|
// started, falling back to the newest running job.
|
||||||
|
func (m Model) focusedTask() (task.Snapshot, bool) {
|
||||||
|
if m.focusTask > 0 {
|
||||||
|
if snapshot, found := m.tasks.Snapshot(m.focusTask); found {
|
||||||
|
return snapshot, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for index := len(m.taskList) - 1; index >= 0; index-- {
|
||||||
|
if m.taskList[index].State == task.StateRunning {
|
||||||
|
return m.taskList[index], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(m.taskList) > 0 {
|
||||||
|
return m.taskList[len(m.taskList)-1], true
|
||||||
|
}
|
||||||
|
return task.Snapshot{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// rebuildRows recomputes both screens from the current data, keeping the
|
||||||
|
// selection anchored to the same logical row.
|
||||||
|
func (m *Model) rebuildRows() {
|
||||||
|
devKey := m.selectedDevKey()
|
||||||
|
instKey := m.selectedInstanceName()
|
||||||
|
|
||||||
|
m.devRows = applyContainers(buildDevRows(m.prs, m.images, m.tags, m.tagsKnown), m.containers)
|
||||||
|
m.instRows = buildInstanceRows(m.containers, m.volumes)
|
||||||
|
m.applyFilter()
|
||||||
|
|
||||||
|
m.restoreDevSelection(devKey)
|
||||||
|
m.restoreInstanceSelection(instKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) applyFilter() {
|
||||||
|
query := strings.ToLower(strings.TrimSpace(m.filter.Value()))
|
||||||
|
|
||||||
|
m.devFiltered = m.devFiltered[:0]
|
||||||
|
for _, row := range m.devRows {
|
||||||
|
if query == "" || strings.Contains(row.searchTerms(), query) {
|
||||||
|
m.devFiltered = append(m.devFiltered, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
devCols := devColumns(m.tableWidth())
|
||||||
|
m.devTable.SetColumns(devCols)
|
||||||
|
m.devTable.SetRows(devTableRows(m.devFiltered, devCols))
|
||||||
|
clampCursor(&m.devTable, len(m.devFiltered))
|
||||||
|
|
||||||
|
m.instFiltered = m.instFiltered[:0]
|
||||||
|
for _, row := range m.instRows {
|
||||||
|
if query == "" || strings.Contains(row.searchTerms(), query) {
|
||||||
|
m.instFiltered = append(m.instFiltered, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
instCols := instanceColumns(m.tableWidth())
|
||||||
|
m.instTable.SetColumns(instCols)
|
||||||
|
m.instTable.SetRows(instanceTableRows(m.instFiltered, instCols))
|
||||||
|
clampCursor(&m.instTable, len(m.instFiltered))
|
||||||
|
}
|
||||||
|
|
||||||
|
// clampCursor keeps the selection inside the row set. SetCursor clamps against
|
||||||
|
// the rows the table currently holds, so calling it while the table is empty
|
||||||
|
// parks the cursor at -1; this restores it as soon as rows exist again.
|
||||||
|
func clampCursor(model *table.Model, length int) {
|
||||||
|
if length == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cursor := model.Cursor(); cursor < 0 || cursor >= length {
|
||||||
|
model.SetCursor(clampInt(cursor, 0, length-1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) selectedDevKey() string {
|
||||||
|
if row, found := m.selectedDev(); found {
|
||||||
|
return row.key()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) restoreDevSelection(key string) {
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for index, row := range m.devFiltered {
|
||||||
|
if row.key() == key {
|
||||||
|
m.devTable.SetCursor(index)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) selectedInstanceName() string {
|
||||||
|
if row, found := m.selectedInstance(); found {
|
||||||
|
return row.container.Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) restoreInstanceSelection(name string) {
|
||||||
|
if name == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for index, row := range m.instFiltered {
|
||||||
|
if row.container.Name == name {
|
||||||
|
m.instTable.SetCursor(index)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) selectedDev() (devRow, bool) {
|
||||||
|
index := m.devTable.Cursor()
|
||||||
|
if index < 0 || index >= len(m.devFiltered) {
|
||||||
|
return devRow{}, false
|
||||||
|
}
|
||||||
|
return m.devFiltered[index], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) selectedInstance() (instanceRow, bool) {
|
||||||
|
index := m.instTable.Cursor()
|
||||||
|
if index < 0 || index >= len(m.instFiltered) {
|
||||||
|
return instanceRow{}, false
|
||||||
|
}
|
||||||
|
return m.instFiltered[index], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sidebar splits the screen side by side when there is room, and stacks
|
||||||
|
// under the table when there is not. Hiding it outright on a narrow terminal
|
||||||
|
// would make live logs unavailable exactly where they are most needed.
|
||||||
|
const sidebarSplitWidth = 96
|
||||||
|
|
||||||
|
func (m Model) tableWidth() int {
|
||||||
|
if m.sidebarCols > 0 {
|
||||||
|
return max(m.width-m.sidebarCols-1, 40)
|
||||||
|
}
|
||||||
|
return max(m.width, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mainHeight is the number of lines the table or an overlay may occupy.
|
||||||
|
func (m Model) mainHeight() int {
|
||||||
|
if m.sidebarStacked {
|
||||||
|
return max(m.bodyHeight-m.sidebar.height, 4)
|
||||||
|
}
|
||||||
|
return m.bodyHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) detailVisible() bool {
|
||||||
|
if m.screen == screenInstances {
|
||||||
|
_, found := m.selectedInstance()
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
_, found := m.selectedDev()
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
// relayout recomputes every component's size from the terminal dimensions. The
|
||||||
|
// reserved count has to match what View actually emits line for line, or the
|
||||||
|
// bottom of the interface is pushed off screen.
|
||||||
|
func (m *Model) relayout() {
|
||||||
|
reserved := 2 // header + blank
|
||||||
|
if m.mode == modeFilter || m.filter.Value() != "" {
|
||||||
|
reserved += 2
|
||||||
|
}
|
||||||
|
if m.detailVisible() {
|
||||||
|
reserved += 3 // blank + title + meta
|
||||||
|
}
|
||||||
|
if m.tasks.Running() > 0 {
|
||||||
|
reserved += 1
|
||||||
|
}
|
||||||
|
switch m.mode {
|
||||||
|
case modeManage:
|
||||||
|
reserved += 2
|
||||||
|
case modeConfirm:
|
||||||
|
reserved += 3
|
||||||
|
case modeImageSelect:
|
||||||
|
reserved += 5
|
||||||
|
}
|
||||||
|
if m.status != "" {
|
||||||
|
reserved += 2
|
||||||
|
}
|
||||||
|
reserved += 3 // blank + two footer lines
|
||||||
|
|
||||||
|
m.bodyHeight = max(m.height-reserved, 6)
|
||||||
|
|
||||||
|
m.sidebarCols = 0
|
||||||
|
m.sidebarStacked = false
|
||||||
|
if m.sidebarRelevant() {
|
||||||
|
if m.width >= sidebarSplitWidth {
|
||||||
|
m.sidebarCols = clampInt(m.width*2/5, 34, 72)
|
||||||
|
m.sidebar.resize(m.sidebarCols, m.bodyHeight)
|
||||||
|
} else {
|
||||||
|
m.sidebarStacked = true
|
||||||
|
m.sidebar.resize(m.width, clampInt(m.bodyHeight/2, 6, 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A table renders a header line plus one line per row, so its height is
|
||||||
|
// one less than the space it is given.
|
||||||
|
tableHeight := max(m.mainHeight()-1, 3)
|
||||||
|
m.devTable.SetWidth(m.tableWidth())
|
||||||
|
m.devTable.SetHeight(tableHeight)
|
||||||
|
m.instTable.SetWidth(m.tableWidth())
|
||||||
|
m.instTable.SetHeight(tableHeight)
|
||||||
|
m.applyFilter()
|
||||||
|
m.progress.SetWidth(clampInt(m.width/4, 12, 32))
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncLogStreams drops followers whose container no longer exists. Attaching is
|
||||||
|
// deliberately left to refreshSidebar: following every container eagerly would
|
||||||
|
// thrash against the stream cap on a machine running more instances than the
|
||||||
|
// cap allows, killing and restarting `docker logs` every refresh.
|
||||||
|
func (m Model) syncLogStreams() tea.Cmd {
|
||||||
|
live := make([]string, 0, len(m.containers))
|
||||||
|
for _, container := range m.containers {
|
||||||
|
live = append(live, container.Name)
|
||||||
|
}
|
||||||
|
m.logs.Retain(live)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshSidebar recomputes panel content from whichever source is selected.
|
||||||
|
func (m *Model) refreshSidebar() tea.Cmd {
|
||||||
|
if !m.sidebarRelevant() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if m.sidebar.source == sourceTask {
|
||||||
|
snapshot, found := m.focusedTask()
|
||||||
|
if !found {
|
||||||
|
m.sidebar.title, m.sidebar.subtitle = "tasks", "no background work"
|
||||||
|
m.sidebar.setContent(ui.Help.Render("Press R to build locally or p to pull a pull-request image."))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
title, subtitle, content := renderTaskSnapshot(snapshot, m.sidebar.textWidth())
|
||||||
|
m.sidebar.title, m.sidebar.subtitle = title, subtitle
|
||||||
|
m.sidebar.setContent(content)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.sidebar.source == sourceCI {
|
||||||
|
pr := m.selectedPRNumber()
|
||||||
|
state, found := m.ciChecks[pr]
|
||||||
|
var cmd tea.Cmd
|
||||||
|
if pr > 0 && (!found || (!state.loading && time.Since(state.updated) > 30*time.Second)) {
|
||||||
|
m.ciChecks[pr] = ciCheckState{loading: true, checks: state.checks, err: state.err, updated: state.updated}
|
||||||
|
cmd = fetchCIChecks(pr, false)
|
||||||
|
}
|
||||||
|
title, subtitle, content := renderCISnapshot(pr, state.checks, state.loading, state.err, m.sidebar.textWidth())
|
||||||
|
m.sidebar.title, m.sidebar.subtitle = title, subtitle
|
||||||
|
m.sidebar.setContent(content)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
name := m.sidebar.pinned
|
||||||
|
if name == "" {
|
||||||
|
name = m.logTarget()
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Attaching here is what starts the stream, and it is a no-op once one
|
||||||
|
// exists. A stopped container is still worth following: `docker logs`
|
||||||
|
// replays why it died and then ends the stream.
|
||||||
|
m.logs.Attach(name)
|
||||||
|
snapshot, attached := m.logs.Snapshot(name)
|
||||||
|
title, subtitle, content := renderLogSnapshot(name, snapshot, attached)
|
||||||
|
if m.sidebar.pinned != "" {
|
||||||
|
subtitle = ui.IconPin + " pinned · " + subtitle
|
||||||
|
}
|
||||||
|
m.sidebar.title, m.sidebar.subtitle = title, subtitle
|
||||||
|
m.sidebar.setContent(content)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// logTarget is the container whose logs the sidebar shows by default.
|
||||||
|
func (m Model) logTarget() string {
|
||||||
|
if m.screen == screenInstances {
|
||||||
|
if row, found := m.selectedInstance(); found {
|
||||||
|
return row.container.Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if row, found := m.selectedDev(); found {
|
||||||
|
if row.state != "" || row.running {
|
||||||
|
return row.containerName()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) selectedPRNumber() int {
|
||||||
|
if m.screen == screenInstances {
|
||||||
|
if row, found := m.selectedInstance(); found {
|
||||||
|
return row.prNumber()
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if row, found := m.selectedDev(); found {
|
||||||
|
if row.kind == rowRemote {
|
||||||
|
return row.pr.Number
|
||||||
|
}
|
||||||
|
if row.local.PRNumber > 0 {
|
||||||
|
return row.local.PRNumber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) containerExists(name string) bool {
|
||||||
|
if name == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, c := range m.containers {
|
||||||
|
if c.Name == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// sidebarRelevant determines whether the right-hand panel has meaningful
|
||||||
|
// content to display (active tasks, CI checks on a PR, pinned logs, or an existing container).
|
||||||
|
func (m Model) sidebarRelevant() bool {
|
||||||
|
if !m.sidebar.visible {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if m.sidebar.source == sourceTask {
|
||||||
|
_, found := m.focusedTask()
|
||||||
|
return found || m.tasks.Running() > 0
|
||||||
|
}
|
||||||
|
if m.sidebar.source == sourceCI {
|
||||||
|
return m.selectedPRNumber() > 0
|
||||||
|
}
|
||||||
|
if m.sidebar.pinned != "" && m.containerExists(m.sidebar.pinned) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return m.logTarget() != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
switch {
|
||||||
|
case d < time.Minute:
|
||||||
|
return fmt.Sprintf("%.0fs", d.Seconds())
|
||||||
|
case d < time.Hour:
|
||||||
|
return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortHelp(bindings []key.Binding, model help.Model) string {
|
||||||
|
return model.ShortHelpView(bindings)
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/table"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/registry"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
type rowKind int
|
||||||
|
|
||||||
|
const (
|
||||||
|
rowRemote rowKind = iota
|
||||||
|
rowLocal
|
||||||
|
)
|
||||||
|
|
||||||
|
// devRow is one line of the development screen: either an open pull request,
|
||||||
|
// or a local image that no open pull request accounts for.
|
||||||
|
type devRow struct {
|
||||||
|
kind rowKind
|
||||||
|
pr gh.PR
|
||||||
|
local docker.Image
|
||||||
|
image ui.ImageState
|
||||||
|
running bool
|
||||||
|
port string
|
||||||
|
state string
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasCurrentLocalImage reports whether a local build matches the pull request's
|
||||||
|
// current head. Only an exact revision match counts: an image built from an
|
||||||
|
// older commit is a different thing and stays on its own row rather than
|
||||||
|
// silently shadowing the pull request.
|
||||||
|
func (r devRow) hasCurrentLocalImage() bool {
|
||||||
|
return r.kind == rowRemote && r.local.Tag != "" && r.pr.HeadSHA != "" && r.local.Revision == r.pr.HeadSHA
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r devRow) containerName() string {
|
||||||
|
if r.kind == rowLocal {
|
||||||
|
return "homarr_" + r.local.Tag
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("homarr_pr_%d", r.pr.Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r devRow) volumeName() string { return r.containerName() + "_data" }
|
||||||
|
|
||||||
|
// localReference is the local image this row would run, if one exists.
|
||||||
|
func (r devRow) localReference() string {
|
||||||
|
if r.local.Tag == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.local.Reference()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r devRow) remoteReference() string {
|
||||||
|
if r.kind != rowRemote {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return registry.ImageReference(r.pr.Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r devRow) label() string {
|
||||||
|
if r.kind == rowLocal {
|
||||||
|
return r.local.Reference()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("PR #%d", r.pr.Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r devRow) key() string {
|
||||||
|
if r.kind == rowLocal {
|
||||||
|
return "local:" + r.local.Tag
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("pr:%d", r.pr.Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchTerms is everything the filter matches against for this row.
|
||||||
|
func (r devRow) searchTerms() string {
|
||||||
|
terms := []string{
|
||||||
|
r.pr.Title, r.pr.Author, r.pr.HeadRef, r.pr.CIState,
|
||||||
|
r.local.Tag, r.local.Source, r.local.Revision, r.port,
|
||||||
|
}
|
||||||
|
if r.kind == rowRemote {
|
||||||
|
terms = append(terms, "remote", fmt.Sprint(r.pr.Number), fmt.Sprintf("#%d pr-%d", r.pr.Number, r.pr.Number))
|
||||||
|
if r.pr.IsDraft {
|
||||||
|
terms = append(terms, "draft")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
terms = append(terms, "local", r.local.Reference())
|
||||||
|
}
|
||||||
|
if r.hasCurrentLocalImage() {
|
||||||
|
terms = append(terms, "local built")
|
||||||
|
}
|
||||||
|
if r.running {
|
||||||
|
terms = append(terms, "running started up")
|
||||||
|
}
|
||||||
|
return strings.ToLower(strings.Join(terms, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDevRows merges open pull requests with local images into a single list.
|
||||||
|
// A local image folds into its pull request only when the revisions match, so
|
||||||
|
// the row exposes both sources when the registry image is available too.
|
||||||
|
func buildDevRows(prs []gh.PR, images []docker.Image, tags map[string]bool, tagsKnown bool) []devRow {
|
||||||
|
rows := make([]devRow, 0, len(prs)+len(images))
|
||||||
|
indexByPR := make(map[int]int, len(prs))
|
||||||
|
for _, pr := range prs {
|
||||||
|
indexByPR[pr.Number] = len(rows)
|
||||||
|
rows = append(rows, devRow{kind: rowRemote, pr: pr})
|
||||||
|
}
|
||||||
|
for _, image := range images {
|
||||||
|
index, matched := indexByPR[image.PRNumber]
|
||||||
|
if matched && image.Revision != "" && image.Revision == rows[index].pr.HeadSHA {
|
||||||
|
if rows[index].local.Tag == "" {
|
||||||
|
rows[index].local = image
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows = append(rows, devRow{kind: rowLocal, local: image, image: ui.ImageLocal})
|
||||||
|
}
|
||||||
|
for index := range rows {
|
||||||
|
if rows[index].kind != rowRemote {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case rows[index].hasCurrentLocalImage():
|
||||||
|
rows[index].image = ui.ImageLocal
|
||||||
|
case rows[index].local.Tag != "":
|
||||||
|
rows[index].image = ui.ImageStale
|
||||||
|
case !tagsKnown:
|
||||||
|
rows[index].image = ui.ImageChecking
|
||||||
|
case registry.HasPRImage(tags, rows[index].pr.Number):
|
||||||
|
rows[index].image = ui.ImageRemote
|
||||||
|
default:
|
||||||
|
rows[index].image = ui.ImageMissing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyContainers annotates rows with the state of their container.
|
||||||
|
func applyContainers(rows []devRow, containers []docker.Container) []devRow {
|
||||||
|
byName := make(map[string]docker.Container, len(containers))
|
||||||
|
for _, container := range containers {
|
||||||
|
byName[container.Name] = container
|
||||||
|
}
|
||||||
|
for index := range rows {
|
||||||
|
container, found := byName[rows[index].containerName()]
|
||||||
|
rows[index].running = found && container.Running()
|
||||||
|
rows[index].state = ""
|
||||||
|
rows[index].port = ""
|
||||||
|
if found {
|
||||||
|
rows[index].state = container.State
|
||||||
|
rows[index].port = container.HostPort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// flexWidth hands the leftover table width to one column. Every visible column
|
||||||
|
// costs its own width plus the two padding cells the table adds, and columns set
|
||||||
|
// to zero are skipped entirely, which is how the narrow layouts drop them.
|
||||||
|
func flexWidth(columns []table.Column, available, index, minimum int) int {
|
||||||
|
used := 0
|
||||||
|
for position, column := range columns {
|
||||||
|
if position == index || column.Width <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
used += column.Width + 2
|
||||||
|
}
|
||||||
|
return max(available-used-2, minimum)
|
||||||
|
}
|
||||||
|
|
||||||
|
func devColumns(width int) []table.Column {
|
||||||
|
author, port := 15, 6
|
||||||
|
switch {
|
||||||
|
case width < 68:
|
||||||
|
author, port = 0, 0
|
||||||
|
case width < 92:
|
||||||
|
author = 0
|
||||||
|
case width < 108:
|
||||||
|
author = 10
|
||||||
|
}
|
||||||
|
columns := []table.Column{
|
||||||
|
{Title: "", Width: 1},
|
||||||
|
{Title: "SOURCE", Width: 6},
|
||||||
|
{Title: "REF", Width: 9},
|
||||||
|
{Title: "TITLE", Width: 0},
|
||||||
|
{Title: "AUTHOR", Width: author},
|
||||||
|
{Title: "IMG", Width: 3},
|
||||||
|
{Title: "CI", Width: 2},
|
||||||
|
{Title: "PORT", Width: port},
|
||||||
|
}
|
||||||
|
columns[3].Width = flexWidth(columns, width, 3, 18)
|
||||||
|
return columns
|
||||||
|
}
|
||||||
|
|
||||||
|
func devTableRows(rows []devRow, columns []table.Column) []table.Row {
|
||||||
|
titleWidth, authorWidth := columns[3].Width, columns[4].Width
|
||||||
|
rendered := make([]table.Row, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
marker := " "
|
||||||
|
switch {
|
||||||
|
case row.running:
|
||||||
|
marker = ui.OK.Render(ui.IconRunning)
|
||||||
|
case row.state != "":
|
||||||
|
marker = ui.Dim.Render(ui.IconStopped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cells are truncated as plain text and styled afterwards. Styling
|
||||||
|
// first would leave the table cutting through an escape sequence,
|
||||||
|
// because it measures width in bytes it cannot interpret.
|
||||||
|
source, reference, title, author := "remote", fmt.Sprint(row.pr.Number), row.pr.Title, row.pr.Author
|
||||||
|
ci := ui.CIBadge(row.pr.CIState, false)
|
||||||
|
draft := false
|
||||||
|
if row.kind == rowLocal {
|
||||||
|
source, reference, title = "local", row.local.Tag, row.local.Reference()
|
||||||
|
ci = ui.CIBadge("", false)
|
||||||
|
author = "untracked"
|
||||||
|
if row.local.Source != "" {
|
||||||
|
author = filepath.Base(row.local.Source)
|
||||||
|
}
|
||||||
|
} else if row.pr.IsDraft {
|
||||||
|
title, draft = "DRAFT · "+title, true
|
||||||
|
}
|
||||||
|
|
||||||
|
styledSource := ui.Dim.Render(source)
|
||||||
|
if row.kind == rowLocal {
|
||||||
|
styledSource = ui.Local.Render(source)
|
||||||
|
}
|
||||||
|
styledTitle := ui.Truncate(title, titleWidth)
|
||||||
|
if draft {
|
||||||
|
styledTitle = ui.Dim.Render(styledTitle)
|
||||||
|
}
|
||||||
|
port := ""
|
||||||
|
if row.port != "" {
|
||||||
|
port = ui.OK.Render(":" + row.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered = append(rendered, table.Row{
|
||||||
|
marker,
|
||||||
|
styledSource,
|
||||||
|
ui.Truncate(reference, columns[2].Width),
|
||||||
|
styledTitle,
|
||||||
|
ui.Help.Render(ui.Truncate(author, authorWidth)),
|
||||||
|
ui.ImageBadge(row.image, false),
|
||||||
|
ci,
|
||||||
|
port,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
// instanceRow is one line of the instances screen.
|
||||||
|
type instanceRow struct {
|
||||||
|
container docker.Container
|
||||||
|
volume string
|
||||||
|
health string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r instanceRow) prNumber() int {
|
||||||
|
var number int
|
||||||
|
if _, err := fmt.Sscanf(r.container.Name, "homarr_pr_%d", &number); err == nil {
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r instanceRow) url() string {
|
||||||
|
if !r.container.Running() || r.container.HostPort() == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "http://localhost:" + r.container.HostPort()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r instanceRow) searchTerms() string {
|
||||||
|
return strings.ToLower(strings.Join([]string{
|
||||||
|
r.container.Name, r.container.Image, r.container.State,
|
||||||
|
r.container.Status, r.container.HostPort(), r.health, r.url(),
|
||||||
|
}, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
var healthStatusMap = map[string]string{
|
||||||
|
"healthy": "healthy",
|
||||||
|
"unhealthy": "unhealthy",
|
||||||
|
"health: starting": "starting",
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHealth pulls the healthcheck verdict out of the status string Docker
|
||||||
|
// renders, for example "Up 3 minutes (healthy)".
|
||||||
|
func parseHealth(status string) string {
|
||||||
|
open := strings.Index(status, "(")
|
||||||
|
close := strings.LastIndex(status, ")")
|
||||||
|
if open < 0 || close <= open {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return healthStatusMap[status[open+1:close]]
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildInstanceRows(containers []docker.Container, volumes []docker.Volume) []instanceRow {
|
||||||
|
byName := make(map[string]docker.Volume, len(volumes))
|
||||||
|
for _, volume := range volumes {
|
||||||
|
byName[volume.Name] = volume
|
||||||
|
}
|
||||||
|
rows := make([]instanceRow, 0, len(containers))
|
||||||
|
for _, container := range containers {
|
||||||
|
row := instanceRow{container: container, health: parseHealth(container.Status)}
|
||||||
|
if volume, found := byName[container.Name+"_data"]; found {
|
||||||
|
row.volume = volume.Name
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func instanceColumns(width int) []table.Column {
|
||||||
|
image, status := 24, 22
|
||||||
|
switch {
|
||||||
|
case width < 72:
|
||||||
|
image, status = 14, 0
|
||||||
|
case width < 100:
|
||||||
|
image, status = 18, 0
|
||||||
|
case width < 124:
|
||||||
|
status = 16
|
||||||
|
}
|
||||||
|
columns := []table.Column{
|
||||||
|
{Title: "", Width: 1},
|
||||||
|
{Title: "NAME", Width: 0},
|
||||||
|
{Title: "STATE", Width: 10},
|
||||||
|
{Title: "IMAGE", Width: image},
|
||||||
|
{Title: "PORT", Width: 6},
|
||||||
|
{Title: "STATUS", Width: status},
|
||||||
|
}
|
||||||
|
columns[1].Width = flexWidth(columns, width, 1, 16)
|
||||||
|
return columns
|
||||||
|
}
|
||||||
|
|
||||||
|
func instanceTableRows(rows []instanceRow, columns []table.Column) []table.Row {
|
||||||
|
nameWidth, imageWidth, statusWidth := columns[1].Width, columns[3].Width, columns[5].Width
|
||||||
|
rendered := make([]table.Row, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
marker := ui.Dim.Render(ui.IconStopped)
|
||||||
|
if row.container.Running() {
|
||||||
|
marker = ui.OK.Render(ui.IconRunning)
|
||||||
|
}
|
||||||
|
port := ""
|
||||||
|
if value := row.container.HostPort(); value != "" {
|
||||||
|
port = ui.OK.Render(":" + value)
|
||||||
|
}
|
||||||
|
image := strings.TrimPrefix(row.container.Image, "ghcr.io/homarr-labs/")
|
||||||
|
rendered = append(rendered, table.Row{
|
||||||
|
marker,
|
||||||
|
ui.Body.Render(ui.Truncate(row.container.Name, nameWidth)),
|
||||||
|
ui.StateBadge(row.container.State),
|
||||||
|
ui.Help.Render(ui.Truncate(image, imageWidth)),
|
||||||
|
port,
|
||||||
|
prefixed(ui.HealthBadge(row.health), row.container.Status, statusWidth),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixed puts a rendered badge in front of plain text, truncating the text to
|
||||||
|
// whatever width the badge leaves behind.
|
||||||
|
func prefixed(badge, text string, width int) string {
|
||||||
|
if width <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if badge == "" {
|
||||||
|
return ui.Help.Render(ui.Truncate(text, width))
|
||||||
|
}
|
||||||
|
remaining := width - lipgloss.Width(badge) - 1
|
||||||
|
if remaining <= 0 {
|
||||||
|
return badge
|
||||||
|
}
|
||||||
|
return badge + " " + ui.Help.Render(ui.Truncate(text, remaining))
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
// Run starts the interactive application. showInstances opens directly on the
|
||||||
|
// instances screen instead of the development browser.
|
||||||
|
func Run(showInstances bool) error {
|
||||||
|
model := New(showInstances)
|
||||||
|
program := tea.NewProgram(model)
|
||||||
|
final, err := program.Run()
|
||||||
|
// Background work outlives the render loop unless it is stopped here, and a
|
||||||
|
// stray `docker logs --follow` would keep the terminal's child process alive
|
||||||
|
// after the CLI has exited.
|
||||||
|
if quit, ok := final.(Model); ok {
|
||||||
|
quit.tasks.CancelAll()
|
||||||
|
quit.logs.Close()
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunDev opens the development browser.
|
||||||
|
func RunDev() error { return Run(false) }
|
||||||
|
|
||||||
|
// RunDashboard opens the instances screen.
|
||||||
|
func RunDashboard() error { return Run(true) }
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/bubbles/v2/viewport"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/logs"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/task"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sidebarSource int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// sourceLogs follows the selected (or pinned) container.
|
||||||
|
sourceLogs sidebarSource = iota
|
||||||
|
// sourceTask follows a background pull or build.
|
||||||
|
sourceTask
|
||||||
|
// sourceCI follows the GitHub CI checks for the selected PR.
|
||||||
|
sourceCI
|
||||||
|
)
|
||||||
|
|
||||||
|
var sidebarTabNames = map[sidebarSource]string{
|
||||||
|
sourceLogs: "logs",
|
||||||
|
sourceTask: "build",
|
||||||
|
sourceCI: "ci",
|
||||||
|
}
|
||||||
|
|
||||||
|
var tabOrder = []sidebarSource{sourceLogs, sourceTask, sourceCI}
|
||||||
|
|
||||||
|
// sidebar is the right-hand panel. It has two jobs — live container output and
|
||||||
|
// live build output — and both are just text, so one scrollable viewport backs
|
||||||
|
// them and the source switch only changes what fills it.
|
||||||
|
type sidebar struct {
|
||||||
|
visible bool
|
||||||
|
source sidebarSource
|
||||||
|
viewport viewport.Model
|
||||||
|
follow bool
|
||||||
|
pinned string
|
||||||
|
taskID int
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
title string
|
||||||
|
subtitle string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSidebar() sidebar {
|
||||||
|
view := viewport.New(viewport.WithWidth(40), viewport.WithHeight(10))
|
||||||
|
view.SoftWrap = true
|
||||||
|
view.Style = lipgloss.NewStyle().Foreground(ui.Text)
|
||||||
|
return sidebar{visible: true, follow: true, viewport: view, width: 40, height: 10}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resize sets the panel's outer dimensions; the viewport is inset for the
|
||||||
|
// border and the two header lines.
|
||||||
|
// resize sets the panel's outer size in terminal cells. In Lip Gloss v2 a
|
||||||
|
// style's Width includes its border, so the text inside loses two columns to
|
||||||
|
// the border and two more to the horizontal padding. Vertically the border
|
||||||
|
// costs two rows and the title and subtitle one each.
|
||||||
|
func (s *sidebar) resize(width, height int) {
|
||||||
|
s.width, s.height = width, height
|
||||||
|
s.viewport.SetWidth(s.textWidth())
|
||||||
|
s.viewport.SetHeight(max(height-4, 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
// textWidth is the usable width inside the border and padding. Content wider
|
||||||
|
// than this soft-wraps, which turns a progress list into a ragged mess, so
|
||||||
|
// every renderer budgets against it.
|
||||||
|
func (s sidebar) textWidth() int { return max(s.width-4, 10) }
|
||||||
|
|
||||||
|
func (s *sidebar) setContent(content string) {
|
||||||
|
atBottom := s.viewport.AtBottom()
|
||||||
|
s.viewport.SetContent(content)
|
||||||
|
if s.follow || atBottom {
|
||||||
|
s.viewport.GotoBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sidebar) toggleFollow() {
|
||||||
|
s.follow = !s.follow
|
||||||
|
if s.follow {
|
||||||
|
s.viewport.GotoBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sidebar) pageUp() {
|
||||||
|
s.follow = false
|
||||||
|
s.viewport.PageUp()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sidebar) pageDown() {
|
||||||
|
s.viewport.PageDown()
|
||||||
|
s.follow = s.viewport.AtBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
// view renders the panel, or nothing when it is hidden.
|
||||||
|
func (s sidebar) view() string {
|
||||||
|
if !s.visible || s.width < 24 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
text := s.textWidth()
|
||||||
|
header := ui.Heading.Render(ui.Truncate(s.title, text))
|
||||||
|
mode := "paused"
|
||||||
|
if s.follow {
|
||||||
|
mode = "following"
|
||||||
|
}
|
||||||
|
subtitle := s.subtitle
|
||||||
|
if subtitle == "" {
|
||||||
|
subtitle = mode
|
||||||
|
} else {
|
||||||
|
subtitle += " · " + mode
|
||||||
|
}
|
||||||
|
body := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
|
header,
|
||||||
|
ui.Help.Render(ui.Truncate(subtitle, text)),
|
||||||
|
s.viewport.View(),
|
||||||
|
)
|
||||||
|
return ui.Panel("", s.source == sourceTask).Width(s.width).Height(s.height - 2).Render(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var logStateLabels = map[logs.State]string{
|
||||||
|
logs.StateStarting: "attaching",
|
||||||
|
logs.StateStopped: "stream ended",
|
||||||
|
logs.StateFailed: "stream failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderLogSnapshot turns a live log buffer into panel content.
|
||||||
|
func renderLogSnapshot(name string, snapshot logs.Snapshot, attached bool) (title, subtitle, content string) {
|
||||||
|
title = ui.IconTerminal + " " + name
|
||||||
|
if name == "" {
|
||||||
|
return ui.IconTerminal + " logs", "no container selected", ui.Help.Render("Select a running instance to stream its logs.")
|
||||||
|
}
|
||||||
|
if !attached {
|
||||||
|
return title, "not streaming", ui.Help.Render("Container is not running.")
|
||||||
|
}
|
||||||
|
if snapshot.State == logs.StateStreaming {
|
||||||
|
subtitle = fmt.Sprintf("%d lines", len(snapshot.Lines))
|
||||||
|
} else if label, found := logStateLabels[snapshot.State]; found {
|
||||||
|
subtitle = label
|
||||||
|
}
|
||||||
|
content = tailLines(snapshot.Lines, len(snapshot.Lines))
|
||||||
|
if snapshot.Err != nil {
|
||||||
|
content += "\n" + ui.Alert.Render(snapshot.Err.Error())
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(content) == "" {
|
||||||
|
content = ui.Help.Render("waiting for output…")
|
||||||
|
}
|
||||||
|
return title, subtitle, content
|
||||||
|
}
|
||||||
|
|
||||||
|
var taskKindIcons = map[task.Kind]string{
|
||||||
|
task.KindBuild: ui.IconBuild,
|
||||||
|
task.KindPull: ui.IconCloud,
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderTaskSnapshot lays out a background job: a progress bar per unit of
|
||||||
|
// work, then the tail of its output. This is the panel that answers "what is my
|
||||||
|
// build doing right now" without leaving the interface.
|
||||||
|
func renderTaskSnapshot(snapshot task.Snapshot, width int) (title, subtitle, content string) {
|
||||||
|
kindIcon := taskKindIcons[snapshot.Kind]
|
||||||
|
if kindIcon == "" {
|
||||||
|
kindIcon = ui.IconBuild
|
||||||
|
}
|
||||||
|
title = kindIcon + " " + snapshot.Kind.String() + " · " + snapshot.Target
|
||||||
|
subtitle = snapshot.State.String()
|
||||||
|
if snapshot.Detail != "" {
|
||||||
|
subtitle += " · " + snapshot.Detail
|
||||||
|
}
|
||||||
|
subtitle += fmt.Sprintf(" · %s", formatDuration(snapshot.Elapsed()))
|
||||||
|
|
||||||
|
var out strings.Builder
|
||||||
|
labelWidth := stepLabelWidth(snapshot.Steps, width)
|
||||||
|
for _, step := range snapshot.Steps {
|
||||||
|
out.WriteString(renderStep(step, labelWidth, width))
|
||||||
|
out.WriteString("\n")
|
||||||
|
}
|
||||||
|
if snapshot.Kind == task.KindBuild {
|
||||||
|
if tail := tailLines(snapshot.Lines, 60); tail != "" {
|
||||||
|
if out.Len() > 0 {
|
||||||
|
out.WriteString(ui.Rule("output", width) + "\n")
|
||||||
|
}
|
||||||
|
// Left unstyled so the build tool's own colours survive.
|
||||||
|
out.WriteString(tail + "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if snapshot.Err != nil {
|
||||||
|
out.WriteString("\n" + ui.Alert.Render(snapshot.Err.Error()))
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out.String()) == "" {
|
||||||
|
out.WriteString(ui.Help.Render("starting…"))
|
||||||
|
}
|
||||||
|
return title, subtitle, out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderStep lays out one progress row inside an exact width. The panel wraps
|
||||||
|
// long lines, which would turn a layer list into a ragged mess, so each row is
|
||||||
|
// budgeted rather than trimmed after the fact.
|
||||||
|
// stepLabelWidth sizes the label column to the widest label, so a pull of
|
||||||
|
// twelve-character layer IDs does not leave a gap the width of a Dockerfile
|
||||||
|
// instruction.
|
||||||
|
func stepLabelWidth(steps []task.Step, width int) int {
|
||||||
|
longest := 0
|
||||||
|
for _, step := range steps {
|
||||||
|
longest = max(longest, lipgloss.Width(step.Label))
|
||||||
|
}
|
||||||
|
bar := clampInt(width/3, 6, 22)
|
||||||
|
available := max(width-bar-stepFixedWidth-8, 8)
|
||||||
|
return clampInt(longest, 8, available)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderStep(step task.Step, labelWidth, width int) string {
|
||||||
|
icon := ui.Pending.Render(ui.IconPending)
|
||||||
|
switch {
|
||||||
|
case step.Failed:
|
||||||
|
icon = ui.Alert.Render(ui.IconFail)
|
||||||
|
case step.Done:
|
||||||
|
icon = ui.OK.Render(ui.IconPass)
|
||||||
|
}
|
||||||
|
colour := ui.Warning
|
||||||
|
if step.Done {
|
||||||
|
colour = ui.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
note := step.Note
|
||||||
|
if note == "" {
|
||||||
|
note = strings.ToLower(step.Status)
|
||||||
|
}
|
||||||
|
bar := clampInt(width/3, 6, 22)
|
||||||
|
noteWidth := max(width-bar-labelWidth-stepFixedWidth, 0)
|
||||||
|
|
||||||
|
rendered := fmt.Sprintf("%s %-*s %s %s",
|
||||||
|
icon,
|
||||||
|
labelWidth, ui.Truncate(step.Label, labelWidth),
|
||||||
|
ui.Bar(step.Percent, bar, colour),
|
||||||
|
ui.Percent(step.Percent),
|
||||||
|
)
|
||||||
|
if noteWidth > 1 && note != "" {
|
||||||
|
rendered += " " + ui.Help.Render(ui.Truncate(note, noteWidth-1))
|
||||||
|
}
|
||||||
|
return rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepFixedWidth is the icon, the percentage, and the three separating spaces.
|
||||||
|
const stepFixedWidth = 1 + 4 + 3
|
||||||
|
|
||||||
|
// tailLines keeps the last count meaningful lines of output.
|
||||||
|
func tailLines(lines []string, count int) string {
|
||||||
|
kept := make([]string, 0, min(len(lines), count))
|
||||||
|
for _, line := range lines {
|
||||||
|
if !ui.Blank(line) {
|
||||||
|
kept = append(kept, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) > count {
|
||||||
|
kept = kept[len(kept)-count:]
|
||||||
|
}
|
||||||
|
return strings.Join(kept, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func clampInt(value, low, high int) int {
|
||||||
|
if value < low {
|
||||||
|
return low
|
||||||
|
}
|
||||||
|
if value > high {
|
||||||
|
return high
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
type checkGlyphSpec struct {
|
||||||
|
symbol string
|
||||||
|
style lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
var checkGlyphs = map[string]checkGlyphSpec{
|
||||||
|
"pass": {ui.IconPass, ui.OK},
|
||||||
|
"fail": {ui.IconFail, ui.Alert},
|
||||||
|
"pending": {ui.IconPending, ui.Pending},
|
||||||
|
"skipping": {ui.IconStopped, ui.Dim},
|
||||||
|
"cancel": {ui.IconFail, ui.Dim},
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderCheckLine(c gh.Check, width int) string {
|
||||||
|
spec, found := checkGlyphs[c.BucketKey()]
|
||||||
|
if !found {
|
||||||
|
spec = checkGlyphs["pending"]
|
||||||
|
}
|
||||||
|
icon := spec.style.Render(spec.symbol)
|
||||||
|
|
||||||
|
dur := c.Duration()
|
||||||
|
durStyle := ui.Dim
|
||||||
|
if dur == "" {
|
||||||
|
if c.IsSkipped() {
|
||||||
|
dur = "skipped"
|
||||||
|
} else if c.IsPending() {
|
||||||
|
dur = "running"
|
||||||
|
durStyle = ui.Pending
|
||||||
|
} else if c.IsFailure() {
|
||||||
|
dur = "failed"
|
||||||
|
durStyle = ui.Alert
|
||||||
|
} else {
|
||||||
|
dur = "—"
|
||||||
|
}
|
||||||
|
} else if c.IsFailure() {
|
||||||
|
durStyle = ui.Alert
|
||||||
|
} else if c.IsPending() {
|
||||||
|
durStyle = ui.Pending
|
||||||
|
}
|
||||||
|
styledDur := durStyle.Render(dur)
|
||||||
|
durLen := lipgloss.Width(styledDur)
|
||||||
|
|
||||||
|
name := c.Name
|
||||||
|
if c.Workflow != "" && c.Workflow != c.Name && c.Workflow != "CI" {
|
||||||
|
name += " " + ui.Dim.Render("("+c.Workflow+")")
|
||||||
|
}
|
||||||
|
|
||||||
|
maxNameWidth := max(width-durLen-4, 10)
|
||||||
|
styledName := ui.Truncate(name, maxNameWidth)
|
||||||
|
padding := max(width-lipgloss.Width(icon)-1-lipgloss.Width(styledName)-durLen, 1)
|
||||||
|
|
||||||
|
line := icon + " " + styledName + strings.Repeat(" ", padding) + styledDur
|
||||||
|
if c.Description != "" {
|
||||||
|
line += "\n " + ui.Help.Render(ui.Truncate(c.Description, max(width-6, 10)))
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderCISnapshot(pr int, checks []gh.Check, loading bool, err error, width int) (title, subtitle, content string) {
|
||||||
|
if pr <= 0 {
|
||||||
|
return "ci", "no PR selected", ui.Help.Render("Select a pull request to inspect its CI checks.")
|
||||||
|
}
|
||||||
|
title = fmt.Sprintf("ci · PR #%d", pr)
|
||||||
|
if loading && len(checks) == 0 {
|
||||||
|
return title, "fetching…", ui.Help.Render("Fetching CI checks from GitHub…")
|
||||||
|
}
|
||||||
|
if err != nil && len(checks) == 0 {
|
||||||
|
return title, "error", ui.Alert.Render(err.Error()) + "\n\n" + ui.Help.Render("Check `gh auth status` and internet connection.")
|
||||||
|
}
|
||||||
|
if len(checks) == 0 {
|
||||||
|
return title, "no checks", ui.Help.Render("No CI checks reported for this pull request.")
|
||||||
|
}
|
||||||
|
|
||||||
|
pass, fail, pending, skipping := 0, 0, 0, 0
|
||||||
|
for _, c := range checks {
|
||||||
|
switch {
|
||||||
|
case c.IsSuccess():
|
||||||
|
pass++
|
||||||
|
case c.IsFailure():
|
||||||
|
fail++
|
||||||
|
case c.IsPending():
|
||||||
|
pending++
|
||||||
|
default:
|
||||||
|
skipping++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
if pass > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s %d pass", ui.IconPass, pass))
|
||||||
|
}
|
||||||
|
if fail > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s %d fail", ui.IconFail, fail))
|
||||||
|
}
|
||||||
|
if pending > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s %d pending", ui.IconPending, pending))
|
||||||
|
}
|
||||||
|
if skipping > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%s %d skipped", ui.IconStopped, skipping))
|
||||||
|
}
|
||||||
|
subtitle = strings.Join(parts, " · ")
|
||||||
|
|
||||||
|
var out strings.Builder
|
||||||
|
for _, c := range checks {
|
||||||
|
out.WriteString(renderCheckLine(c, width))
|
||||||
|
out.WriteString("\n")
|
||||||
|
}
|
||||||
|
return title, subtitle, strings.TrimRight(out.String(), "\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/task"
|
||||||
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m Model) View() tea.View {
|
||||||
|
sections := []string{m.header(), ""}
|
||||||
|
if m.mode == modeFilter || m.filter.Value() != "" {
|
||||||
|
sections = append(sections, m.filter.View(), "")
|
||||||
|
}
|
||||||
|
sections = append(sections, m.body())
|
||||||
|
if detail := m.detail(); detail != "" {
|
||||||
|
sections = append(sections, "", detail)
|
||||||
|
}
|
||||||
|
if tray := m.taskTray(); tray != "" {
|
||||||
|
sections = append(sections, tray)
|
||||||
|
}
|
||||||
|
switch m.mode {
|
||||||
|
case modeManage:
|
||||||
|
sections = append(sections, "", m.manageBar())
|
||||||
|
case modeConfirm:
|
||||||
|
sections = append(sections, "", m.confirmBar())
|
||||||
|
case modeImageSelect:
|
||||||
|
sections = append(sections, "", m.imageSelectBar())
|
||||||
|
}
|
||||||
|
if m.status != "" {
|
||||||
|
sections = append(sections, "", m.statusView())
|
||||||
|
}
|
||||||
|
sections = append(sections, "", m.footer())
|
||||||
|
|
||||||
|
view := tea.NewView(strings.Join(sections, "\n"))
|
||||||
|
view.AltScreen = true
|
||||||
|
view.WindowTitle = ui.IconDocker + " Homarr"
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) header() string {
|
||||||
|
title := ui.IconDocker + " homarr"
|
||||||
|
screen := "development"
|
||||||
|
if m.screen == screenInstances {
|
||||||
|
screen = "instances"
|
||||||
|
}
|
||||||
|
header := ui.Title.Render(title) + ui.Dim.Render(" · ") + ui.Heading.Render(screen)
|
||||||
|
if m.busy() {
|
||||||
|
header += " " + m.spinner.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
badges := make([]string, 0, 4)
|
||||||
|
if m.screen == screenDev {
|
||||||
|
remote, local, running := 0, 0, 0
|
||||||
|
for _, row := range m.devRows {
|
||||||
|
if row.kind == rowLocal {
|
||||||
|
local++
|
||||||
|
} else {
|
||||||
|
remote++
|
||||||
|
if row.hasCurrentLocalImage() {
|
||||||
|
local++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row.running {
|
||||||
|
running++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
badges = append(badges, ui.Help.Render(fmt.Sprintf("%s %d PRs · %s %d local · %s %d running · %s %d shown", ui.IconPR, remote, ui.IconLocal, local, ui.IconRunning, running, ui.IconSearch, len(m.devFiltered))))
|
||||||
|
} else {
|
||||||
|
running := 0
|
||||||
|
for _, row := range m.instRows {
|
||||||
|
if row.container.Running() {
|
||||||
|
running++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
badges = append(badges, ui.Help.Render(fmt.Sprintf("%s %d instances · %s %d running · %s %d shown", ui.IconDocker, len(m.instRows), ui.IconRunning, running, ui.IconSearch, len(m.instFiltered))))
|
||||||
|
}
|
||||||
|
if m.demo {
|
||||||
|
badges = append(badges, ui.ModeBadge("DEMO", ui.Warning))
|
||||||
|
}
|
||||||
|
if running := m.tasks.Running(); running > 0 {
|
||||||
|
badges = append(badges, ui.Pending.Render(fmt.Sprintf("%s %d working", ui.IconBuild, running)))
|
||||||
|
}
|
||||||
|
if !m.includeBots && m.screen == screenDev {
|
||||||
|
badges = append(badges, ui.Dim.Render("bots hidden"))
|
||||||
|
}
|
||||||
|
return clip(header+" "+strings.Join(badges, ui.Dim.Render(" · ")), m.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clip trims a rendered line to the terminal width. The line already carries
|
||||||
|
// styling, so it is measured and cut by display width rather than by bytes.
|
||||||
|
func clip(line string, width int) string {
|
||||||
|
if width <= 0 || lipgloss.Width(line) <= width {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().MaxWidth(width).Render(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// body joins the active screen with the sidebar. Overlays replace the table but
|
||||||
|
// keep the sidebar, so a build stays visible while the help is open.
|
||||||
|
func (m Model) body() string {
|
||||||
|
main := m.mainPane()
|
||||||
|
if !m.sidebarRelevant() {
|
||||||
|
return main
|
||||||
|
}
|
||||||
|
panel := m.sidebar.view()
|
||||||
|
if panel == "" {
|
||||||
|
return main
|
||||||
|
}
|
||||||
|
if m.sidebarStacked {
|
||||||
|
return main + "\n" + panel
|
||||||
|
}
|
||||||
|
return lipgloss.JoinHorizontal(lipgloss.Top, main, " ", panel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) mainPane() string {
|
||||||
|
width := m.tableWidth()
|
||||||
|
switch m.mode {
|
||||||
|
case modeHelp:
|
||||||
|
return m.helpOverlay(width)
|
||||||
|
case modeTasks:
|
||||||
|
return m.taskOverlay(width)
|
||||||
|
}
|
||||||
|
if m.screen == screenInstances {
|
||||||
|
if len(m.instFiltered) == 0 {
|
||||||
|
return m.emptyPane(width, len(m.instRows) > 0,
|
||||||
|
"no Homarr instances — press enter on a pull request to start one")
|
||||||
|
}
|
||||||
|
return m.instTable.View()
|
||||||
|
}
|
||||||
|
if len(m.devFiltered) == 0 {
|
||||||
|
return m.emptyPane(width, len(m.devRows) > 0,
|
||||||
|
"nothing to show — check `gh auth status` and that Docker is running")
|
||||||
|
}
|
||||||
|
return m.devTable.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) emptyPane(width int, filtered bool, message string) string {
|
||||||
|
if filtered {
|
||||||
|
message = "nothing matches " + m.filter.Value()
|
||||||
|
}
|
||||||
|
block := lipgloss.NewStyle().Width(width).Height(m.mainHeight()).Padding(1, 2)
|
||||||
|
return block.Render(ui.Help.Render(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) helpOverlay(width int) string {
|
||||||
|
body := lipgloss.JoinVertical(lipgloss.Left,
|
||||||
|
ui.Heading.Render("Keys"),
|
||||||
|
"",
|
||||||
|
m.helpView.FullHelpView(m.keys.FullHelp()),
|
||||||
|
"",
|
||||||
|
ui.Heading.Render("Manage mode"),
|
||||||
|
ui.Help.Render("m arms manage mode for the selected row, then:"),
|
||||||
|
m.helpView.ShortHelpView(m.keys.ManageHelp()),
|
||||||
|
"",
|
||||||
|
ui.Heading.Render("Legend"),
|
||||||
|
ui.Help.Render("CI "+ui.CIBadge("SUCCESS", true)+" "+ui.CIBadge("FAILURE", true)+" "+ui.CIBadge("PENDING", true)+" "+ui.CIBadge("", true)),
|
||||||
|
ui.Help.Render("IMAGE "+ui.ImageBadge(ui.ImageLocal, true)+" "+ui.ImageBadge(ui.ImageStale, true)+" "+ui.ImageBadge(ui.ImageRemote, true)+" "+ui.ImageBadge(ui.ImageMissing, true)),
|
||||||
|
ui.Help.Render("STATE "+ui.RunningBadge(true, "7575")+" "+ui.StateBadge("exited")+" "+ui.HealthBadge("healthy")),
|
||||||
|
"",
|
||||||
|
ui.Help.Render("press any key to close"),
|
||||||
|
)
|
||||||
|
return lipgloss.NewStyle().Width(width).Height(m.mainHeight()).Padding(0, 1).Render(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskOverlay lists background work with per-job progress, so a user who
|
||||||
|
// started three builds can see all of them without cycling the sidebar.
|
||||||
|
func (m Model) taskOverlay(width int) string {
|
||||||
|
if len(m.taskList) == 0 {
|
||||||
|
return m.emptyPane(width, false, "no background tasks yet — R builds locally, p pulls a pull-request image")
|
||||||
|
}
|
||||||
|
lines := []string{ui.Heading.Render(ui.IconBuild + " Background tasks"), ""}
|
||||||
|
barWidth := clampInt(width/4, 10, 26)
|
||||||
|
for index, snapshot := range m.taskList {
|
||||||
|
cursor := " "
|
||||||
|
if index == m.taskCursor {
|
||||||
|
cursor = ui.Local.Render("▸ ")
|
||||||
|
}
|
||||||
|
colour := ui.Warning
|
||||||
|
switch snapshot.State {
|
||||||
|
case task.StateSucceeded:
|
||||||
|
colour = ui.Success
|
||||||
|
case task.StateFailed:
|
||||||
|
colour = ui.Danger
|
||||||
|
case task.StateCanceled:
|
||||||
|
colour = ui.Faint
|
||||||
|
}
|
||||||
|
line := fmt.Sprintf("%s%s %s %s %s",
|
||||||
|
cursor,
|
||||||
|
ui.TaskBadge(snapshot.State.String(), snapshot.Kind.String()),
|
||||||
|
ui.Bar(snapshot.Percent, barWidth, colour),
|
||||||
|
ui.Percent(snapshot.Percent),
|
||||||
|
ui.Body.Render(ui.Truncate(snapshot.Title, max(width-barWidth-30, 12))),
|
||||||
|
)
|
||||||
|
lines = append(lines, line)
|
||||||
|
detail := snapshot.Detail
|
||||||
|
if snapshot.Err != nil {
|
||||||
|
detail = snapshot.Err.Error()
|
||||||
|
}
|
||||||
|
if detail != "" {
|
||||||
|
lines = append(lines, ui.Help.Render(" "+ui.Truncate(detail, max(width-8, 20))+" · "+formatDuration(snapshot.Elapsed())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines = append(lines, "", ui.Help.Render("enter watch · x cancel or clear · esc close"))
|
||||||
|
return lipgloss.NewStyle().Width(width).Height(m.mainHeight()).Padding(0, 1).Render(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// detail describes the selected row in full, including the things that are too
|
||||||
|
// wide for a table cell: branch, provenance, and the image source Enter can run.
|
||||||
|
func (m Model) detail() string {
|
||||||
|
if m.screen == screenInstances {
|
||||||
|
row, found := m.selectedInstance()
|
||||||
|
if !found {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
meta := []string{
|
||||||
|
ui.StateBadge(row.container.State),
|
||||||
|
ui.Dim.Render(row.container.Image),
|
||||||
|
}
|
||||||
|
if health := ui.HealthBadge(row.health); health != "" {
|
||||||
|
meta = append(meta, health)
|
||||||
|
}
|
||||||
|
if url := row.url(); url != "" {
|
||||||
|
meta = append(meta, ui.OK.Render(ui.IconPort+" "+url))
|
||||||
|
}
|
||||||
|
if row.volume != "" {
|
||||||
|
meta = append(meta, ui.Help.Render(ui.IconData+" "+row.volume))
|
||||||
|
}
|
||||||
|
meta = append(meta, ui.Help.Render(row.container.Status))
|
||||||
|
return clip(ui.Heading.Render(ui.IconDocker+" "+row.container.Name), m.width) + "\n" +
|
||||||
|
clip(strings.Join(meta, ui.Dim.Render(" · ")), m.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
row, found := m.selectedDev()
|
||||||
|
if !found {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if row.kind == rowLocal {
|
||||||
|
meta := []string{ui.Local.Render(ui.IconLocal + " local image")}
|
||||||
|
if row.local.Size != "" {
|
||||||
|
meta = append(meta, ui.Help.Render(row.local.Size))
|
||||||
|
}
|
||||||
|
if row.local.Created != "" {
|
||||||
|
meta = append(meta, ui.Help.Render(row.local.Created))
|
||||||
|
}
|
||||||
|
if row.local.Source != "" {
|
||||||
|
meta = append(meta, ui.Help.Render(row.local.Source))
|
||||||
|
} else {
|
||||||
|
meta = append(meta, ui.Pending.Render("no provenance · rebuild unavailable"))
|
||||||
|
}
|
||||||
|
if row.local.Revision != "" {
|
||||||
|
meta = append(meta, ui.Dim.Render(ui.IconBranch+" "+truncateRevision(row.local.Revision)))
|
||||||
|
}
|
||||||
|
if row.local.PRNumber > 0 {
|
||||||
|
meta = append(meta, ui.Help.Render(fmt.Sprintf("%s PR #%d", ui.IconPR, row.local.PRNumber)))
|
||||||
|
}
|
||||||
|
meta = append(meta, ui.RunningBadge(row.running, row.port))
|
||||||
|
return clip(ui.Heading.Render(ui.IconLocal+" "+row.local.Reference()), m.width) + "\n" +
|
||||||
|
clip(strings.Join(meta, ui.Dim.Render(" · ")), m.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
branch := row.pr.HeadRef
|
||||||
|
if branch == "" {
|
||||||
|
branch = "unknown branch"
|
||||||
|
}
|
||||||
|
meta := []string{
|
||||||
|
ui.Help.Render(ui.IconUser + " @" + row.pr.Author),
|
||||||
|
ui.Dim.Render(ui.IconBranch + " " + branch),
|
||||||
|
ui.CIBadge(row.pr.CIState, true),
|
||||||
|
ui.ImageBadge(row.image, true),
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case row.running:
|
||||||
|
meta = append(meta, ui.Help.Render("enter stops it"))
|
||||||
|
case row.hasCurrentLocalImage() && m.remoteImageAvailable(row):
|
||||||
|
meta = append(meta, ui.Help.Render("enter chooses local or remote"))
|
||||||
|
case row.hasCurrentLocalImage():
|
||||||
|
meta = append(meta, ui.Local.Render("enter runs "+row.localReference()))
|
||||||
|
case row.image == ui.ImageRemote:
|
||||||
|
meta = append(meta, ui.Help.Render("enter pulls "+remoteImageFor(row)))
|
||||||
|
case row.image == ui.ImageStale:
|
||||||
|
meta = append(meta, ui.Pending.Render(row.localReference()+" is behind the head commit"))
|
||||||
|
case row.image == ui.ImageMissing:
|
||||||
|
meta = append(meta, ui.Pending.Render("R builds it locally"))
|
||||||
|
}
|
||||||
|
meta = append(meta, ui.RunningBadge(row.running, row.port))
|
||||||
|
title := fmt.Sprintf("%s #%d %s", ui.IconPR, row.pr.Number, row.pr.Title)
|
||||||
|
return clip(ui.Heading.Render(ui.Truncate(title, max(m.width-2, 20))), m.width) + "\n" +
|
||||||
|
clip(strings.Join(meta, ui.Dim.Render(" · ")), m.width)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) imageSelectBar() string {
|
||||||
|
row, found := m.selectedDev()
|
||||||
|
if !found {
|
||||||
|
return ui.Help.Render("image selection is no longer available")
|
||||||
|
}
|
||||||
|
|
||||||
|
title := fmt.Sprintf("%s choose image for PR #%d", ui.IconDocker, row.pr.Number)
|
||||||
|
local := m.imageChoiceLine(imageChoiceLocal, ui.IconLocal+" local", row.localReference())
|
||||||
|
remote := m.imageChoiceLine(imageChoiceRemote, ui.IconCloud+" remote", remoteImageFor(row))
|
||||||
|
hint := ui.Help.Render("↑/↓ choose · enter start · esc cancel")
|
||||||
|
return strings.Join([]string{ui.Heading.Render(title), local, remote, hint}, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) imageChoiceLine(choice imageChoice, label, reference string) string {
|
||||||
|
marker := " "
|
||||||
|
style := ui.Help
|
||||||
|
if m.imageSelection.choice == choice {
|
||||||
|
marker = "▸ "
|
||||||
|
style = ui.Selected
|
||||||
|
}
|
||||||
|
return style.Render(ui.Truncate(marker+label+" "+reference, max(m.width-2, 20)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskTray is the always-visible one-liner for the job being followed. It uses
|
||||||
|
// the springy bubbles progress bar because a single headline bar reads better
|
||||||
|
// when it eases towards its target.
|
||||||
|
func (m Model) taskTray() string {
|
||||||
|
if m.tasks.Running() == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
snapshot, found := m.focusedTask()
|
||||||
|
if !found {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
detail := snapshot.Detail
|
||||||
|
if detail == "" {
|
||||||
|
detail = snapshot.State.String()
|
||||||
|
}
|
||||||
|
title := ui.Truncate(snapshot.Title, clampInt(m.width/3, 12, 40))
|
||||||
|
head := fmt.Sprintf("%s %s %s %s",
|
||||||
|
ui.Pending.Render(m.spinner.View()),
|
||||||
|
ui.Body.Render(title),
|
||||||
|
m.progress.View(),
|
||||||
|
ui.Pending.Render(ui.Percent(snapshot.Percent)),
|
||||||
|
)
|
||||||
|
remaining := m.width - lipgloss.Width(head) - 1
|
||||||
|
if remaining < 8 {
|
||||||
|
return head
|
||||||
|
}
|
||||||
|
return head + " " + ui.Help.Render(ui.Truncate(detail+" · "+formatDuration(snapshot.Elapsed()), remaining))
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusStyles = map[statusLevel]lipgloss.Style{
|
||||||
|
levelInfo: ui.Help,
|
||||||
|
levelOK: ui.OK,
|
||||||
|
levelWarn: ui.Pending,
|
||||||
|
levelError: ui.Alert,
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusIcons = map[statusLevel]string{
|
||||||
|
levelInfo: ui.IconNone,
|
||||||
|
levelOK: ui.IconPass,
|
||||||
|
levelWarn: ui.IconPending,
|
||||||
|
levelError: ui.IconFail,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) statusView() string {
|
||||||
|
style, found := statusStyles[m.statusLevel]
|
||||||
|
if !found {
|
||||||
|
style = ui.Help
|
||||||
|
}
|
||||||
|
icon := statusIcons[m.statusLevel]
|
||||||
|
text := m.status
|
||||||
|
if icon != "" && icon != ui.IconNone {
|
||||||
|
text = icon + " " + text
|
||||||
|
}
|
||||||
|
return style.Render(ui.Truncate(text, max(m.width-1, 20)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer shows the bindings that apply to the current screen, so the hints
|
||||||
|
// never advertise a key that would do nothing here.
|
||||||
|
func (m Model) footer() string {
|
||||||
|
primary := m.keys.DevHelp()
|
||||||
|
if m.screen == screenInstances {
|
||||||
|
primary = m.keys.InstanceHelp()
|
||||||
|
}
|
||||||
|
return clip(m.helpView.ShortHelpView(primary), m.width) + "\n" +
|
||||||
|
clip(m.helpView.ShortHelpView(m.keys.CommonHelp()), m.width)
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image/color"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Badges come in two weights. A chip is a filled pill used for the handful of
|
||||||
|
// states that must be seen at a glance across a busy table — running, demo,
|
||||||
|
// manage mode. Everything else is a glyph plus coloured text, which stays
|
||||||
|
// readable when a dozen of them share a row.
|
||||||
|
func chip(label string, background color.Color) string {
|
||||||
|
return lipgloss.NewStyle().Bold(true).Foreground(Ink).Background(background).Padding(0, 1).Render(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
func glyph(symbol, label string, colour color.Color) string {
|
||||||
|
style := lipgloss.NewStyle().Foreground(colour)
|
||||||
|
if label == "" {
|
||||||
|
return style.Render(symbol)
|
||||||
|
}
|
||||||
|
return style.Render(symbol + " " + label)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CI & Status badge glyphs (Nerd Fonts).
|
||||||
|
const (
|
||||||
|
IconPass = "\uf00c" // nf-fa-check
|
||||||
|
IconFail = "\uf00d" // nf-fa-times
|
||||||
|
IconPending = "\uf444" // nf-oct-dot_fill
|
||||||
|
IconNone = "–"
|
||||||
|
IconLocal = "\uf1b2" // nf-fa-cube
|
||||||
|
IconCloud = "\uf0c2" // nf-fa-cloud
|
||||||
|
IconRunning = "\uf04b" // nf-fa-play
|
||||||
|
IconStopped = "\uf04d" // nf-fa-stop
|
||||||
|
IconPaused = "\uf04c" // nf-fa-pause
|
||||||
|
IconBuild = "\uf0ad" // nf-fa-wrench
|
||||||
|
IconData = "\uf1c0" // nf-fa-database
|
||||||
|
IconDocker = "\uf308" // nf-fa-docker
|
||||||
|
IconGitHub = "\uf09b" // nf-fa-github
|
||||||
|
IconPR = "\uf407" // nf-oct-git_pull_request
|
||||||
|
IconBranch = "\uf418" // nf-oct-git_branch
|
||||||
|
IconTerminal = "\uf120" // nf-fa-terminal
|
||||||
|
IconHealth = "\uf004" // nf-fa-heart
|
||||||
|
IconPort = "\uf08e" // nf-fa-external_link
|
||||||
|
IconUser = "\uf007" // nf-fa-user
|
||||||
|
IconPin = "\uf435" // nf-oct-pin
|
||||||
|
IconSearch = "\uf002" // nf-fa-search
|
||||||
|
IconKey = "\uf084" // nf-fa-key
|
||||||
|
)
|
||||||
|
|
||||||
|
type badgeSpec struct {
|
||||||
|
symbol string
|
||||||
|
label string
|
||||||
|
colour color.Color
|
||||||
|
}
|
||||||
|
|
||||||
|
var ciBadges = map[string]badgeSpec{
|
||||||
|
"SUCCESS": {IconPass, "checks pass", Success},
|
||||||
|
"FAILURE": {IconFail, "checks fail", Danger},
|
||||||
|
"PENDING": {IconPending, "checks running", Warning},
|
||||||
|
}
|
||||||
|
|
||||||
|
// CIBadge renders the GitHub check rollup of a pull request.
|
||||||
|
func CIBadge(state string, verbose bool) string {
|
||||||
|
spec, found := ciBadges[state]
|
||||||
|
if !found {
|
||||||
|
spec = badgeSpec{IconNone, "no checks", Faint}
|
||||||
|
}
|
||||||
|
label := spec.label
|
||||||
|
if !verbose {
|
||||||
|
label = ""
|
||||||
|
}
|
||||||
|
return glyph(spec.symbol, label, spec.colour)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageState describes where a runnable image for a row can come from.
|
||||||
|
type ImageState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ImageUnknown ImageState = iota
|
||||||
|
ImageChecking
|
||||||
|
ImageRemote
|
||||||
|
ImageMissing
|
||||||
|
ImageLocal
|
||||||
|
ImageStale
|
||||||
|
ImageError
|
||||||
|
)
|
||||||
|
|
||||||
|
var imageBadges = map[ImageState]badgeSpec{
|
||||||
|
ImageLocal: {IconLocal, "local", Info},
|
||||||
|
ImageStale: {IconLocal, "local (outdated)", Muted},
|
||||||
|
ImageRemote: {IconCloud, "ghcr", Success},
|
||||||
|
ImageMissing: {"…", "not built", Faint},
|
||||||
|
ImageError: {"!", "check failed", Danger},
|
||||||
|
ImageChecking: {"?", "checking", Warning},
|
||||||
|
ImageUnknown: {"?", "checking", Warning},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageBadge renders the preferred image availability for a row. A matching
|
||||||
|
// local build is shown first; the start action can still offer the registry
|
||||||
|
// image when both sources are available.
|
||||||
|
func ImageBadge(state ImageState, verbose bool) string {
|
||||||
|
spec, found := imageBadges[state]
|
||||||
|
if !found {
|
||||||
|
spec = badgeSpec{"?", "checking", Warning}
|
||||||
|
}
|
||||||
|
label := spec.label
|
||||||
|
if !verbose {
|
||||||
|
label = ""
|
||||||
|
}
|
||||||
|
return glyph(spec.symbol, label, spec.colour)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunningBadge renders container state, filled when the container is up so it
|
||||||
|
// stands out from every other marker in the row.
|
||||||
|
func RunningBadge(running bool, port string) string {
|
||||||
|
if !running {
|
||||||
|
return glyph(IconStopped, "", Faint)
|
||||||
|
}
|
||||||
|
if port == "" {
|
||||||
|
return chip(IconRunning+" UP", Success)
|
||||||
|
}
|
||||||
|
return chip(IconRunning+" :"+port, Success)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stateBadges = map[string]badgeSpec{
|
||||||
|
"running": {IconRunning, "running", Success},
|
||||||
|
"restarting": {IconPending, "restarting", Warning},
|
||||||
|
"created": {IconPending, "created", Warning},
|
||||||
|
"paused": {IconPending, "paused", Warning},
|
||||||
|
"exited": {IconStopped, "exited", Danger},
|
||||||
|
"dead": {IconStopped, "dead", Danger},
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateBadge renders a Docker container state string.
|
||||||
|
func StateBadge(state string) string {
|
||||||
|
if spec, found := stateBadges[state]; found {
|
||||||
|
return glyph(spec.symbol, spec.label, spec.colour)
|
||||||
|
}
|
||||||
|
return glyph(IconStopped, state, Faint)
|
||||||
|
}
|
||||||
|
|
||||||
|
var healthBadges = map[string]badgeSpec{
|
||||||
|
"healthy": {IconHealth, "healthy", Success},
|
||||||
|
"unhealthy": {IconFail, "unhealthy", Danger},
|
||||||
|
"starting": {IconPending, "starting", Warning},
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthBadge renders the healthcheck verdict Docker embeds in a status string.
|
||||||
|
func HealthBadge(health string) string {
|
||||||
|
if spec, found := healthBadges[health]; found {
|
||||||
|
return glyph(spec.symbol, spec.label, spec.colour)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModeBadge marks a non-default interaction mode in the header.
|
||||||
|
func ModeBadge(label string, colour color.Color) string { return chip(label, colour) }
|
||||||
|
|
||||||
|
var taskGlyphs = map[string]struct {
|
||||||
|
symbol string
|
||||||
|
colour color.Color
|
||||||
|
}{
|
||||||
|
"running": {IconPending, Warning},
|
||||||
|
"succeeded": {IconPass, Success},
|
||||||
|
"failed": {IconFail, Danger},
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskBadge renders the outcome of a background job.
|
||||||
|
func TaskBadge(state, kind string) string {
|
||||||
|
if spec, found := taskGlyphs[state]; found {
|
||||||
|
return glyph(spec.symbol, kind, spec.colour)
|
||||||
|
}
|
||||||
|
return glyph(IconNone, kind, Faint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bar renders a fixed width progress bar. The bubbles progress component
|
||||||
|
// animates a single value with a spring; these bars are drawn many per frame
|
||||||
|
// for individual layers and steps, where an instant, exact bar reads better
|
||||||
|
// than a dozen independently easing ones.
|
||||||
|
func Bar(percent float64, width int, colour color.Color) string {
|
||||||
|
if width <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if percent < 0 {
|
||||||
|
percent = 0
|
||||||
|
}
|
||||||
|
if percent > 1 {
|
||||||
|
percent = 1
|
||||||
|
}
|
||||||
|
filled := int(percent * float64(width))
|
||||||
|
empty := width - filled
|
||||||
|
return lipgloss.NewStyle().Foreground(colour).Render(strings.Repeat("█", filled)) +
|
||||||
|
lipgloss.NewStyle().Foreground(Faint).Render(strings.Repeat("░", empty))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Percent formats a fraction for display next to a bar.
|
||||||
|
func Percent(value float64) string { return fmt.Sprintf("%3.0f%%", value*100) }
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import "charm.land/bubbles/v2/key"
|
||||||
|
|
||||||
|
// KeyMap is the complete binding set. Bindings are declared once here so the
|
||||||
|
// help overlay, the footer hints, and the dispatcher can never drift apart —
|
||||||
|
// adding a key to the map is what makes it appear in the help.
|
||||||
|
type KeyMap struct {
|
||||||
|
Up key.Binding
|
||||||
|
Down key.Binding
|
||||||
|
Top key.Binding
|
||||||
|
Bottom key.Binding
|
||||||
|
Enter key.Binding
|
||||||
|
Filter key.Binding
|
||||||
|
Refresh key.Binding
|
||||||
|
Quit key.Binding
|
||||||
|
Help key.Binding
|
||||||
|
|
||||||
|
Dev key.Binding
|
||||||
|
Instances key.Binding
|
||||||
|
Cycle key.Binding
|
||||||
|
|
||||||
|
Sidebar key.Binding
|
||||||
|
SidebarTab key.Binding
|
||||||
|
Pin key.Binding
|
||||||
|
Follow key.Binding
|
||||||
|
PageUp key.Binding
|
||||||
|
PageDown key.Binding
|
||||||
|
|
||||||
|
Pull key.Binding
|
||||||
|
Build key.Binding
|
||||||
|
Demo key.Binding
|
||||||
|
Bots key.Binding
|
||||||
|
OpenPR key.Binding
|
||||||
|
OpenApp key.Binding
|
||||||
|
Copy key.Binding
|
||||||
|
|
||||||
|
Stop key.Binding
|
||||||
|
Restart key.Binding
|
||||||
|
Remove key.Binding
|
||||||
|
|
||||||
|
Manage key.Binding
|
||||||
|
Tasks key.Binding
|
||||||
|
Cancel key.Binding
|
||||||
|
|
||||||
|
ManageImage key.Binding
|
||||||
|
ManageData key.Binding
|
||||||
|
ManageBuild key.Binding
|
||||||
|
ManageStop key.Binding
|
||||||
|
ManagePrune key.Binding
|
||||||
|
ManageConfirm key.Binding
|
||||||
|
ManageAbort key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultKeyMap() KeyMap {
|
||||||
|
return KeyMap{
|
||||||
|
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
|
||||||
|
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
|
||||||
|
Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("g", "top")),
|
||||||
|
Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("G", "bottom")),
|
||||||
|
Enter: key.NewBinding(key.WithKeys("enter", " "), key.WithHelp("enter", "start/stop")),
|
||||||
|
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
||||||
|
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
|
||||||
|
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||||
|
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||||
|
|
||||||
|
Dev: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "development")),
|
||||||
|
Instances: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "instances")),
|
||||||
|
Cycle: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "switch screen")),
|
||||||
|
|
||||||
|
Sidebar: key.NewBinding(key.WithKeys("l"), key.WithHelp("l", "sidebar")),
|
||||||
|
SidebarTab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "logs/build/ci")),
|
||||||
|
Pin: key.NewBinding(key.WithKeys("L"), key.WithHelp("L", "pin logs")),
|
||||||
|
Follow: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "follow")),
|
||||||
|
PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "scroll up")),
|
||||||
|
PageDown: key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "scroll down")),
|
||||||
|
|
||||||
|
Pull: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "pull remote")),
|
||||||
|
Build: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "build locally")),
|
||||||
|
Demo: key.NewBinding(key.WithKeys("M"), key.WithHelp("M", "demo mode")),
|
||||||
|
Bots: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "bot PRs")),
|
||||||
|
OpenPR: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "open PR")),
|
||||||
|
OpenApp: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "open app")),
|
||||||
|
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy URL")),
|
||||||
|
|
||||||
|
Stop: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "stop")),
|
||||||
|
Restart: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "restart")),
|
||||||
|
Remove: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "remove")),
|
||||||
|
|
||||||
|
Manage: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "manage")),
|
||||||
|
Tasks: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "tasks")),
|
||||||
|
Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear filter")),
|
||||||
|
|
||||||
|
ManageImage: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "delete image")),
|
||||||
|
ManageData: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete data")),
|
||||||
|
ManageBuild: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "rebuild locally")),
|
||||||
|
ManageStop: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "remove container")),
|
||||||
|
ManagePrune: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "prune stopped")),
|
||||||
|
ManageConfirm: key.NewBinding(key.WithKeys("y", "enter"), key.WithHelp("y", "confirm")),
|
||||||
|
ManageAbort: key.NewBinding(key.WithKeys("n", "esc"), key.WithHelp("n", "cancel")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortHelp is the footer hint set, kept to what fits on two lines.
|
||||||
|
func (k KeyMap) ShortHelp() []key.Binding {
|
||||||
|
return []key.Binding{k.Up, k.Down, k.Enter, k.Filter, k.Sidebar, k.Manage, k.Tasks, k.Cycle, k.Help, k.Quit}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullHelp is the grouped set rendered by the help overlay.
|
||||||
|
func (k KeyMap) FullHelp() [][]key.Binding {
|
||||||
|
return [][]key.Binding{
|
||||||
|
{k.Up, k.Down, k.Top, k.Bottom, k.Filter, k.Refresh},
|
||||||
|
{k.Enter, k.Pull, k.Build, k.Demo, k.Bots},
|
||||||
|
{k.Sidebar, k.SidebarTab, k.Pin, k.Follow, k.PageUp, k.PageDown},
|
||||||
|
{k.Stop, k.Restart, k.Remove, k.OpenPR, k.OpenApp, k.Copy},
|
||||||
|
{k.Manage, k.Tasks, k.Dev, k.Instances, k.Cycle, k.Quit},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DevHelp is the footer hint set for the development screen.
|
||||||
|
func (k KeyMap) DevHelp() []key.Binding {
|
||||||
|
return []key.Binding{k.Enter, k.Pull, k.Build, k.Demo, k.OpenPR, k.OpenApp, k.Bots}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceHelp is the footer hint set for the instances screen.
|
||||||
|
func (k KeyMap) InstanceHelp() []key.Binding {
|
||||||
|
return []key.Binding{k.Enter, k.Stop, k.Restart, k.Remove, k.Build, k.OpenApp, k.Copy}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommonHelp is the second footer line, shared by both screens.
|
||||||
|
func (k KeyMap) CommonHelp() []key.Binding {
|
||||||
|
return []key.Binding{k.Filter, k.Cancel, k.Sidebar, k.SidebarTab, k.Pin, k.Follow, k.Manage, k.Tasks, k.Cycle, k.Refresh, k.Help, k.Quit}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManageHelp is the binding set shown while manage mode is armed.
|
||||||
|
func (k KeyMap) ManageHelp() []key.Binding {
|
||||||
|
return []key.Binding{k.ManageBuild, k.ManageImage, k.ManageData, k.ManageStop, k.ManagePrune, k.ManageAbort}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Package ui holds the CLI's visual vocabulary: one palette, one set of status
|
||||||
|
// badges, and one keymap, so every screen looks and behaves the same way.
|
||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The palette is intentionally small. Colour carries meaning here — green is
|
||||||
|
// healthy, red is broken, amber is in flight, cyan is local — so introducing
|
||||||
|
// extra hues would dilute the signal rather than add information.
|
||||||
|
var (
|
||||||
|
Accent = lipgloss.Color("212")
|
||||||
|
Success = lipgloss.Color("42")
|
||||||
|
Warning = lipgloss.Color("214")
|
||||||
|
Danger = lipgloss.Color("203")
|
||||||
|
Info = lipgloss.Color("81")
|
||||||
|
Muted = lipgloss.Color("241")
|
||||||
|
Faint = lipgloss.Color("238")
|
||||||
|
Text = lipgloss.Color("252")
|
||||||
|
Bright = lipgloss.Color("230")
|
||||||
|
Ink = lipgloss.Color("235")
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Title = lipgloss.NewStyle().Bold(true).Foreground(Accent)
|
||||||
|
Heading = lipgloss.NewStyle().Bold(true).Foreground(Bright)
|
||||||
|
Help = lipgloss.NewStyle().Foreground(Muted)
|
||||||
|
Dim = lipgloss.NewStyle().Foreground(Faint)
|
||||||
|
Body = lipgloss.NewStyle().Foreground(Text)
|
||||||
|
OK = lipgloss.NewStyle().Foreground(Success)
|
||||||
|
Alert = lipgloss.NewStyle().Foreground(Danger)
|
||||||
|
Pending = lipgloss.NewStyle().Foreground(Warning)
|
||||||
|
Local = lipgloss.NewStyle().Foreground(Info)
|
||||||
|
Selected = lipgloss.NewStyle().Bold(true).Foreground(Bright).Background(lipgloss.Color("57"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Panel frames the sidebar and overlays.
|
||||||
|
func Panel(title string, focused bool) lipgloss.Style {
|
||||||
|
var border color.Color = Faint
|
||||||
|
if focused {
|
||||||
|
border = Accent
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(border).
|
||||||
|
Padding(0, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule draws a labelled separator across the given width.
|
||||||
|
func Rule(label string, width int) string {
|
||||||
|
if width < 8 {
|
||||||
|
return Dim.Render(Truncate(label, width))
|
||||||
|
}
|
||||||
|
prefix := "── " + label + " "
|
||||||
|
prefixWidth := lipgloss.Width(prefix)
|
||||||
|
if prefixWidth >= width {
|
||||||
|
return Dim.Render(Truncate(prefix, width))
|
||||||
|
}
|
||||||
|
fill := width - prefixWidth
|
||||||
|
return Dim.Render(prefix + strings.Repeat("─", fill))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blank reports whether a line would render as empty. Build tools emit lines
|
||||||
|
// that hold nothing but a colour reset; they are not empty as strings but they
|
||||||
|
// occupy a row for no reason, which turns a log panel into double-spaced text.
|
||||||
|
func Blank(line string) bool { return lipgloss.Width(strings.TrimSpace(line)) == 0 }
|
||||||
|
|
||||||
|
// Truncate shortens a string to display width, marking the cut with an ellipsis.
|
||||||
|
// It handles ANSI escape sequences and multi-byte runes correctly.
|
||||||
|
func Truncate(value string, width int) string {
|
||||||
|
if width <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ansi.Truncate(value, width, "…")
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"charm.land/lipgloss/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGlyphWidths(t *testing.T) {
|
||||||
|
glyphs := []string{
|
||||||
|
"✓", "✗", "●", "–", "◆", "☁", "▶", "○", "⚒", "▤", "█", "░",
|
||||||
|
// Nerd Font glyphs:
|
||||||
|
"\uf00c", // nf-fa-check
|
||||||
|
"\uf00d", // nf-fa-times
|
||||||
|
"\uf444", // nf-oct-dot_fill
|
||||||
|
"\uf0c2", // nf-fa-cloud
|
||||||
|
"\uf1b2", // nf-fa-cube
|
||||||
|
"\uf04b", // nf-fa-play
|
||||||
|
"\uf04d", // nf-fa-stop
|
||||||
|
"\uf0ad", // nf-fa-wrench
|
||||||
|
"\uf1c0", // nf-fa-database
|
||||||
|
"\uf407", // nf-oct-git_pull_request
|
||||||
|
"\uf120", // nf-fa-terminal
|
||||||
|
"\uf007", // nf-fa-user
|
||||||
|
"\uf004", // nf-fa-heart
|
||||||
|
"\uf09b", // nf-fa-github
|
||||||
|
"\uf308", // nf-fa-docker
|
||||||
|
"\uf002", // nf-fa-search
|
||||||
|
"\uf08e", // nf-fa-external_link
|
||||||
|
}
|
||||||
|
for _, glyph := range glyphs {
|
||||||
|
t.Logf("%q (%s) width=%d", glyph, glyph, lipgloss.Width(glyph))
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-305
@@ -1,316 +1,16 @@
|
|||||||
|
// Command homarr is the Homarr developer CLI. It builds and launches local
|
||||||
|
// images and pull-request builds, then manages them from an interactive
|
||||||
|
// terminal application where every slow operation runs in the background.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/cli"
|
||||||
|
|
||||||
"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() {
|
func main() {
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := cli.Execute(); err != nil {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
|
||||||
Use: "homarr",
|
|
||||||
Short: "Launch and manage Homarr Docker instances",
|
|
||||||
SilenceUsage: true,
|
|
||||||
Long: `Launch and manage Homarr Docker containers.
|
|
||||||
|
|
||||||
Start a local image, a PR test build from ghcr.io, browse open PRs,
|
|
||||||
or manage running instances from an interactive dashboard.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
homarr --pr 6390 # launch remote PR test image directly
|
|
||||||
homarr --pr 6390 -e FOO=bar # PR image with environment overrides
|
|
||||||
homarr run dev # launch local homarr:dev image
|
|
||||||
homarr run --pr 6390 # launch remote PR test image
|
|
||||||
homarr run --pr 6390 --demo # PR + demo mode
|
|
||||||
homarr run -e FOO=bar dev # extra env vars
|
|
||||||
homarr run --detach dev # start in background
|
|
||||||
homarr dash # interactive instance dashboard
|
|
||||||
homarr dev # browse remote PRs and local images
|
|
||||||
homarr build feature # build this checkout as homarr:feature
|
|
||||||
homarr build --pr 6390 # build PR #6390 locally as homarr:pr-6390
|
|
||||||
homarr list # script-friendly instance list
|
|
||||||
homarr logs homarr_pr_6390 # follow logs
|
|
||||||
homarr doctor # check Docker and optional integrations`,
|
|
||||||
Args: cobra.NoArgs,
|
|
||||||
RunE: runRoot,
|
|
||||||
}
|
|
||||||
|
|
||||||
var dashCmd = &cobra.Command{
|
|
||||||
Use: "dash",
|
|
||||||
Short: "Interactive dashboard of running instances",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return tui.RunDashboard()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var runCmd = &cobra.Command{
|
|
||||||
Use: "run [tag]",
|
|
||||||
Short: "Launch a local image or remote pull-request image",
|
|
||||||
Args: cobra.MaximumNArgs(1),
|
|
||||||
RunE: runLaunch,
|
|
||||||
}
|
|
||||||
|
|
||||||
var devCmd = &cobra.Command{
|
|
||||||
Use: "dev",
|
|
||||||
Short: "Browse remote PRs and local development images",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return tui.RunDev()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var buildCmd = &cobra.Command{
|
|
||||||
Use: "build [name]",
|
|
||||||
Short: "Build a local Homarr image from this checkout or a pull request",
|
|
||||||
Args: cobra.MaximumNArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
tag := ""
|
|
||||||
if len(args) > 0 {
|
|
||||||
tag = args[0]
|
|
||||||
}
|
|
||||||
if flagBuildPR < 0 {
|
|
||||||
return fmt.Errorf("PR number must be positive")
|
|
||||||
}
|
|
||||||
if flagBuildPR > 0 {
|
|
||||||
if tag == "" {
|
|
||||||
tag = fmt.Sprintf("pr-%d", flagBuildPR)
|
|
||||||
}
|
|
||||||
return run.BuildPRImage(flagBuildPR, tag)
|
|
||||||
}
|
|
||||||
if tag == "" {
|
|
||||||
return fmt.Errorf("image name is required when --pr is not set")
|
|
||||||
}
|
|
||||||
return run.BuildCurrentImage(tag, ".")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var rebuildCmd = &cobra.Command{
|
|
||||||
Use: "rebuild <name>",
|
|
||||||
Short: "Rebuild a local image from its recorded checkout or pull request",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
images, err := docker.ListLocalImages(cmd.Context())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, image := range images {
|
|
||||||
if image.Tag == args[0] || image.Reference() == args[0] {
|
|
||||||
return run.RebuildImage(image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("local image homarr:%s was not found", strings.TrimPrefix(args[0], "homarr:"))
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var logsCmd = &cobra.Command{
|
|
||||||
Use: "logs <container>",
|
|
||||||
Short: "Follow logs of a running instance",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return docker.FollowLogs(args[0])
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var stopCmd = &cobra.Command{
|
|
||||||
Use: "stop <container>",
|
|
||||||
Short: "Stop a running instance",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return docker.Stop(args[0])
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var restartCmd = &cobra.Command{
|
|
||||||
Use: "restart <container>",
|
|
||||||
Short: "Restart a running instance",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return docker.Restart(args[0])
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var removeCmd = &cobra.Command{
|
|
||||||
Use: "remove <container>",
|
|
||||||
Short: "Force-remove an instance",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
return docker.Remove(args[0])
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var openCmd = &cobra.Command{
|
|
||||||
Use: "open <container>",
|
|
||||||
Short: "Open a running instance in the browser",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
containers, err := docker.List()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, container := range containers {
|
|
||||||
if container.Name != args[0] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !container.Running() {
|
|
||||||
return fmt.Errorf("container %q is not running", args[0])
|
|
||||||
}
|
|
||||||
port := container.HostPort()
|
|
||||||
if port == "" {
|
|
||||||
return fmt.Errorf("container %q does not publish the Homarr port", args[0])
|
|
||||||
}
|
|
||||||
return platform.OpenURL("http://localhost:" + port)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("container %q was not found", args[0])
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var listCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Aliases: []string{"ls"},
|
|
||||||
Short: "List Homarr instances without opening the dashboard",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
containers, err := docker.List()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(containers) == 0 {
|
|
||||||
fmt.Println("No Homarr instances found.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
fmt.Printf("%-28s %-10s %-7s %s\n", "NAME", "STATE", "PORT", "IMAGE")
|
|
||||||
for _, container := range containers {
|
|
||||||
fmt.Printf("%-28s %-10s %-7s %s\n", container.Name, container.State, container.HostPort(), container.Image)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var doctorCmd = &cobra.Command{
|
|
||||||
Use: "doctor",
|
|
||||||
Short: "Check Docker and optional developer CLI integrations",
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
failed := false
|
|
||||||
output, err := exec.Command("docker", "info", "--format", "{{.ServerVersion}}").CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
failed = true
|
|
||||||
fmt.Printf("✗ Docker daemon: %s\n", strings.TrimSpace(string(output)))
|
|
||||||
} else {
|
|
||||||
fmt.Printf("✓ Docker daemon: %s\n", strings.TrimSpace(string(output)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := exec.LookPath("gh"); err != nil {
|
|
||||||
fmt.Println("- GitHub CLI not found (optional; required for pull-request features)")
|
|
||||||
} else if output, err := exec.Command("gh", "auth", "status").CombinedOutput(); err != nil {
|
|
||||||
fmt.Printf("- GitHub CLI is not authenticated (optional; run `gh auth login` for pull-request features): %s\n", strings.TrimSpace(string(output)))
|
|
||||||
} else {
|
|
||||||
fmt.Println("✓ GitHub CLI authentication")
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := exec.LookPath("homarr"); err != nil {
|
|
||||||
fmt.Println("- homarr is not on PATH (optional; use `pnpm dev:cli -- ...` or run `pnpm dev:cli:install`)")
|
|
||||||
} else {
|
|
||||||
fmt.Println("✓ homarr is on PATH")
|
|
||||||
}
|
|
||||||
if failed {
|
|
||||||
return fmt.Errorf("one or more checks failed")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
addRunFlags(rootCmd)
|
|
||||||
addRunFlags(runCmd)
|
|
||||||
buildCmd.Flags().IntVarP(&flagBuildPR, "pr", "p", 0, "Build a pull request in a temporary checkout")
|
|
||||||
|
|
||||||
rootCmd.AddCommand(runCmd, dashCmd, devCmd, buildCmd, rebuildCmd, listCmd, logsCmd, stopCmd, restartCmd, removeCmd, openCmd, doctorCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
func addRunFlags(cmd *cobra.Command) {
|
|
||||||
cmd.Flags().BoolVarP(&flagDemo, "demo", "m", false, "Demo mode (mock integrations)")
|
|
||||||
cmd.Flags().IntVarP(&flagPR, "pr", "p", 0, "Launch PR test image from ghcr.io")
|
|
||||||
cmd.Flags().StringArrayVarP(&flagEnv, "env", "e", nil, "Extra env vars (repeatable, KEY=VALUE)")
|
|
||||||
cmd.Flags().BoolVar(&flagDetach, "detach", false, "Start in the background")
|
|
||||||
}
|
|
||||||
|
|
||||||
func runRoot(cmd *cobra.Command, args []string) error {
|
|
||||||
for _, name := range []string{"pr", "env", "demo", "detach"} {
|
|
||||||
if cmd.Flags().Changed(name) {
|
|
||||||
return runRootMain(cmd, args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return runMain()
|
|
||||||
}
|
|
||||||
|
|
||||||
func runLaunch(cmd *cobra.Command, args []string) error {
|
|
||||||
tag := ""
|
|
||||||
if len(args) > 0 {
|
|
||||||
tag = args[0]
|
|
||||||
}
|
|
||||||
if flagPR < 0 {
|
|
||||||
return fmt.Errorf("PR number must be positive")
|
|
||||||
}
|
|
||||||
if flagPR > 0 && tag != "" {
|
|
||||||
return fmt.Errorf("tag and --pr cannot be used together")
|
|
||||||
}
|
|
||||||
if flagPR == 0 && tag == "" {
|
|
||||||
if err := cmd.Usage(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return fmt.Errorf("provide a tag or --pr")
|
|
||||||
}
|
|
||||||
|
|
||||||
plan, err := run.BuildPlan(run.Options{
|
|
||||||
Context: cmd.Context(),
|
|
||||||
PR: flagPR,
|
|
||||||
Tag: tag,
|
|
||||||
Demo: flagDemo,
|
|
||||||
Env: flagEnv,
|
|
||||||
FetchTitle: flagPR > 0,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Starting Homarr (%s)\n", plan.Label)
|
|
||||||
fmt.Printf(" Image : %s\n", plan.Image)
|
|
||||||
fmt.Printf(" Container : %s\n", plan.Name)
|
|
||||||
fmt.Printf(" Volume : %s\n", plan.Volume)
|
|
||||||
fmt.Printf(" Port : %d:7575\n", plan.HostPort)
|
|
||||||
fmt.Printf(" URL : http://localhost:%d\n", plan.HostPort)
|
|
||||||
if plan.PRNumber > 0 {
|
|
||||||
fmt.Printf(" PR : https://github.com/homarr-labs/homarr/pull/%d\n", plan.PRNumber)
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
if flagDetach {
|
|
||||||
if err := run.StartDetached(plan); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
fmt.Printf("Started in background: http://localhost:%d\n", plan.HostPort)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return run.StartForeground(plan)
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestRootCommandLaunchesDevelopmentDashboard(t *testing.T) {
|
|
||||||
want := errors.New("dashboard started")
|
|
||||||
previous := runMain
|
|
||||||
t.Cleanup(func() { runMain = previous })
|
|
||||||
runMain = func() error { return want }
|
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
if err := runRoot(cmd, nil); !errors.Is(err, want) {
|
|
||||||
t.Fatalf("root command error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRootCommandLaunchesPRWhenRunFlagIsProvided(t *testing.T) {
|
|
||||||
want := errors.New("PR launched")
|
|
||||||
previous := runRootMain
|
|
||||||
t.Cleanup(func() { runRootMain = previous })
|
|
||||||
runRootMain = func(cmd *cobra.Command, args []string) error { return want }
|
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
cmd.Flags().Bool("pr", false, "")
|
|
||||||
if err := cmd.Flags().Set("pr", "true"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := runRoot(cmd, nil); !errors.Is(err, want) {
|
|
||||||
t.Fatalf("root command error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunLaunchRejectsTagWithPR(t *testing.T) {
|
|
||||||
previousPR := flagPR
|
|
||||||
t.Cleanup(func() { flagPR = previousPR })
|
|
||||||
flagPR = 42
|
|
||||||
|
|
||||||
err := runLaunch(runCmd, []string{"feature"})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "cannot be used together") {
|
|
||||||
t.Fatalf("error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunLaunchRejectsNegativePR(t *testing.T) {
|
|
||||||
previousPR := flagPR
|
|
||||||
t.Cleanup(func() { flagPR = previousPR })
|
|
||||||
flagPR = -1
|
|
||||||
|
|
||||||
err := runLaunch(runCmd, nil)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "must be positive") {
|
|
||||||
t.Fatalf("error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunLaunchRejectsMissingImage(t *testing.T) {
|
|
||||||
previousPR := flagPR
|
|
||||||
t.Cleanup(func() { flagPR = previousPR })
|
|
||||||
flagPR = 0
|
|
||||||
|
|
||||||
err := runLaunch(runCmd, nil)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "provide a tag or --pr") {
|
|
||||||
t.Fatalf("error = %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user