Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
193a536a2d | ||
|
|
c8df2fbe5f | ||
|
|
7c4c1ffbe7 | ||
|
|
84b000afda | ||
|
|
7710abcf26 | ||
|
|
4c475b8c0a | ||
|
|
f3014fe271 | ||
|
|
bd4f6a92c6 |
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."
|
||||
@@ -374,21 +374,31 @@ jobs:
|
||||
AMD64_DIGEST: ${{ needs.preview-amd64.outputs.digest }}
|
||||
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
|
||||
run: |
|
||||
docker buildx imagetools create -t "${PREVIEW_IMAGE}:${PREVIEW_TAG}" \
|
||||
tags=(-t "${PREVIEW_IMAGE}:${PREVIEW_TAG}")
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
|
||||
tags+=(-t "${PREVIEW_IMAGE}:pr-6545")
|
||||
fi
|
||||
docker buildx imagetools create "${tags[@]}" \
|
||||
"${PREVIEW_IMAGE}@${AMD64_DIGEST}" \
|
||||
"${PREVIEW_IMAGE}@${ARM64_DIGEST}"
|
||||
|
||||
- name: Verify preview manifest platforms
|
||||
if: "!env.ACT"
|
||||
run: |
|
||||
image="${PREVIEW_IMAGE}:${PREVIEW_TAG}"
|
||||
manifest="$(docker buildx imagetools inspect "$image" --raw)"
|
||||
for architecture in amd64 arm64; do
|
||||
jq --exit-status --arg architecture "$architecture" \
|
||||
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \
|
||||
<<<"$manifest" >/dev/null
|
||||
tags=("${PREVIEW_TAG}")
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/release/v2" ]]; then
|
||||
tags+=("pr-6545")
|
||||
fi
|
||||
for tag in "${tags[@]}"; do
|
||||
image="${PREVIEW_IMAGE}:${tag}"
|
||||
manifest="$(docker buildx imagetools inspect "$image" --raw)"
|
||||
for architecture in amd64 arm64; do
|
||||
jq --exit-status --arg architecture "$architecture" \
|
||||
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \
|
||||
<<<"$manifest" >/dev/null
|
||||
done
|
||||
docker buildx imagetools inspect "$image"
|
||||
done
|
||||
docker buildx imagetools inspect "$image"
|
||||
|
||||
cleanup:
|
||||
name: Clean up preview image
|
||||
|
||||
@@ -27,37 +27,103 @@ jobs:
|
||||
- run: pnpm test:workshop
|
||||
- run: pnpm test:workshop-image
|
||||
|
||||
publish:
|
||||
publish-amd64:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||
needs: validate
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/metadata-action@v6
|
||||
id: metadata
|
||||
with:
|
||||
images: ghcr.io/homarr-labs/workshop
|
||||
tags: |
|
||||
type=sha,prefix=sha-,format=long
|
||||
type=raw,value=v2,enable=${{ github.ref_name == 'v2' || github.ref_name == 'release/v2' }}
|
||||
type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
|
||||
- uses: docker/build-push-action@v7
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
file: apps/workshop/Dockerfile
|
||||
target: production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
outputs: type=image,"name=ghcr.io/homarr-labs/workshop",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=workshop-linux-amd64
|
||||
cache-to: type=gha,scope=workshop-linux-amd64,mode=max
|
||||
|
||||
publish-arm64:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||
needs: validate
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@v7
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
file: apps/workshop/Dockerfile
|
||||
target: production
|
||||
platforms: linux/arm64
|
||||
outputs: type=image,"name=ghcr.io/homarr-labs/workshop",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=workshop-linux-arm64
|
||||
cache-to: type=gha,scope=workshop-linux-arm64,mode=max
|
||||
|
||||
publish:
|
||||
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
|
||||
needs: [publish-amd64, publish-arm64]
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
env:
|
||||
WORKSHOP_IMAGE: ghcr.io/homarr-labs/workshop
|
||||
steps:
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Publish multi-platform image
|
||||
env:
|
||||
AMD64_DIGEST: ${{ needs.publish-amd64.outputs.digest }}
|
||||
ARM64_DIGEST: ${{ needs.publish-arm64.outputs.digest }}
|
||||
run: |
|
||||
tags=(-t "${WORKSHOP_IMAGE}:sha-${GITHUB_SHA}")
|
||||
if [[ "${GITHUB_REF_NAME}" == "v2" || "${GITHUB_REF_NAME}" == "release/v2" ]]; then
|
||||
tags+=(-t "${WORKSHOP_IMAGE}:v2")
|
||||
fi
|
||||
if [[ "${GITHUB_REF_NAME}" == "main" ]]; then
|
||||
tags+=(-t "${WORKSHOP_IMAGE}:latest")
|
||||
fi
|
||||
docker buildx imagetools create "${tags[@]}" \
|
||||
"${WORKSHOP_IMAGE}@${AMD64_DIGEST}" \
|
||||
"${WORKSHOP_IMAGE}@${ARM64_DIGEST}"
|
||||
- name: Verify multi-platform image
|
||||
run: |
|
||||
image="${WORKSHOP_IMAGE}:sha-${GITHUB_SHA}"
|
||||
manifest="$(docker buildx imagetools inspect "$image" --raw)"
|
||||
for architecture in amd64 arm64; do
|
||||
jq --exit-status --arg architecture "$architecture" \
|
||||
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \
|
||||
<<<"$manifest" >/dev/null
|
||||
done
|
||||
docker buildx imagetools inspect "$image"
|
||||
@@ -25,6 +25,8 @@ packages/db/migrations/*/migrate.cjs
|
||||
|
||||
# production
|
||||
build
|
||||
tools/homarr-dev/homarr
|
||||
tools/homarr-dev/homarr-dev
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
@@ -1,43 +1,175 @@
|
||||
# Developer Docker CLI
|
||||
|
||||
The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
|
||||
The host-side developer CLI builds and runs local Homarr images and pull-request previews with non-blocking background operations. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
|
||||
|
||||
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features.
|
||||
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication (`gh auth login`) is only needed for pull-request features.
|
||||
|
||||
## Browse development images
|
||||
## Requirements
|
||||
|
||||
- Docker or OrbStack
|
||||
- Go 1.25+ (when building the CLI from source)
|
||||
- GitHub CLI authenticated with `gh auth login` (for pull-request and CI features)
|
||||
- A [Nerd Font](https://www.nerdfonts.com/) enabled in your terminal (recommended for icons, status indicators, and TUI glyphs)
|
||||
|
||||
## Interactive Development Browser
|
||||
|
||||
```bash
|
||||
pnpm dev:cli --
|
||||
# or with global binary:
|
||||
homarr
|
||||
```
|
||||
|
||||
The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen.
|
||||
The main browser combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` (or `1`/`2`) to toggle between the image browser and the instance dashboard.
|
||||
|
||||
A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds.
|
||||
A locally built PR image is folded into its remote PR row only when its recorded revision matches the current GitHub head SHA (marked `◆ local`). When the matching GHCR image is also available, pressing `Enter` opens a local/remote selector with local preselected. Outdated local builds remain separate rows.
|
||||
|
||||
## Build images
|
||||
### Multi-Source Sidebar
|
||||
|
||||
Press `Tab` to cycle between sidebar tabs:
|
||||
|
||||
- **`[logs]`**: Live container output streams (press `f` to follow/pause, `L` to pin to the selected container, `Page Up`/`Page Down` to scroll).
|
||||
- **`[build]`**: Real-time BuildKit step streaming for active local builds.
|
||||
- **`[ci]`**: GitHub Actions CI check statuses, run duration, and workflow links for pull requests.
|
||||
|
||||
### Background Task Overlay
|
||||
|
||||
Pulls and builds run asynchronously in the background. Press `t` to open the tasks overlay, showing:
|
||||
|
||||
- Layer-by-layer download progress and transfer speeds (via Docker Engine API).
|
||||
- Active build stages and logs.
|
||||
- Task cancellation and status.
|
||||
|
||||
### Browser Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
| ---------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `/` | Filter rows by PR number, title, author, branch, CI, image, state, or port |
|
||||
| `↑` / `↓` or `j` / `k` | Navigate rows |
|
||||
| `Enter` or `Space` | Start the available image; choose local or remote when both exist; stop if running |
|
||||
| `R` | Build locally in background (or rebuild local image) |
|
||||
| `p` | Pull remote PR image in background and start container |
|
||||
| `m` | Manage mode (`b` rebuild, `i` delete image, `d` delete data, `c` remove container, `p` prune) |
|
||||
| `t` | Toggle background tasks overlay |
|
||||
| `Tab` | Cycle sidebar tabs (`[logs]`, `[build]`, `[ci]`) |
|
||||
| `l` / `L` | Toggle sidebar visibility / Pin logs to selected instance |
|
||||
| `f` | Follow or pause logs |
|
||||
| `M` | Toggle demo mode for next launch |
|
||||
| `b` | Toggle bot pull requests |
|
||||
| `o` / `a` | Open PR on GitHub / Open running instance in browser |
|
||||
| `c` | Copy instance URL to clipboard |
|
||||
| `r` | Refresh data |
|
||||
| `1` / `2` / `d` | Switch screen (development browser / instance dashboard) |
|
||||
| `?` / `q` | Help overlay / Quit (cancels active background tasks) |
|
||||
|
||||
## Instance Dashboard
|
||||
|
||||
```bash
|
||||
pnpm dev:cli -- build <name>
|
||||
pnpm dev:cli -- build --pr <number> [name]
|
||||
homarr dash
|
||||
# or
|
||||
pnpm dev:cli -- dash
|
||||
```
|
||||
|
||||
The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
|
||||
The instance dashboard displays all running and stopped Homarr containers, published ports, health states, and resource volumes.
|
||||
|
||||
## Rebuild images
|
||||
| Key | Action |
|
||||
| ------------------ | ---------------------------------------- |
|
||||
| `Enter` or `Space` | Start or stop selected instance |
|
||||
| `s` / `S` | Stop / Restart instance |
|
||||
| `x` | Remove container (preserves data volume) |
|
||||
| `m` | Manage mode for selected instance |
|
||||
| `R` | Rebuild from recorded build provenance |
|
||||
|
||||
## CLI Commands
|
||||
|
||||
The commands below use the installed `homarr` binary. Without global installation, prefix each command with `pnpm dev:cli --` (for example, `pnpm dev:cli -- list`).
|
||||
|
||||
### Launch Instances
|
||||
|
||||
```bash
|
||||
pnpm dev:cli -- rebuild <name>
|
||||
homarr run dev
|
||||
homarr run --detach dev
|
||||
homarr run --pr 6441
|
||||
homarr run --pr 6441 --demo
|
||||
homarr --pr 6441 --env WORKSHOP_WEB_URL=https://app-v2.preview.homarr.dev/
|
||||
```
|
||||
|
||||
Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout.
|
||||
- `--pr <number>`: Pull and run a PR image from GHCR (shorthand: `homarr --pr <number>`).
|
||||
- `--demo` (`-m`): Start with mock integrations and sample data.
|
||||
- `--detach`: Start container in background.
|
||||
- `--env` (`-e`): Pass additional environment variables (repeatable: `--env KEY=VALUE`).
|
||||
|
||||
## Run images
|
||||
### Build & Rebuild
|
||||
|
||||
```bash
|
||||
homarr --pr <number> --env HOMARR_WEBSITE_URL=https://app-v2.preview.homarr.dev/
|
||||
homarr run <name>
|
||||
homarr run --pr <number>
|
||||
# Build current checkout as homarr:<name>
|
||||
homarr build feature
|
||||
|
||||
# Build a pull request locally in a temporary checkout
|
||||
homarr build --pr 6441
|
||||
|
||||
# Rebuild an existing image from recorded provenance
|
||||
homarr rebuild feature
|
||||
```
|
||||
|
||||
The direct `homarr --pr <number>` form is shorthand for `homarr run --pr <number>`. Add `--detach` to run in the
|
||||
background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
|
||||
Images built with the CLI record their source checkout, revision SHA, and optional PR number. `homarr rebuild` reads this provenance to reconstruct the build without manual configuration.
|
||||
|
||||
### GitHub CI Checks
|
||||
|
||||
```bash
|
||||
# View CI checks for a specific PR
|
||||
homarr ci 6441
|
||||
|
||||
# View CI checks for the current branch
|
||||
homarr ci
|
||||
|
||||
# Watch CI checks until completion
|
||||
homarr ci 6441 --watch
|
||||
```
|
||||
|
||||
### Interactive Shell
|
||||
|
||||
```bash
|
||||
# Open interactive shell in running container
|
||||
homarr shell homarr-dev
|
||||
|
||||
# Run specific command in container
|
||||
homarr shell homarr-dev pnpm db:migration:sqlite:run
|
||||
```
|
||||
|
||||
### Image & Volume Management
|
||||
|
||||
```bash
|
||||
# List local images with build provenance (source, revision, PR)
|
||||
homarr images
|
||||
|
||||
# Delete local image
|
||||
homarr images rm feature
|
||||
|
||||
# List data volumes with owner PR/tag and in-use status
|
||||
homarr data
|
||||
|
||||
# Delete data volume (safely stops and removes owning container first)
|
||||
homarr data rm homarr_feature_data
|
||||
|
||||
# Remove stopped containers (prompts without --force)
|
||||
homarr prune --force
|
||||
```
|
||||
|
||||
### Instance Operations & Diagnostics
|
||||
|
||||
```bash
|
||||
# List instances in a script-friendly table
|
||||
homarr list
|
||||
|
||||
# Follow container logs
|
||||
homarr logs homarr-dev
|
||||
|
||||
# Stop, restart, remove, or open container in browser
|
||||
homarr stop homarr-dev
|
||||
homarr restart homarr-dev
|
||||
homarr remove homarr-dev
|
||||
homarr open homarr-dev
|
||||
|
||||
# Verify Docker daemon, Engine API, GHCR access, and gh CLI authentication
|
||||
homarr doctor
|
||||
```
|
||||
@@ -25,3 +25,27 @@ You should also create a backup of the database, in case you break something.
|
||||
4. Click "Execute" in the top right of the Window. 
|
||||
5. Select /bin/bash and click Execute 
|
||||
6. Run the Homarr command and press enter (eg. homarr reset-owner-password) 
|
||||
|
||||
## Build a local branch harness
|
||||
|
||||
The host-side operations CLI can build the current checkout and run it as a named, browser-ready Docker harness. This is separate from the recovery CLI described above and does not open the interactive developer TUI.
|
||||
|
||||
```sh
|
||||
pnpm cli harness setup feature-board
|
||||
```
|
||||
|
||||
The command builds `homarr:feature-board`, starts a container with a persistent data volume, enables demo mode, and keeps demo mutations writable (`DEMO_READ_ONLY=false`). Docker assigns a free host port by default; use the URL printed by the command or query it later:
|
||||
|
||||
```sh
|
||||
pnpm cli harness port feature-board
|
||||
```
|
||||
|
||||
Pass environment overrides by repeating `--env`:
|
||||
|
||||
```sh
|
||||
pnpm cli harness setup feature-board \
|
||||
--env WORKSHOP_WEB_URL=https://example.test \
|
||||
--env FEATURE_FLAG=true
|
||||
```
|
||||
|
||||
Other commands are `harness build <name>`, `harness run <name>`, and `harness stop <name>`. Use the returned URL with a browser automation harness for feature validation; do not assume the host port is `7575`.
|
||||
@@ -204,10 +204,13 @@ changes, Homarr asks whether to discard them. If the editor cannot load, the boa
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
|
||||
While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and
|
||||
Spotlight. It lists the available actions directly and can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
|
||||
current user and context allow it. Widget setup offers the main canvas, enabled rails, and existing Containers as
|
||||
destinations before the widget is created, so the common flow does not require a second move step.
|
||||
While edit mode is active, **Add board content** opens a menu for adding a widget, app, integration, or Container. The
|
||||
available actions respect the current user's permissions. Widget setup offers the main canvas, enabled rails, and existing
|
||||
Containers as destinations before the widget is created, so the common flow does not require a second move step.
|
||||
|
||||
Hold `Ctrl` on Windows or Linux, or `⌘` on macOS, while clicking board items to select several at once. The selection
|
||||
toolbar can move the complete selection to another canvas, rail, or Container, delete it with an undo option, or clear the
|
||||
selection. A move is applied only when every selected item fits in the destination.
|
||||
|
||||
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test,
|
||||
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
|
||||
@@ -227,11 +230,7 @@ During a pointer or touch interaction, Escape cancels the move or resize and res
|
||||
Invalid, outside, or otherwise forbidden drops are cancelled in the same way.
|
||||
The **Move / resize item** action in the edit menu remains available for precise numeric input.
|
||||
|
||||
<img
|
||||
src={keyboardTileEditingImage}
|
||||
alt="A focused board tile in keyboard editing mode"
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
<img src={keyboardTileEditingImage} alt="A focused board tile in keyboard editing mode" style={{ maxWidth: "100%" }} />
|
||||
|
||||
#### Preview
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
title: "Today"
|
||||
description: "Displays the current date and progress through the calendar."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
|
||||
|
||||
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||
import { todayWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={todayWidget} categories={["Date", "Time"]} />
|
||||
|
||||
The Today widget presents the current local date without duplicating a live clock. It progressively adds the week
|
||||
number, day of year, quarter, remaining days, and calendar progress as more space becomes available.
|
||||
|
||||
The advanced view shows week, month, and year progress together with a twelve-month overview. Week numbers can follow
|
||||
your locale or the ISO 8601 convention. “Days after today” excludes the current day to keep the remaining-day count
|
||||
unambiguous.
|
||||
|
||||
The widget runs entirely in your browser and does not send date or location data to an external service. It follows
|
||||
the timezone and locale of the device viewing the board, so viewers in different regions can see their own local date.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={todayWidget.configuration} />
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { WidgetDefinition } from "@site/src/types";
|
||||
import { IconCalendarWeek } from "@tabler/icons-react";
|
||||
|
||||
export const todayWidget: WidgetDefinition = {
|
||||
icon: IconCalendarWeek,
|
||||
name: "Today",
|
||||
description: "Displays the current date and progress through the calendar.",
|
||||
path: "../../widgets/today",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Week numbering",
|
||||
description: "Use locale-specific week numbering or ISO 8601 week numbers",
|
||||
values: { type: "select", options: ["Use locale rules", "ISO 8601"] },
|
||||
defaultValue: "Use locale rules",
|
||||
},
|
||||
{
|
||||
name: "Show week number",
|
||||
description: "Display the current week number when the widget has room",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show day of year",
|
||||
description: "Display the current day number within the year",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show quarter",
|
||||
description: "Display the current calendar quarter",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show year progress",
|
||||
description: "Display progress through the current year",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
-1
@@ -2106,7 +2106,6 @@
|
||||
"enum": [
|
||||
"clock",
|
||||
"weather",
|
||||
"today",
|
||||
"airQuality",
|
||||
"countdown",
|
||||
"timer",
|
||||
|
||||
@@ -23,6 +23,8 @@ import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-
|
||||
import { BoardGridPortalHost, BoardGridPortalRenderer } from "~/components/board/sections/grid/grid-portal-host";
|
||||
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
|
||||
import { BoardBackgroundVideo } from "~/components/layout/background";
|
||||
import { BoardSelectionProvider } from "~/components/board/selection/board-selection-context";
|
||||
import { BoardSelectionToolbar } from "~/components/board/selection/board-selection-toolbar";
|
||||
import classes from "./_client.module.css";
|
||||
|
||||
const APP_SHELL_INLINE_PADDING = 32;
|
||||
@@ -63,71 +65,74 @@ export const ClientBoard = () => {
|
||||
.join(" ");
|
||||
|
||||
const content = (
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
<BoardEmptyState />
|
||||
<BoardSetupChecklist />
|
||||
<BoardSectionCollapseProvider>
|
||||
<BoardGridPortalHost>
|
||||
<ScaledBoardCanvas
|
||||
logicalWidth={logicalWidth}
|
||||
initialLogicalHeight={initialLogicalHeight}
|
||||
initialAvailableWidth={initialAvailableWidth}
|
||||
label={board.name}
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<BoardGridPortalRenderer />
|
||||
<div className={classes.columns} style={{ gridTemplateColumns }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("leftRail")}
|
||||
data-board-gutter="left"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${leftSection.id}`}
|
||||
section={leftSection}
|
||||
columnCount={leftColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="left"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
<BoardSelectionProvider>
|
||||
<BoardAdvancedFocusProvider>
|
||||
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
|
||||
<BoardBackgroundVideo />
|
||||
<BoardEmptyState />
|
||||
<BoardSetupChecklist />
|
||||
<BoardSectionCollapseProvider>
|
||||
<BoardGridPortalHost>
|
||||
<ScaledBoardCanvas
|
||||
logicalWidth={logicalWidth}
|
||||
initialLogicalHeight={initialLogicalHeight}
|
||||
initialAvailableWidth={initialAvailableWidth}
|
||||
label={board.name}
|
||||
>
|
||||
<BoardGridEditorBoundary key={currentLayoutId}>
|
||||
<BoardGridPortalRenderer />
|
||||
<div className={classes.columns} style={{ gridTemplateColumns }}>
|
||||
{leftColumnCount > 0 && leftSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("leftRail")}
|
||||
data-board-gutter="left"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${leftSection.id}`}
|
||||
section={leftSection}
|
||||
columnCount={leftColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="left"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<section className={classes.lane} aria-label={t("canvas")}>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${mainSection.id}`}
|
||||
section={mainSection}
|
||||
columnCount={mainColumnCount}
|
||||
requestedRowCount={0}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{rightColumnCount > 0 && rightSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("rightRail")}
|
||||
data-board-gutter="right"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<section className={classes.lane} aria-label={t("canvas")}>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${rightSection.id}`}
|
||||
section={rightSection}
|
||||
columnCount={rightColumnCount}
|
||||
key={`${currentLayoutId}-${mainSection.id}`}
|
||||
section={mainSection}
|
||||
columnCount={mainColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="right"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</BoardGridEditorBoundary>
|
||||
</ScaledBoardCanvas>
|
||||
</BoardGridPortalHost>
|
||||
</BoardSectionCollapseProvider>
|
||||
</Box>
|
||||
</BoardAdvancedFocusProvider>
|
||||
</section>
|
||||
|
||||
{rightColumnCount > 0 && rightSection && (
|
||||
<aside
|
||||
className={`${classes.lane} ${classes.gutter}`}
|
||||
aria-label={t("rightRail")}
|
||||
data-board-gutter="right"
|
||||
data-board-editing={isEditMode ? "true" : undefined}
|
||||
>
|
||||
<BoardEmptySection
|
||||
key={`${currentLayoutId}-${rightSection.id}`}
|
||||
section={rightSection}
|
||||
columnCount={rightColumnCount}
|
||||
requestedRowCount={0}
|
||||
railPlacement="right"
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</BoardGridEditorBoundary>
|
||||
</ScaledBoardCanvas>
|
||||
</BoardGridPortalHost>
|
||||
</BoardSectionCollapseProvider>
|
||||
<BoardSelectionToolbar />
|
||||
</Box>
|
||||
</BoardAdvancedFocusProvider>
|
||||
</BoardSelectionProvider>
|
||||
);
|
||||
|
||||
if (representativeWidth === null) return content;
|
||||
|
||||
@@ -2,14 +2,19 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { Group, Menu } from "@mantine/core";
|
||||
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { AppSelectModal } from "@homarr/modals-collection";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useBoardAddActions } from "~/components/board/use-board-add-actions";
|
||||
import { UniversalCreateModal } from "~/components/create/universal-create-modal";
|
||||
import { useItemActions } from "~/components/board/items/item-actions";
|
||||
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
|
||||
import { useContainerActions } from "~/components/board/sections/container/container-actions";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export default function BoardEditActions() {
|
||||
@@ -17,9 +22,13 @@ export default function BoardEditActions() {
|
||||
}
|
||||
|
||||
const AddMenu = () => {
|
||||
const { data: session } = useSession();
|
||||
const board = useRequiredBoard();
|
||||
const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
|
||||
const { openModal } = useModalAction(UniversalCreateModal);
|
||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
|
||||
const { addContainer } = useContainerActions();
|
||||
const { createItem } = useItemActions();
|
||||
const t = useI18n();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
@@ -29,29 +38,69 @@ const AddMenu = () => {
|
||||
useEffect(() => {
|
||||
if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
|
||||
handledInitialAdd.current = true;
|
||||
addWidget();
|
||||
openItemSelectModal({ boardId: board.id });
|
||||
|
||||
const nextSearchParams = new URLSearchParams(searchParams.toString());
|
||||
nextSearchParams.delete("add");
|
||||
const query = nextSearchParams.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
||||
}, [addWidget, board.id, pathname, router, searchParams]);
|
||||
}, [board.id, openItemSelectModal, pathname, router, searchParams]);
|
||||
|
||||
const handleSelectItem = () => {
|
||||
openItemSelectModal({ boardId: board.id });
|
||||
};
|
||||
|
||||
const handleSelectApp = () => {
|
||||
openAppSelectModal({
|
||||
onSelect: (app) => {
|
||||
createItem({
|
||||
kind: "app",
|
||||
options: { appId: app.id },
|
||||
});
|
||||
},
|
||||
onSelectMany: (apps) => {
|
||||
for (const app of apps) {
|
||||
createItem({
|
||||
kind: "app",
|
||||
options: { appId: app.id },
|
||||
});
|
||||
}
|
||||
},
|
||||
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddIntegration = () => {
|
||||
openIntegrationSelectModal({ completionBoardId: board.id });
|
||||
};
|
||||
|
||||
return (
|
||||
<HeaderButton
|
||||
aria-label={t("board.action.addContent")}
|
||||
onClick={() =>
|
||||
openModal({
|
||||
boardActions: {
|
||||
widget: addWidget,
|
||||
app: addApp,
|
||||
integration: canConnectService ? connectService : undefined,
|
||||
container: addContainer,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<IconPlus stroke={1.5} />
|
||||
</HeaderButton>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<HeaderButton w="auto" px={4} aria-label={t("board.action.addContent")}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<IconPlus stroke={1.5} />
|
||||
<IconChevronDown color="gray" size={16} />
|
||||
</Group>
|
||||
</HeaderButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
|
||||
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
|
||||
{t("item.action.create")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
|
||||
{t("app.action.add")}
|
||||
</Menu.Item>
|
||||
{session?.user.permissions.includes("integration-create") && (
|
||||
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
|
||||
{t("integration.action.create")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
|
||||
{t("section.container.action.create")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Procedural UI sounds for Homarr using the Web Audio API.
|
||||
* Zero audio files shipped. Pure runtime synthesis with oscillators, noise buffers, and gain envelopes.
|
||||
*/
|
||||
|
||||
let audioContext: AudioContext | null = null;
|
||||
|
||||
const getAudioContext = (): AudioContext | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
try {
|
||||
if (!audioContext) {
|
||||
const AudioContextClass =
|
||||
window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
|
||||
if (AudioContextClass) audioContext = new AudioContextClass();
|
||||
}
|
||||
if (audioContext?.state === "suspended") void audioContext.resume().catch(() => undefined);
|
||||
return audioContext;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a white noise audio buffer for percussive textures
|
||||
*/
|
||||
const createNoiseBuffer = (ctx: AudioContext, durationSeconds = 0.2): AudioBuffer => {
|
||||
const bufferSize = Math.floor(ctx.sampleRate * durationSeconds);
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < bufferSize; i++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
return buffer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Procedural trash / delete crunch sound.
|
||||
* Synthesizes a crunchy bin/paper crumple noise burst and downward pitch thud.
|
||||
*/
|
||||
export const playTrashSound = (volume = 0.35) => {
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx) return;
|
||||
|
||||
try {
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// Master Gain
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.gain.setValueAtTime(volume, now);
|
||||
masterGain.connect(ctx.destination);
|
||||
|
||||
// 1. Downward sweep sub-oscillator (physical thud)
|
||||
const osc = ctx.createOscillator();
|
||||
const oscGain = ctx.createGain();
|
||||
osc.type = "triangle";
|
||||
osc.frequency.setValueAtTime(260, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(35, now + 0.18);
|
||||
oscGain.gain.setValueAtTime(0.7, now);
|
||||
oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.18);
|
||||
osc.connect(oscGain);
|
||||
oscGain.connect(masterGain);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.2);
|
||||
|
||||
// 2. High-crunch noise burst (crush / crumple texture)
|
||||
try {
|
||||
const noiseBuffer = createNoiseBuffer(ctx, 0.22);
|
||||
const noiseSource = ctx.createBufferSource();
|
||||
noiseSource.buffer = noiseBuffer;
|
||||
|
||||
const noiseFilter = ctx.createBiquadFilter();
|
||||
noiseFilter.type = "bandpass";
|
||||
noiseFilter.frequency.setValueAtTime(1400, now);
|
||||
noiseFilter.frequency.exponentialRampToValueAtTime(320, now + 0.2);
|
||||
noiseFilter.Q.setValueAtTime(2.2, now);
|
||||
|
||||
const noiseGain = ctx.createGain();
|
||||
noiseGain.gain.setValueAtTime(0.9, now);
|
||||
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.2);
|
||||
|
||||
noiseSource.connect(noiseFilter);
|
||||
noiseFilter.connect(noiseGain);
|
||||
noiseGain.connect(masterGain);
|
||||
|
||||
noiseSource.start(now);
|
||||
noiseSource.stop(now + 0.22);
|
||||
} catch {
|
||||
// The noise layer is optional; retain the oscillator when buffers are constrained.
|
||||
}
|
||||
} catch {
|
||||
// Sound feedback must never interrupt deleting board items.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Procedural select / pop sound.
|
||||
*/
|
||||
export const playPopSound = (volume = 0.25) => {
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx) return;
|
||||
|
||||
try {
|
||||
const now = ctx.currentTime;
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.type = "sine";
|
||||
osc.frequency.setValueAtTime(460, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(780, now + 0.05);
|
||||
|
||||
gain.gain.setValueAtTime(volume, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.07);
|
||||
} catch {
|
||||
// Sound feedback must never interrupt selecting board items.
|
||||
}
|
||||
};
|
||||
@@ -11,6 +11,19 @@
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.settingsButton {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
:global([data-type="item"]:hover) .settingsButton,
|
||||
:global([data-type="item"]:focus-within) .settingsButton,
|
||||
.settingsButton[data-menu-open="true"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.editModeInertContent {
|
||||
display: contents;
|
||||
}
|
||||
@@ -39,3 +52,9 @@
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.settingsButton {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { BoardRemoveConfirmationMenuItem } from "../remove-confirmation-menu-ite
|
||||
import { useSectionContext } from "../sections/section-context";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import { useOpenItemMoveModal } from "./item-move-modal";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
|
||||
|
||||
interface BoardItemMenuProps {
|
||||
@@ -105,9 +106,11 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
|
||||
size={24}
|
||||
radius="sm"
|
||||
pos="absolute"
|
||||
top={12}
|
||||
right={8}
|
||||
top={4}
|
||||
right={4}
|
||||
style={{ zIndex: 30 }}
|
||||
className={itemContentClasses.settingsButton}
|
||||
data-menu-open={isMenuOpen || undefined}
|
||||
data-board-widget-settings
|
||||
aria-label={tItem("menu.label.settingsFor", { name: label })}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import {
|
||||
getWidgetConnectionStatus,
|
||||
resolveMatchingIntegrationsAsync,
|
||||
@@ -8,95 +10,254 @@ import {
|
||||
} from "./item-select-data";
|
||||
|
||||
describe("getWidgetConnectionStatus", () => {
|
||||
const availableKinds = new Set(["sonarr" as const]);
|
||||
const availableKinds = new Set<IntegrationKind>(["sonarr", "plex"]);
|
||||
|
||||
it("reports widgets with a configured compatible integration as ready", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["sonarr", "radarr"],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("ready");
|
||||
describe("ready status", () => {
|
||||
it("returns 'ready' when at least one supported integration is in availableKinds", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["sonarr", "radarr"],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("ready");
|
||||
});
|
||||
|
||||
it("returns 'ready' when all supported integrations are in availableKinds", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["sonarr", "plex"],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("ready");
|
||||
});
|
||||
|
||||
it("returns 'ready' even if connectionOptional is true as long as a matching integration exists", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["sonarr"],
|
||||
availableKinds,
|
||||
connectionOptional: true,
|
||||
}),
|
||||
).toBe("ready");
|
||||
});
|
||||
});
|
||||
|
||||
it("reports required integrations that are not configured as needing setup", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["radarr"],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("needsSetup");
|
||||
describe("needsSetup status", () => {
|
||||
it("returns 'needsSetup' when supported integrations are non-empty, none are available, and connectionOptional is false", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["radarr", "sabNzbd"],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("needsSetup");
|
||||
});
|
||||
|
||||
it("returns 'needsSetup' when availableKinds is empty and connection is required", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["sonarr"],
|
||||
availableKinds: new Set<IntegrationKind>(),
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("needsSetup");
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ supportedIntegrations: [], connectionOptional: false },
|
||||
{ supportedIntegrations: ["radarr" as const], connectionOptional: true },
|
||||
])("reports a widget without a required connection", ({ supportedIntegrations, connectionOptional }) => {
|
||||
expect(getWidgetConnectionStatus({ supportedIntegrations, availableKinds, connectionOptional })).toBe(
|
||||
"noConnectionRequired",
|
||||
);
|
||||
describe("noConnectionRequired status", () => {
|
||||
it("returns 'noConnectionRequired' when supportedIntegrations is empty regardless of connectionOptional", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: [],
|
||||
availableKinds,
|
||||
connectionOptional: false,
|
||||
}),
|
||||
).toBe("noConnectionRequired");
|
||||
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: [],
|
||||
availableKinds: new Set(),
|
||||
connectionOptional: true,
|
||||
}),
|
||||
).toBe("noConnectionRequired");
|
||||
});
|
||||
|
||||
it("returns 'noConnectionRequired' when connectionOptional is true and no supported integrations are available", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["radarr", "jellyfin"],
|
||||
availableKinds,
|
||||
connectionOptional: true,
|
||||
}),
|
||||
).toBe("noConnectionRequired");
|
||||
});
|
||||
|
||||
it("returns 'noConnectionRequired' when connectionOptional is true and availableKinds is empty", () => {
|
||||
expect(
|
||||
getWidgetConnectionStatus({
|
||||
supportedIntegrations: ["sonarr"],
|
||||
availableKinds: new Set(),
|
||||
connectionOptional: true,
|
||||
}),
|
||||
).toBe("noConnectionRequired");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("widget selection lock", () => {
|
||||
it("rejects repeated selection synchronously until the active selection finishes", () => {
|
||||
describe("selection concurrency locks", () => {
|
||||
it("acquires lock on unlocked ref and sets current to true", () => {
|
||||
const lock = { current: false };
|
||||
|
||||
const acquired = tryLockSelection(lock);
|
||||
expect(acquired).toBe(true);
|
||||
expect(lock.current).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects lock acquisition on an already locked ref", () => {
|
||||
const lock = { current: true };
|
||||
|
||||
const acquired = tryLockSelection(lock);
|
||||
expect(acquired).toBe(false);
|
||||
expect(lock.current).toBe(true);
|
||||
});
|
||||
|
||||
it("unlocks lock and allows subsequent acquisition", () => {
|
||||
const lock = { current: false };
|
||||
|
||||
expect(tryLockSelection(lock)).toBe(true);
|
||||
expect(tryLockSelection(lock)).toBe(false);
|
||||
|
||||
unlockSelection(lock);
|
||||
expect(lock.current).toBe(false);
|
||||
|
||||
expect(tryLockSelection(lock)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles repeated unlock safely without throwing", () => {
|
||||
const lock = { current: false };
|
||||
expect(() => {
|
||||
unlockSelection(lock);
|
||||
unlockSelection(lock);
|
||||
}).not.toThrow();
|
||||
expect(lock.current).toBe(false);
|
||||
});
|
||||
|
||||
it("prevents multiple concurrent callers from proceeding simultaneously", () => {
|
||||
const lock = { current: false };
|
||||
const results: boolean[] = [];
|
||||
|
||||
// Simulate 5 simultaneous selection attempts
|
||||
for (let i = 0; i < 5; i++) {
|
||||
results.push(tryLockSelection(lock));
|
||||
}
|
||||
|
||||
expect(results).toEqual([true, false, false, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMatchingIntegrationsAsync", () => {
|
||||
it("waits for the shared integration query before a cold integration-backed selection", async () => {
|
||||
const ensureDataAsync = vi.fn(async () => [
|
||||
{ id: "matching", kind: "sonarr" as const },
|
||||
{ id: "other", kind: "radarr" as const },
|
||||
]);
|
||||
it("returns empty array immediately when widget does not support integrations without calling ensureDataAsync", async () => {
|
||||
const ensureDataAsync = vi.fn(async () => [{ id: "1", kind: "sonarr" as IntegrationKind }]);
|
||||
|
||||
await expect(
|
||||
resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr"],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
}),
|
||||
).resolves.toEqual([{ id: "matching", kind: "sonarr" }]);
|
||||
expect(ensureDataAsync).toHaveBeenCalledOnce();
|
||||
});
|
||||
const result = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: false,
|
||||
supportedIntegrations: ["sonarr"],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
});
|
||||
|
||||
it("does not wait for integrations when the widget has no integration support", async () => {
|
||||
const ensureDataAsync = vi.fn(async () => [{ id: "unused", kind: "sonarr" as const }]);
|
||||
|
||||
await expect(
|
||||
resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: false,
|
||||
supportedIntegrations: [],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
expect(result).toEqual([]);
|
||||
expect(ensureDataAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("excludes matching integrations the user cannot use", async () => {
|
||||
it("uses currentData synchronously when available without calling ensureDataAsync", async () => {
|
||||
const currentData = [
|
||||
{ id: "allowed", kind: "sonarr" as const, permissions: { hasUseAccess: true } },
|
||||
{ id: "blocked", kind: "sonarr" as const, permissions: { hasUseAccess: false } },
|
||||
{ id: "1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: true } },
|
||||
{ id: "2", kind: "radarr" as IntegrationKind, permissions: { hasUseAccess: true } },
|
||||
{ id: "3", kind: "plex" as IntegrationKind, permissions: { hasUseAccess: true } },
|
||||
];
|
||||
const ensureDataAsync = vi.fn();
|
||||
|
||||
const result = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr", "radarr"],
|
||||
currentData,
|
||||
ensureDataAsync,
|
||||
});
|
||||
|
||||
expect(result).toEqual([currentData[0], currentData[1]]);
|
||||
expect(ensureDataAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls ensureDataAsync when currentData is undefined and filters by supported kinds", async () => {
|
||||
const fetchedData = [
|
||||
{ id: "1", kind: "sonarr" as IntegrationKind },
|
||||
{ id: "2", kind: "radarr" as IntegrationKind },
|
||||
{ id: "3", kind: "sabNzbd" as IntegrationKind },
|
||||
];
|
||||
const ensureDataAsync = vi.fn(async () => fetchedData);
|
||||
|
||||
const result = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["radarr", "sabNzbd"],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
});
|
||||
|
||||
expect(result).toEqual([fetchedData[1], fetchedData[2]]);
|
||||
expect(ensureDataAsync).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("excludes integrations where permissions.hasUseAccess is explicitly false", async () => {
|
||||
const currentData = [
|
||||
{ id: "allowed-1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: true } },
|
||||
{ id: "blocked-1", kind: "sonarr" as IntegrationKind, permissions: { hasUseAccess: false } },
|
||||
{ id: "allowed-no-perms", kind: "sonarr" as IntegrationKind },
|
||||
];
|
||||
|
||||
const result = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr"],
|
||||
currentData,
|
||||
ensureDataAsync: vi.fn(),
|
||||
});
|
||||
|
||||
expect(result).toEqual([currentData[0], currentData[2]]);
|
||||
});
|
||||
|
||||
it("returns empty array when none of the fetched integrations match supported kinds", async () => {
|
||||
const fetchedData = [
|
||||
{ id: "1", kind: "plex" as IntegrationKind },
|
||||
{ id: "2", kind: "jellyfin" as IntegrationKind },
|
||||
];
|
||||
const ensureDataAsync = vi.fn(async () => fetchedData);
|
||||
|
||||
const result = await resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr", "radarr"],
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(ensureDataAsync).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("propagates error when ensureDataAsync rejects", async () => {
|
||||
const ensureDataAsync = vi.fn(async () => {
|
||||
throw new Error("Network timeout");
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveMatchingIntegrationsAsync({
|
||||
hasIntegrationSupport: true,
|
||||
supportedIntegrations: ["sonarr"],
|
||||
currentData,
|
||||
ensureDataAsync: vi.fn(),
|
||||
currentData: undefined,
|
||||
ensureDataAsync,
|
||||
}),
|
||||
).resolves.toEqual([currentData[0]]);
|
||||
).rejects.toThrow("Network timeout");
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
.action {
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
.card:hover .action,
|
||||
.card:focus-within .action {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.action {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.action {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import { ConnectionStatusBadge, SupportedIntegrations, WidgetItem } from "./item-select-modal";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"item.create.connectionStatus.ready": "Ready",
|
||||
"item.create.connectionStatus.needsSetup": "Needs setup",
|
||||
"item.create.connectionStatus.noConnectionRequired": "No connection required",
|
||||
"item.create.standalone": "Standalone",
|
||||
"widget.mediaServer.name": "Media Server",
|
||||
"widget.mediaServer.description": "Stream your media",
|
||||
"widget.clock.name": "Clock",
|
||||
"widget.clock.description": "Displays current time",
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
},
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/definitions", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("@homarr/definitions");
|
||||
return {
|
||||
...actual,
|
||||
getIntegrationName: (kind: string) => kind.charAt(0).toUpperCase() + kind.slice(1),
|
||||
};
|
||||
});
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
describe("Advanced Add App - Modal Subcomponents", () => {
|
||||
describe("ConnectionStatusBadge", () => {
|
||||
it("renders 'Ready' badge with teal color when status is 'ready'", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<ConnectionStatusBadge status="ready" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Ready");
|
||||
const badge = host.querySelector("span, div");
|
||||
expect(badge).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders 'Needs setup' badge with orange color when status is 'needsSetup'", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<ConnectionStatusBadge status="needsSetup" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Needs setup");
|
||||
});
|
||||
|
||||
it("renders null when status is 'noConnectionRequired'", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<ConnectionStatusBadge status="noConnectionRequired" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("[class*='Badge-root']")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SupportedIntegrations", () => {
|
||||
it("renders up to 5 avatars for supported integrations", async () => {
|
||||
const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex"];
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SupportedIntegrations integrations={integrations} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// Avatars should be rendered for the 3 items
|
||||
expect(host.querySelectorAll("img, svg, div").length).toBeGreaterThan(0);
|
||||
expect(host.textContent).not.toContain("+");
|
||||
});
|
||||
|
||||
it("renders overflow indicator when 6 or more integrations are supported", async () => {
|
||||
const integrations: IntegrationKind[] = ["sonarr", "radarr", "plex", "jellyfin", "emby", "deluge"];
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SupportedIntegrations integrations={integrations} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// 6 integrations total: 4 shown, +2 in badge
|
||||
expect(host.textContent).toContain("+2");
|
||||
});
|
||||
|
||||
it("renders Standalone label when integrations list is empty", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SupportedIntegrations integrations={[]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Standalone");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WidgetItem", () => {
|
||||
const FakeIcon = () => <span data-testid="widget-icon">Icon</span>;
|
||||
|
||||
it("renders widget card with icon, title, description, and connection status", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const item = {
|
||||
kind: "mediaServer" as WidgetKind,
|
||||
name: "Media Server",
|
||||
description: "Stream your media collection effortlessly",
|
||||
icon: FakeIcon as never,
|
||||
supportedIntegrations: ["plex" as IntegrationKind, "jellyfin" as IntegrationKind],
|
||||
};
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<WidgetItem item={item} disabled={false} loading={false} connectionStatus="ready" onSelect={onSelect} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
expect(host.textContent).toContain("Media Server");
|
||||
expect(host.textContent).toContain("Stream your media collection effortlessly");
|
||||
expect(host.textContent).toContain("Ready");
|
||||
expect(host.querySelector("[data-testid='widget-icon']")).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
button?.click();
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not mark widget cards as selected", async () => {
|
||||
const item = {
|
||||
kind: "clock" as WidgetKind,
|
||||
name: "Clock",
|
||||
description: "Displays current time",
|
||||
icon: FakeIcon as never,
|
||||
supportedIntegrations: [] as IntegrationKind[],
|
||||
};
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<WidgetItem
|
||||
item={item}
|
||||
disabled={false}
|
||||
loading={false}
|
||||
connectionStatus="noConnectionRequired"
|
||||
onSelect={vi.fn()}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.hasAttribute("data-selected")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
@@ -6,16 +6,18 @@ import {
|
||||
Button,
|
||||
Center,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
Image,
|
||||
Input,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
|
||||
import { IconApi, IconBuildingStore, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
@@ -23,8 +25,8 @@ import { useSession } from "@homarr/auth/client";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import {
|
||||
getIconUrl,
|
||||
getBoardLaneColumnCount,
|
||||
getIconUrl,
|
||||
getIntegrationName,
|
||||
getRootSectionLane,
|
||||
widgetIntegrationSupport,
|
||||
@@ -36,14 +38,14 @@ import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
import { SelectableCard } from "@homarr/ui";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useSetupAnalytics } from "~/components/create/setup-analytics";
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import type { WidgetConnectionStatus } from "./item-select-data";
|
||||
import {
|
||||
@@ -64,6 +66,16 @@ interface ItemSelectModalContentProps {
|
||||
initialSearch?: string;
|
||||
}
|
||||
|
||||
export interface WidgetItemData {
|
||||
kind: WidgetKind;
|
||||
supportedIntegrations: IntegrationKind[];
|
||||
icon: TablerIcon;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const selectGridCols = { base: 1, xs: 2, sm: 2, md: 3, lg: 4 };
|
||||
|
||||
const ItemSelectModalContent = ({
|
||||
actions,
|
||||
integrationData,
|
||||
@@ -108,15 +120,14 @@ const ItemSelectModalContent = ({
|
||||
[board.sections, currentLayout, mainCanvasSection, t],
|
||||
);
|
||||
const [targetSectionId, setTargetSectionId] = useState<string | null>(placementOptions[0]?.value ?? null);
|
||||
const effectiveSectionId = placementOptions.some(({ value }) => value === targetSectionId)
|
||||
? targetSectionId
|
||||
: (placementOptions[0]?.value ?? null);
|
||||
|
||||
const [loadingSelection, setLoadingSelection] = useState<string | null>(null);
|
||||
const selectionLock = useRef(false);
|
||||
const { createItem, removeItem } = useItemActions();
|
||||
|
||||
useEffect(() => {
|
||||
if (placementOptions.some(({ value }) => value === targetSectionId)) return;
|
||||
setTargetSectionId(placementOptions[0]?.value ?? null);
|
||||
}, [placementOptions, targetSectionId]);
|
||||
|
||||
const { openModal: openEditModal } = useModalAction(LazyWidgetEditModal);
|
||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||
const settings = useSettings();
|
||||
@@ -128,7 +139,7 @@ const ItemSelectModalContent = ({
|
||||
[integrationData],
|
||||
);
|
||||
|
||||
const items = useMemo(
|
||||
const items = useMemo<WidgetItemData[]>(
|
||||
() =>
|
||||
widgetKinds
|
||||
.filter((kind) => kind !== "customApi")
|
||||
@@ -188,7 +199,7 @@ const ItemSelectModalContent = ({
|
||||
createdItem.layouts.find(({ layoutId }) => layoutId === currentLayoutId)?.sectionId ??
|
||||
createdItem.layouts[0]?.sectionId;
|
||||
const actualDestination = placementOptions.find(({ value }) => value === actualSectionId)?.label;
|
||||
const wasRelocated = targetSectionId !== null && actualSectionId !== targetSectionId && actualDestination;
|
||||
const wasRelocated = effectiveSectionId !== null && actualSectionId !== effectiveSectionId && actualDestination;
|
||||
const notificationId = `board-item-created:${itemId}`;
|
||||
showSuccessNotification({
|
||||
id: notificationId,
|
||||
@@ -247,7 +258,7 @@ const ItemSelectModalContent = ({
|
||||
kind: "customApi",
|
||||
integrationIds: [],
|
||||
options: configuredOptions,
|
||||
targetSectionId: targetSectionId ?? undefined,
|
||||
targetSectionId: effectiveSectionId ?? undefined,
|
||||
advancedOptions,
|
||||
});
|
||||
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
|
||||
@@ -310,7 +321,7 @@ const ItemSelectModalContent = ({
|
||||
kind,
|
||||
options,
|
||||
integrationIds: newIntegrationIds,
|
||||
targetSectionId: targetSectionId ?? undefined,
|
||||
targetSectionId: effectiveSectionId ?? undefined,
|
||||
advancedOptions,
|
||||
});
|
||||
if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return;
|
||||
@@ -377,125 +388,233 @@ const ItemSelectModalContent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex gap="md" align="stretch">
|
||||
<Box style={{ flex: "1 1 0%", minWidth: 0 }}>
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
ariaLabel={t("item.create.search")}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && loadingSelection === null && filteredItems.length === 1) {
|
||||
const [item] = filteredItems;
|
||||
if (item) void handleAdd(item.kind);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{placementOptions.length > 1 && (
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
<Select
|
||||
label={t("item.create.destination.label")}
|
||||
description={t("item.create.destination.description")}
|
||||
data={placementOptions}
|
||||
value={targetSectionId}
|
||||
onChange={setTargetSectionId}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{filteredItems.map((item) => (
|
||||
<WidgetItem
|
||||
key={item.kind}
|
||||
item={item}
|
||||
onSelect={() => void handleAdd(item.kind)}
|
||||
onFocus={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === item.kind}
|
||||
connectionStatus={getWidgetConnectionStatus({
|
||||
supportedIntegrations: item.supportedIntegrations,
|
||||
availableKinds,
|
||||
connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
|
||||
})}
|
||||
<Stack gap="md">
|
||||
{/* Top Search & Destination Selector */}
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="sm">
|
||||
<Box style={{ flex: "1 1 240px", minWidth: 200 }}>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={`${t("item.create.search")}...`}
|
||||
aria-label={t("item.create.search")}
|
||||
data-autofocus
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
loadingSelection === null &&
|
||||
filteredItems.length === 1 &&
|
||||
filteredItems[0]
|
||||
) {
|
||||
void handleAdd(filteredItems[0].kind);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{placementOptions.length > 1 && (
|
||||
<Box style={{ flex: "0 1 280px", minWidth: 200 }}>
|
||||
<Select
|
||||
label={t("item.create.destination.label")}
|
||||
data={placementOptions}
|
||||
value={effectiveSectionId}
|
||||
onChange={setTargetSectionId}
|
||||
allowDeselect={false}
|
||||
size="xs"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider
|
||||
label={t("customWidget.page.list.title")}
|
||||
labelPosition="center"
|
||||
my="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
{/* Grid of Widgets */}
|
||||
<SimpleGrid cols={selectGridCols} spacing="sm">
|
||||
{filteredItems.map((item) => (
|
||||
<WidgetItem
|
||||
key={item.kind}
|
||||
item={item}
|
||||
onSelect={() => void handleAdd(item.kind)}
|
||||
onFocus={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
void loadWidgetDefinition(item.kind).catch(() => undefined);
|
||||
preloadWidgetEditModal();
|
||||
}}
|
||||
disabled={loadingSelection !== null}
|
||||
loading={loadingSelection === item.kind}
|
||||
connectionStatus={getWidgetConnectionStatus({
|
||||
supportedIntegrations: item.supportedIntegrations,
|
||||
availableKinds,
|
||||
connectionOptional: widgetKindsWithOptionalIntegrations.has(item.kind),
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<IconApi size={14} />
|
||||
<Text size="xs" fw={600}>
|
||||
{t("customWidget.page.list.title")}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
labelPosition="center"
|
||||
my="sm"
|
||||
style={{ gridColumn: "1 / -1" }}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<SelectableCard
|
||||
key={def.id}
|
||||
onClick={() => void handleAddCustomWidget(def)}
|
||||
aria-label={def.name}
|
||||
icon={
|
||||
def.iconUrl ? (
|
||||
<Image src={def.iconUrl} w={24} h={24} fit="contain" style={{ flexShrink: 0 }} />
|
||||
) : (
|
||||
<ThemeIcon size={34} radius="md" variant="light" color="primaryColor">
|
||||
<IconApi size={20} />
|
||||
</ThemeIcon>
|
||||
)
|
||||
}
|
||||
title={def.name}
|
||||
description={def.description ?? ""}
|
||||
footerLeft={
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("item.create.customWidget")}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
{filteredCustomWidgets.map((def) => (
|
||||
<CatalogItem
|
||||
key={def.id}
|
||||
height={selectGridCardHeight}
|
||||
label={def.name}
|
||||
status={t("item.create.connectionStatus.noConnectionRequired")}
|
||||
disabled={loadingSelection !== null}
|
||||
busy={loadingSelection === `custom:${def.id}`}
|
||||
onSelect={() => void handleAddCustomWidget(def)}
|
||||
onFocus={() => {
|
||||
void loadWidgetDefinition("customApi").catch(() => undefined);
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
void loadWidgetDefinition("customApi").catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
{def.iconUrl ? (
|
||||
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
) : (
|
||||
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
)}
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{def.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{def.description ?? ""}
|
||||
</Text>
|
||||
<ConnectionStatusBadge status="noConnectionRequired" />
|
||||
</Stack>
|
||||
</CatalogItem>
|
||||
))}
|
||||
))}
|
||||
<Box mt="xs" style={{ gridColumn: "1 / -1" }}>
|
||||
<Button
|
||||
component="a"
|
||||
href="/manage/custom-widgets/workshop"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
fullWidth
|
||||
leftSection={<IconBuildingStore size={16} />}
|
||||
>
|
||||
{t("workshop.browseWorkshop")}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
<Box style={{ gridColumn: "1 / -1" }}>
|
||||
{/* Opens in a new tab so unsaved board changes survive the detour. */}
|
||||
<Button
|
||||
component="a"
|
||||
href="/manage/custom-widgets/workshop"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
fullWidth
|
||||
leftSection={<IconBuildingStore size={16} />}
|
||||
>
|
||||
{t("workshop.browseWorkshop")}
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
</Box>
|
||||
</Flex>
|
||||
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// WidgetItem: SelectableCard with Inset, Title, Status Badge, and Footer
|
||||
// =========================================================================
|
||||
export const WidgetItem = ({
|
||||
item,
|
||||
onSelect,
|
||||
onFocus,
|
||||
onPointerEnter,
|
||||
disabled,
|
||||
loading,
|
||||
connectionStatus,
|
||||
}: {
|
||||
item: WidgetItemData;
|
||||
onSelect: () => void;
|
||||
onFocus?: () => void;
|
||||
onPointerEnter?: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
connectionStatus: WidgetConnectionStatus;
|
||||
}) => {
|
||||
return (
|
||||
<SelectableCard
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
onClick={onSelect}
|
||||
onFocus={onFocus}
|
||||
onPointerEnter={onPointerEnter}
|
||||
aria-label={item.name}
|
||||
icon={
|
||||
<ThemeIcon size={34} radius="md" variant="light" color="primaryColor">
|
||||
<item.icon size={20} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
title={item.name}
|
||||
topRight={<ConnectionStatusBadge status={connectionStatus} />}
|
||||
description={item.description}
|
||||
footerLeft={<SupportedIntegrations integrations={item.supportedIntegrations} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => {
|
||||
const t = useI18n();
|
||||
if (status === "noConnectionRequired") return null;
|
||||
const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray";
|
||||
|
||||
return (
|
||||
<Badge variant="dot" color={color} size="xs" tt="none" w="fit-content" maw="100%">
|
||||
{t(`item.create.connectionStatus.${status}`)}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => {
|
||||
const t = useI18n();
|
||||
|
||||
if (integrations.length === 0) {
|
||||
return (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
{t("item.create.standalone")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const countToShow = integrations.length >= 6 ? 4 : 5;
|
||||
const moreCount = integrations.length - countToShow;
|
||||
|
||||
return (
|
||||
<Group gap={2}>
|
||||
<Tooltip.Group closeDelay={100}>
|
||||
<Group gap={2}>
|
||||
{integrations.slice(0, countToShow).map((integration) => (
|
||||
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
|
||||
<Avatar src={getIconUrl(integration)} size="xs" radius="xl" />
|
||||
</Tooltip>
|
||||
))}
|
||||
{moreCount > 0 && (
|
||||
<Tooltip
|
||||
withArrow
|
||||
label={
|
||||
<Stack gap={2}>
|
||||
{integrations.slice(countToShow).map((integration) => (
|
||||
<Text key={integration} size="xs">
|
||||
{getIntegrationName(integration)}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
<Avatar radius="xl" size="xs">
|
||||
+{moreCount}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Tooltip.Group>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const ItemSelectModalFrame = ({
|
||||
actions,
|
||||
innerProps,
|
||||
@@ -541,109 +660,3 @@ export const ItemSelectModal = createModal<{
|
||||
defaultTitle: (t) => t("item.create.title"),
|
||||
size: modalSizeSelect,
|
||||
});
|
||||
|
||||
const WidgetItem = ({
|
||||
item,
|
||||
onSelect,
|
||||
onFocus,
|
||||
onPointerEnter,
|
||||
disabled,
|
||||
loading,
|
||||
connectionStatus,
|
||||
}: {
|
||||
item: {
|
||||
kind: WidgetKind;
|
||||
supportedIntegrations: IntegrationKind[];
|
||||
name: string;
|
||||
description: string;
|
||||
icon: TablerIcon;
|
||||
};
|
||||
onSelect: () => void;
|
||||
onFocus: () => void;
|
||||
onPointerEnter: () => void;
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
connectionStatus: WidgetConnectionStatus;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const statusLabel = t(`item.create.connectionStatus.${connectionStatus}`);
|
||||
|
||||
return (
|
||||
<CatalogItem
|
||||
height={selectGridCardHeight}
|
||||
label={item.name}
|
||||
status={statusLabel}
|
||||
onFocus={onFocus}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onSelect={onSelect}
|
||||
disabled={disabled}
|
||||
busy={loading}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<item.icon size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Tooltip label={item.description} multiline w={250} disabled={!item.description}>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{item.description}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<ConnectionStatusBadge status={connectionStatus} />
|
||||
<SupportedIntegrations integrations={item.supportedIntegrations} />
|
||||
</Stack>
|
||||
</CatalogItem>
|
||||
);
|
||||
};
|
||||
|
||||
const ConnectionStatusBadge = ({ status }: { status: WidgetConnectionStatus }) => {
|
||||
const t = useI18n();
|
||||
const color = status === "ready" ? "green" : status === "needsSetup" ? "yellow" : "gray";
|
||||
|
||||
return (
|
||||
<Badge variant="light" color={color} size="xs" tt="none" w="fit-content" maw="100%">
|
||||
{t(`item.create.connectionStatus.${status}`)}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const SupportedIntegrations = ({ integrations }: { integrations: IntegrationKind[] }) => {
|
||||
if (integrations.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const countToShow = integrations.length >= 8 ? 6 : 7;
|
||||
const moreCount = integrations.length - countToShow;
|
||||
|
||||
return (
|
||||
<Group gap={2}>
|
||||
<Tooltip.Group closeDelay={100}>
|
||||
<Group gap={2}>
|
||||
{integrations.slice(0, countToShow).map((integration) => (
|
||||
<Tooltip key={integration} label={getIntegrationName(integration)} withArrow>
|
||||
<Avatar src={getIconUrl(integration)} size="xs" radius="xl" />
|
||||
</Tooltip>
|
||||
))}
|
||||
{moreCount > 0 && (
|
||||
<Tooltip
|
||||
withArrow
|
||||
label={
|
||||
<>
|
||||
{integrations.slice(countToShow).map((integration) => (
|
||||
<div key={integration}>{getIntegrationName(integration)}</div>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Avatar radius="xl" size="xs">
|
||||
+{moreCount}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Tooltip.Group>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, use } from "react";
|
||||
import { Suspense, use, useCallback, useMemo, useState } from "react";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { createModal, modalSizeForm } from "@homarr/modals";
|
||||
import { createModal, modalSizeForm, useModalAction } from "@homarr/modals";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import type * as WidgetModalsModule from "@homarr/widgets/modals";
|
||||
import type { WidgetEditModalProps } from "@homarr/widgets/modals";
|
||||
import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select";
|
||||
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
|
||||
import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
|
||||
|
||||
@@ -80,13 +83,46 @@ interface LazyWidgetEditModalContentProps {
|
||||
|
||||
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
|
||||
const { WidgetEditModal } = use(loadWidgetEditModal());
|
||||
const { openModal: openIntegrationModal } = useModalAction(IntegrationSelectModal);
|
||||
const Component = WidgetEditModal.component;
|
||||
const utils = clientApi.useUtils();
|
||||
const [createdIntegrations, setCreatedIntegrations] = useState<IntegrationSelectOption[]>([]);
|
||||
|
||||
const supportedKinds = useMemo(() => {
|
||||
if (!("supportedIntegrations" in props.innerProps.definition)) return [];
|
||||
return (props.innerProps.definition.supportedIntegrations ?? []).filter((k) => k !== "mock");
|
||||
}, [props.innerProps.definition]);
|
||||
|
||||
const handleOpenNewIntegration = useCallback(
|
||||
(onCreated?: (id: string) => void) => {
|
||||
openIntegrationModal({
|
||||
allowedKinds: supportedKinds,
|
||||
onSuccess: (result) => {
|
||||
if (result?.integration) {
|
||||
setCreatedIntegrations((prev) => [...prev, result.integration]);
|
||||
void utils.integration.all.invalidate();
|
||||
onCreated?.(result.integration.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
[openIntegrationModal, supportedKinds, utils],
|
||||
);
|
||||
|
||||
const combinedIntegrationData = useMemo(() => {
|
||||
const existing = props.innerProps.integrationData;
|
||||
const additional = createdIntegrations.filter((c) => !existing.some((e) => e.id === c.id));
|
||||
return [...existing, ...additional];
|
||||
}, [props.innerProps.integrationData, createdIntegrations]);
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
innerProps={{
|
||||
...props.innerProps,
|
||||
integrationData: combinedIntegrationData,
|
||||
integrationEditForm: LazyIntegrationEditForm,
|
||||
onOpenNewIntegration: supportedKinds.length > 0 ? handleOpenNewIntegration : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -122,6 +158,5 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
presentation: "inspector",
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "~/components/board/layout";
|
||||
import type { GridPlacement } from "~/components/board/layout";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import { useBoardSelection } from "~/components/board/selection/board-selection-context";
|
||||
import { beginGridTransaction, commitGridTransaction, previewGridMove, previewGridResize } from "./dnd";
|
||||
import type { GridTransaction } from "./dnd";
|
||||
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
|
||||
@@ -48,6 +49,16 @@ export const FixedGridItem = ({
|
||||
const { section, items, innerSections, columnCount, maxRowCount, announce } = useSectionContext();
|
||||
const { commitSectionGrid } = useGridLayoutActions();
|
||||
const [isKeyboardEditing, setIsKeyboardEditing] = useState(false);
|
||||
const { isSelected, toggleSelectItem } = useBoardSelection();
|
||||
const selected = item.type === "item" && isSelected(item.id);
|
||||
|
||||
const handleClickCapture = (event: React.MouseEvent) => {
|
||||
if (!isEditorActive || item.type !== "item" || (!event.metaKey && !event.ctrlKey)) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
toggleSelectItem(item.id);
|
||||
};
|
||||
const instructionsId = useId();
|
||||
const isEditorActive = isEditMode && runtimeStatus === "ready";
|
||||
const placement = toPlacement(item);
|
||||
@@ -178,6 +189,8 @@ export const FixedGridItem = ({
|
||||
return (
|
||||
<Box
|
||||
{...semantics}
|
||||
onClickCapture={handleClickCapture}
|
||||
data-board-item-selected={selected ? "true" : undefined}
|
||||
className={combineClasses(isEditorActive ? classes.editorEntry : classes.staticItem)}
|
||||
style={isEditorActive ? { position: "relative", width: "100%", height: "100%" } : getLogicalItemStyle(placement)}
|
||||
data-grid-item-id={isEditorActive ? undefined : item.id}
|
||||
@@ -187,6 +200,7 @@ export const FixedGridItem = ({
|
||||
data-kind={item.kind}
|
||||
data-keyboard-editing={isEditorActive ? String(isKeyboardEditing) : undefined}
|
||||
aria-describedby={isEditorActive ? instructionsId : undefined}
|
||||
aria-selected={isEditorActive && item.type === "item" ? selected : undefined}
|
||||
aria-keyshortcuts={
|
||||
isEditorActive
|
||||
? "Enter Space Escape ArrowLeft ArrowRight ArrowUp ArrowDown Shift+ArrowLeft Shift+ArrowRight Shift+ArrowUp Shift+ArrowDown"
|
||||
|
||||
@@ -32,6 +32,27 @@
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-board-item-selected="true"] {
|
||||
z-index: 15;
|
||||
opacity: 0.56;
|
||||
filter: saturate(0.72) brightness(0.82);
|
||||
transition:
|
||||
opacity 140ms ease,
|
||||
filter 140ms ease;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-board-item-selected="true"]::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
content: "";
|
||||
background: color-mix(in srgb, var(--mantine-primary-color-filled) 12%, transparent);
|
||||
border: 0.125rem dashed var(--mantine-primary-color-filled);
|
||||
border-radius: var(--board-item-radius, var(--mantine-radius-md));
|
||||
box-shadow: inset 0 0 1.125rem color-mix(in srgb, var(--mantine-primary-color-filled) 20%, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.board-grid-drag-overlay[data-dnd-dragging] {
|
||||
position: fixed !important;
|
||||
z-index: 10000;
|
||||
@@ -177,138 +198,115 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
|
||||
|
||||
.board-grid-resize-handle {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
display: block;
|
||||
z-index: 25;
|
||||
display: grid;
|
||||
padding: 0;
|
||||
color: var(--mantine-color-dimmed);
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
background: var(--mantine-color-body);
|
||||
border: 0.0625rem solid var(--mantine-color-default-border);
|
||||
box-shadow: var(--mantine-shadow-xs);
|
||||
opacity: 0;
|
||||
scale: 0.88;
|
||||
place-items: center;
|
||||
touch-action: none;
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
scale 160ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
border-color 120ms ease;
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting] .board-grid-resize-handle {
|
||||
.board-grid-resize-handle::before {
|
||||
position: absolute;
|
||||
inset: -0.375rem;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.board-grid-resize-handle > svg {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body[data-board-grid-interacting] .board-grid-resize-handle::after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle::after {
|
||||
position: absolute;
|
||||
inset: 50% auto auto 50%;
|
||||
width: 12px;
|
||||
height: 3px;
|
||||
content: "";
|
||||
background: color-mix(in srgb, var(--mantine-color-text) 70%, transparent);
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.board-grid-entry:hover > .board-grid-resize-handle::after,
|
||||
.board-grid-resize-handle:focus-visible::after {
|
||||
.board-grid-entry:has(> .board-grid-resize-handle:hover) > .board-grid-resize-handle,
|
||||
.board-grid-entry:has(> .board-grid-resize-handle:active) > .board-grid-resize-handle {
|
||||
border-color: var(--mantine-color-dimmed);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -8px;
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="n"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||
right: 44px;
|
||||
left: 44px;
|
||||
height: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
left: 50%;
|
||||
width: 1.75rem;
|
||||
height: 1rem;
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
translate: -50% 0;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="n"] {
|
||||
top: 0;
|
||||
top: -0.25rem;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="s"] {
|
||||
bottom: 0;
|
||||
bottom: -0.25rem;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||
top: 44px;
|
||||
bottom: 44px;
|
||||
width: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
top: 50%;
|
||||
width: 1rem;
|
||||
height: 1.75rem;
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
translate: 0 -50%;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"] {
|
||||
right: 0;
|
||||
right: -0.25rem;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"] {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="e"]::after,
|
||||
.board-grid-resize-handle[data-grid-resize-handle="w"]::after {
|
||||
width: 3px;
|
||||
height: 12px;
|
||||
left: -0.25rem;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"],
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||
z-index: 9;
|
||||
width: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
height: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
border-radius: var(--board-item-radius, var(--mantine-radius-md));
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
|
||||
top: 0;
|
||||
right: 0;
|
||||
top: -0.625rem;
|
||||
right: -0.625rem;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"] {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
right: -0.625rem;
|
||||
bottom: -0.625rem;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
bottom: -0.625rem;
|
||||
left: -0.625rem;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
|
||||
top: 0;
|
||||
left: 0;
|
||||
top: -0.625rem;
|
||||
left: -0.625rem;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="ne"]::after,
|
||||
.board-grid-resize-handle[data-grid-resize-handle="sw"]::after {
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
}
|
||||
|
||||
.board-grid-resize-handle[data-grid-resize-handle="se"]::after,
|
||||
.board-grid-resize-handle[data-grid-resize-handle="nw"]::after {
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
}
|
||||
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.board-grid-resize-handle::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.board-grid-entry,
|
||||
.board-grid-placeholder,
|
||||
.board-grid-resize-outline,
|
||||
.board-grid-resize-handle::after {
|
||||
.board-grid-resize-handle {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
DragStartEvent,
|
||||
} from "@dnd-kit/react";
|
||||
import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDroppable } from "@dnd-kit/react";
|
||||
import { IconGripHorizontal, IconGripVertical } from "@tabler/icons-react";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -61,6 +62,7 @@ import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
import type { GridInteraction } from "./grid-preview-layer";
|
||||
import { GridPreviewLayer } from "./grid-preview-layer";
|
||||
import { createGridResizeOutlineController } from "./grid-resize-outline";
|
||||
import { useBoardSelection } from "~/components/board/selection/board-selection-context";
|
||||
import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
import { useGridLayoutActions } from "./use-grid-layout-actions";
|
||||
|
||||
@@ -846,6 +848,9 @@ const DndGridEntryComponent = ({
|
||||
}: DndGridEntryProps) => {
|
||||
const { acquireContainer, structureRevision } = useBoardGridPortalHost();
|
||||
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
|
||||
const { isSelected } = useBoardSelection();
|
||||
const board = useRequiredBoard();
|
||||
const isSelectedEntry = placement.type === "item" && isSelected(placement.id);
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const shellRef = useRef<HTMLDivElement>(null);
|
||||
const placementRef = useRef(placement);
|
||||
@@ -921,7 +926,12 @@ const DndGridEntryComponent = ({
|
||||
<div
|
||||
ref={setShellRef}
|
||||
className="board-grid-entry"
|
||||
style={getLogicalItemStyle(placement) as CSSProperties}
|
||||
style={
|
||||
{
|
||||
...getLogicalItemStyle(placement),
|
||||
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
|
||||
} as CSSProperties
|
||||
}
|
||||
data-grid-item-id={placement.id}
|
||||
data-grid-item-type={placement.type}
|
||||
data-grid-x={placement.x}
|
||||
@@ -930,6 +940,7 @@ const DndGridEntryComponent = ({
|
||||
data-grid-h={placement.h}
|
||||
data-dnd-drag-source={isDragSource ? "true" : undefined}
|
||||
data-dnd-dropping={isDropping ? "true" : undefined}
|
||||
data-board-item-selected={isSelectedEntry ? "true" : undefined}
|
||||
>
|
||||
<div ref={mountRef} className="board-grid-content-mount" />
|
||||
<GridResizeHandles
|
||||
@@ -1102,19 +1113,27 @@ const GridResizeHandles = ({
|
||||
finishResize(event.pointerId, { x: event.clientX, y: event.clientY }, canceled);
|
||||
};
|
||||
|
||||
return RESIZE_DIRECTIONS.map((direction) => (
|
||||
<span
|
||||
key={direction}
|
||||
className="board-grid-resize-handle"
|
||||
data-grid-resize-handle={direction}
|
||||
aria-hidden="true"
|
||||
onPointerDown={(event) => handlePointerDown(event, direction)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={(event) => handlePointerEnd(event, false)}
|
||||
onPointerCancel={(event) => handlePointerEnd(event, true)}
|
||||
onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
|
||||
/>
|
||||
));
|
||||
return RESIZE_DIRECTIONS.map((direction) => {
|
||||
const isHorizontalSide = direction === "n" || direction === "s";
|
||||
const isVerticalSide = direction === "e" || direction === "w";
|
||||
const GripIcon = isHorizontalSide ? IconGripHorizontal : IconGripVertical;
|
||||
|
||||
return (
|
||||
<span
|
||||
key={direction}
|
||||
className="board-grid-resize-handle"
|
||||
data-grid-resize-handle={direction}
|
||||
aria-hidden="true"
|
||||
onPointerDown={(event) => handlePointerDown(event, direction)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={(event) => handlePointerEnd(event, false)}
|
||||
onPointerCancel={(event) => handlePointerEnd(event, true)}
|
||||
onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
|
||||
>
|
||||
<GripIcon size={isHorizontalSide || isVerticalSide ? 13 : 15} stroke={2.25} />
|
||||
</span>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const getProjectedDragShape = (
|
||||
@@ -1239,6 +1258,7 @@ const getPreferredRegisteredGridTarget = (
|
||||
|
||||
const preventGridDragActivation = (event: PointerEvent, source: DomDraggable) => {
|
||||
if (document.body.hasAttribute("data-board-grid-interacting")) return true;
|
||||
if (event.metaKey || event.ctrlKey) return true;
|
||||
const target = event.target;
|
||||
const activationElement = source.handle ?? source.element;
|
||||
if (!(target instanceof Element) || target === activationElement) return false;
|
||||
|
||||
@@ -10,6 +10,8 @@ import { FixedGridItem } from "../fixed-grid-item";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
announce: vi.fn(),
|
||||
commitSectionGrid: vi.fn(),
|
||||
isSelected: vi.fn(() => false),
|
||||
toggleSelectItem: vi.fn(),
|
||||
editMode: true,
|
||||
maxRowCount: null as number | null,
|
||||
items: [
|
||||
@@ -44,6 +46,13 @@ vi.mock("@homarr/boards/edit-mode", () => ({
|
||||
useEditMode: () => [mocks.editMode],
|
||||
}));
|
||||
|
||||
vi.mock("~/components/board/selection/board-selection-context", () => ({
|
||||
useBoardSelection: () => ({
|
||||
isSelected: mocks.isSelected,
|
||||
toggleSelectItem: mocks.toggleSelectItem,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/context", () => ({
|
||||
useCurrentLayout: () => "layout",
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Group, Text } from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { usePersistBoard } from "@homarr/boards/updater";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { ContainerSection, ItemLayout } from "~/app/[locale]/boards/_types";
|
||||
import { getFirstEmptyPosition } from "~/components/board/items/actions/empty-position";
|
||||
import { playPopSound, playTrashSound } from "../audio/board-sounds";
|
||||
|
||||
interface BoardSelectionContextValue {
|
||||
selectedItemIds: Set<string>;
|
||||
toggleSelectItem: (id: string) => void;
|
||||
clearSelection: () => void;
|
||||
isSelected: (id: string) => boolean;
|
||||
removeSelectedItems: () => void;
|
||||
moveSelectedItemsToSection: (sectionId: string) => void;
|
||||
}
|
||||
|
||||
const BoardSelectionContext = createContext<BoardSelectionContextValue | null>(null);
|
||||
|
||||
export const BoardSelectionProvider = ({ children }: PropsWithChildren) => {
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const { updateAndPersistBoard } = usePersistBoard(board);
|
||||
const t = useI18n();
|
||||
const tSelection = useScopedI18n("item.selection");
|
||||
|
||||
const persistBoard = useCallback(
|
||||
(updater: (previous: typeof board) => typeof board) => {
|
||||
updateAndPersistBoard(updater, {
|
||||
onError: () => {
|
||||
showErrorNotification({
|
||||
title: tSelection("saveErrorTitle"),
|
||||
message: tSelection("saveErrorMessage"),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[tSelection, updateAndPersistBoard],
|
||||
);
|
||||
|
||||
// Clear selection if leaving edit mode
|
||||
useEffect(() => {
|
||||
if (!isEditMode) {
|
||||
setSelectedItemIds(new Set());
|
||||
}
|
||||
}, [isEditMode]);
|
||||
|
||||
// Clear selection on Escape key
|
||||
useEffect(() => {
|
||||
if (selectedItemIds.size === 0) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setSelectedItemIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedItemIds.size]);
|
||||
|
||||
const toggleSelectItem = useCallback((id: string) => {
|
||||
setSelectedItemIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
playPopSound();
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedItemIds(new Set());
|
||||
}, []);
|
||||
|
||||
const isSelected = useCallback((id: string) => selectedItemIds.has(id), [selectedItemIds]);
|
||||
|
||||
const removeSelectedItems = useCallback(() => {
|
||||
if (selectedItemIds.size === 0) return;
|
||||
|
||||
const idsToRemove = Array.from(selectedItemIds);
|
||||
const count = idsToRemove.length;
|
||||
const removedItems = board.items.filter((item) => idsToRemove.includes(item.id));
|
||||
|
||||
playTrashSound();
|
||||
clearSelection();
|
||||
|
||||
persistBoard((previous) => ({
|
||||
...previous,
|
||||
items: previous.items.filter((item) => !idsToRemove.includes(item.id)),
|
||||
}));
|
||||
|
||||
const notificationId = `batch-remove-${Date.now()}`;
|
||||
showSuccessNotification({
|
||||
id: notificationId,
|
||||
title: t("common.success"),
|
||||
message: (
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Text size="sm">{tSelection("removed", { count })}</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
onClick={() => {
|
||||
persistBoard((previous) => ({
|
||||
...previous,
|
||||
items: [...previous.items, ...removedItems],
|
||||
}));
|
||||
notifications.hide(notificationId);
|
||||
}}
|
||||
>
|
||||
{t("common.action.undo")}
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
autoClose: 10_000,
|
||||
});
|
||||
}, [board.items, clearSelection, persistBoard, selectedItemIds, t, tSelection]);
|
||||
|
||||
const moveSelectedItemsToSection = useCallback(
|
||||
(targetSectionId: string) => {
|
||||
if (selectedItemIds.size === 0) return;
|
||||
|
||||
const idsToMove = new Set(selectedItemIds);
|
||||
let didMoveAll = false;
|
||||
|
||||
persistBoard((previous) => {
|
||||
const targetSection = previous.sections.find((section) => section.id === targetSectionId);
|
||||
const boardLayout = previous.layouts.find((layout) => layout.id === currentLayoutId);
|
||||
if (!targetSection || !boardLayout) return previous;
|
||||
|
||||
const containerLayout =
|
||||
targetSection.kind === "container"
|
||||
? targetSection.layouts.find((layout) => layout.layoutId === currentLayoutId)
|
||||
: undefined;
|
||||
const columnCount =
|
||||
targetSection.kind === "container"
|
||||
? (containerLayout?.width ?? 0)
|
||||
: getBoardLaneColumnCount(boardLayout, getRootSectionLane(targetSection.xOffset));
|
||||
const rowCount = containerLayout?.height ?? 9999;
|
||||
if (columnCount === 0 || rowCount === 0) return previous;
|
||||
|
||||
const existingContainers = previous.sections
|
||||
.filter((section): section is ContainerSection => section.kind === "container")
|
||||
.flatMap((section) => {
|
||||
const layout = section.layouts.find(
|
||||
(candidate) => candidate.layoutId === currentLayoutId && candidate.parentSectionId === targetSection.id,
|
||||
);
|
||||
return layout
|
||||
? [
|
||||
{
|
||||
id: section.id,
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
const existingItems = previous.items.flatMap((item) => {
|
||||
const layout = item.layouts.find(
|
||||
(candidate) => candidate.layoutId === currentLayoutId && candidate.sectionId === targetSection.id,
|
||||
);
|
||||
return layout
|
||||
? [
|
||||
{
|
||||
id: item.id,
|
||||
xOffset: layout.xOffset,
|
||||
yOffset: layout.yOffset,
|
||||
width: layout.width,
|
||||
height: layout.height,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
const occupiedPositions = [...existingContainers, ...existingItems];
|
||||
const movedLayouts = new Map<string, ItemLayout>();
|
||||
|
||||
for (const item of previous.items.filter((candidate) => idsToMove.has(candidate.id))) {
|
||||
const currentItemLayout = item.layouts.find((layout) => layout.layoutId === currentLayoutId);
|
||||
if (!currentItemLayout) continue;
|
||||
|
||||
const previousPositionIndex = occupiedPositions.findIndex((position) => position.id === item.id);
|
||||
const previousPosition =
|
||||
previousPositionIndex >= 0 ? occupiedPositions.splice(previousPositionIndex, 1).at(0) : undefined;
|
||||
const { width, height } = currentItemLayout;
|
||||
const emptyPosition =
|
||||
width <= columnCount && height <= rowCount
|
||||
? getFirstEmptyPosition(occupiedPositions, columnCount, rowCount, { width, height })
|
||||
: undefined;
|
||||
|
||||
if (!emptyPosition) {
|
||||
if (previousPosition) occupiedPositions.push(previousPosition);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextLayout: ItemLayout = {
|
||||
...currentItemLayout,
|
||||
sectionId: targetSectionId,
|
||||
xOffset: emptyPosition.xOffset,
|
||||
yOffset: emptyPosition.yOffset,
|
||||
};
|
||||
movedLayouts.set(item.id, nextLayout);
|
||||
occupiedPositions.push({ id: item.id, ...emptyPosition, width, height });
|
||||
}
|
||||
|
||||
if (movedLayouts.size !== idsToMove.size) return previous;
|
||||
didMoveAll = true;
|
||||
return {
|
||||
...previous,
|
||||
items: previous.items.map((item) => {
|
||||
const movedLayout = movedLayouts.get(item.id);
|
||||
if (!movedLayout) return item;
|
||||
return {
|
||||
...item,
|
||||
layouts: item.layouts.map((layout) => (layout.layoutId === currentLayoutId ? movedLayout : layout)),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
if (didMoveAll) {
|
||||
clearSelection();
|
||||
} else {
|
||||
showErrorNotification({
|
||||
title: tSelection("moveUnavailableTitle"),
|
||||
message: tSelection("moveUnavailableMessage"),
|
||||
});
|
||||
}
|
||||
},
|
||||
[clearSelection, currentLayoutId, persistBoard, selectedItemIds, tSelection],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
selectedItemIds,
|
||||
toggleSelectItem,
|
||||
clearSelection,
|
||||
isSelected,
|
||||
removeSelectedItems,
|
||||
moveSelectedItemsToSection,
|
||||
}),
|
||||
[clearSelection, isSelected, moveSelectedItemsToSection, removeSelectedItems, selectedItemIds, toggleSelectItem],
|
||||
);
|
||||
|
||||
return <BoardSelectionContext.Provider value={value}>{children}</BoardSelectionContext.Provider>;
|
||||
};
|
||||
|
||||
export const useBoardSelection = () => {
|
||||
const context = useContext(BoardSelectionContext);
|
||||
if (!context) {
|
||||
throw new Error("useBoardSelection must be used within a BoardSelectionProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
HoverCard,
|
||||
Menu,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import { IconCheck, IconFolderShare, IconTrash, IconX } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
|
||||
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useBoardSelection } from "./board-selection-context";
|
||||
|
||||
const MAX_AVATARS_DISPLAYED = 5;
|
||||
|
||||
export const BoardSelectionToolbar = () => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const { selectedItemIds, clearSelection, removeSelectedItems, moveSelectedItemsToSection } = useBoardSelection();
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId);
|
||||
const t = useI18n();
|
||||
const tSelection = useScopedI18n("item.selection");
|
||||
|
||||
const selectedItems = board.items.filter((item) => selectedItemIds.has(item.id));
|
||||
const hasAppWidgets = selectedItems.some((item) => item.kind === "app");
|
||||
const { data: selectableApps } = clientApi.app.selectable.useQuery(undefined, {
|
||||
enabled: hasAppWidgets,
|
||||
});
|
||||
|
||||
if (!isEditMode || selectedItemIds.size === 0) return null;
|
||||
|
||||
const appMap = new Map((selectableApps ?? []).map((app) => [app.id, app]));
|
||||
|
||||
// Sort items so apps appear first
|
||||
const sortedSelectedItems = selectedItems.toSorted((itemA, itemB) => {
|
||||
if (itemA.kind === "app" && itemB.kind !== "app") return -1;
|
||||
if (itemA.kind !== "app" && itemB.kind === "app") return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const getItemDetails = (item: (typeof selectedItems)[number]) => {
|
||||
let displayName = item.advancedOptions.title?.trim();
|
||||
let iconUrl: string | undefined = undefined;
|
||||
const IconComponent = widgetCatalogIcons[item.kind] ?? IconCheck;
|
||||
|
||||
if (item.kind === "app") {
|
||||
const appId = (item.options as { appId?: string })?.appId;
|
||||
const app = appId ? appMap.get(appId) : undefined;
|
||||
if (!displayName) {
|
||||
displayName = app?.name || t("widget.app.name");
|
||||
}
|
||||
if (app?.iconUrl) {
|
||||
iconUrl = app.iconUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (!displayName) {
|
||||
displayName = t(`widget.${item.kind}.name`);
|
||||
}
|
||||
|
||||
return { displayName, iconUrl, IconComponent, kind: item.kind };
|
||||
};
|
||||
|
||||
const visibleItems = sortedSelectedItems.slice(0, MAX_AVATARS_DISPLAYED);
|
||||
const overflowCount = sortedSelectedItems.length - visibleItems.length;
|
||||
|
||||
const mainCanvasSection = board.sections
|
||||
.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === "main",
|
||||
)
|
||||
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
|
||||
.at(0);
|
||||
|
||||
const placementOptions = [
|
||||
...(mainCanvasSection ? [{ value: mainCanvasSection.id, label: t("item.create.destination.mainCanvas") }] : []),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "empty") return [];
|
||||
const lane = getRootSectionLane(section.xOffset);
|
||||
if (lane === "main" || !currentLayout || getBoardLaneColumnCount(currentLayout, lane) === 0) return [];
|
||||
return [
|
||||
{
|
||||
value: section.id,
|
||||
label: t(`item.create.destination.${lane === "left" ? "leftRail" : "rightRail"}`),
|
||||
},
|
||||
];
|
||||
}),
|
||||
...board.sections
|
||||
.filter((section) => section.kind === "container")
|
||||
.map((section) => ({
|
||||
value: section.id,
|
||||
label: section.options.title.trim() || t("section.container.untitled"),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="xl"
|
||||
radius="xl"
|
||||
p="xs"
|
||||
bg="light-dark(var(--mantine-color-body), var(--mantine-color-dark-7))"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 28,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 1000,
|
||||
width: "max-content",
|
||||
maxWidth: "calc(100vw - 1rem)",
|
||||
boxShadow: "0 12px 40px rgba(0, 0, 0, 0.28)",
|
||||
backdropFilter: "blur(14px)",
|
||||
border: "1px solid var(--mantine-color-default-border)",
|
||||
animation: "slideUp 200ms cubic-bezier(0.4, 0, 0.2, 1) forwards",
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{/* Avatar Group with HoverCard Popover showing all items */}
|
||||
<HoverCard position="top" withArrow shadow="xl" radius="md" openDelay={100} closeDelay={200} withinPortal>
|
||||
<HoverCard.Target>
|
||||
<UnstyledButton
|
||||
display="inline-flex"
|
||||
aria-label={tSelection("summary", { count: sortedSelectedItems.length })}
|
||||
style={{ alignItems: "center", borderRadius: "var(--mantine-radius-xl)" }}
|
||||
>
|
||||
<Avatar.Group>
|
||||
{visibleItems.map((item) => {
|
||||
const { displayName, iconUrl, IconComponent } = getItemDetails(item);
|
||||
return (
|
||||
<Avatar
|
||||
key={item.id}
|
||||
src={iconUrl}
|
||||
radius="xl"
|
||||
size={32}
|
||||
color="primaryColor"
|
||||
variant="light"
|
||||
styles={{ image: { objectFit: "contain", padding: 2 } }}
|
||||
alt={displayName}
|
||||
>
|
||||
{!iconUrl && <IconComponent size={16} />}
|
||||
</Avatar>
|
||||
);
|
||||
})}
|
||||
{overflowCount > 0 && (
|
||||
<Avatar radius="xl" size={32} color="gray" variant="filled">
|
||||
+{overflowCount}
|
||||
</Avatar>
|
||||
)}
|
||||
</Avatar.Group>
|
||||
</UnstyledButton>
|
||||
</HoverCard.Target>
|
||||
|
||||
<HoverCard.Dropdown p={6} style={{ width: 220 }}>
|
||||
<Stack gap={4}>
|
||||
<Text size="10px" fw={700} c="dimmed" tt="uppercase" px={4}>
|
||||
{tSelection("selected", { count: sortedSelectedItems.length })}
|
||||
</Text>
|
||||
|
||||
<ScrollArea.Autosize mah={176} type="auto">
|
||||
<Stack gap={2}>
|
||||
{sortedSelectedItems.map((item) => {
|
||||
const { displayName, iconUrl, IconComponent } = getItemDetails(item);
|
||||
return (
|
||||
<Group key={item.id} gap={6} wrap="nowrap" px={4} py={2}>
|
||||
<Avatar
|
||||
src={iconUrl}
|
||||
radius="sm"
|
||||
size={22}
|
||||
color="primaryColor"
|
||||
variant="light"
|
||||
styles={{ image: { objectFit: "contain", padding: 2 } }}
|
||||
alt={displayName}
|
||||
>
|
||||
{!iconUrl && <IconComponent size={12} />}
|
||||
</Avatar>
|
||||
<Text size="xs" fw={500} truncate>
|
||||
{displayName}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
|
||||
<Text size="xs" c="dimmed" visibleFrom="sm" maw={220} lh={1.25}>
|
||||
{t("app.action.select.multiSelectTip")}
|
||||
</Text>
|
||||
|
||||
<Divider orientation="vertical" />
|
||||
|
||||
{/* Actions */}
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{placementOptions.length > 1 && (
|
||||
<Menu position="top" withArrow shadow="md">
|
||||
<Menu.Target>
|
||||
<Button size="xs" variant="light" color="primaryColor" leftSection={<IconFolderShare size={15} />}>
|
||||
{tSelection("move")}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{tSelection("moveTo")}</Menu.Label>
|
||||
{placementOptions.map((opt) => (
|
||||
<Menu.Item key={opt.value} onClick={() => moveSelectedItemsToSection(opt.value)}>
|
||||
{opt.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={15} />}
|
||||
onClick={removeSelectedItems}
|
||||
>
|
||||
{tSelection("delete")}
|
||||
</Button>
|
||||
|
||||
<Tooltip label={tSelection("clearShortcut")} withArrow>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={clearSelection}
|
||||
aria-label={tSelection("clear")}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,251 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
|
||||
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import { LayoutMockBuilder } from "~/components/board/items/actions/test/mocks/layout-mock";
|
||||
import { BoardSelectionProvider, useBoardSelection } from "./board-selection-context";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string) => key,
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("../audio/board-sounds", () => ({
|
||||
playPopSound: vi.fn(),
|
||||
playTrashSound: vi.fn(),
|
||||
}));
|
||||
|
||||
let currentBoardState: Board;
|
||||
let mockUpdateBoard: (updater: (prev: Board) => Board) => void;
|
||||
|
||||
vi.mock("@homarr/boards/context", () => ({
|
||||
useRequiredBoard: () => currentBoardState,
|
||||
useCurrentLayout: () => "layout-1",
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/edit-mode", () => ({
|
||||
useEditMode: () => [true, vi.fn()],
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/boards/updater", () => ({
|
||||
usePersistBoard: () => ({
|
||||
updateAndPersistBoard: (updater: (prev: Board) => Board) => {
|
||||
mockUpdateBoard(updater);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
host.remove();
|
||||
});
|
||||
|
||||
const SelectionConsumer = ({ onReady }: { onReady: (ctx: ReturnType<typeof useBoardSelection>) => void }) => {
|
||||
const selection = useBoardSelection();
|
||||
onReady(selection);
|
||||
return <div>Selection Ready</div>;
|
||||
};
|
||||
|
||||
describe("BoardSelectionContext", () => {
|
||||
it("toggles item selection and clears on clearSelection", async () => {
|
||||
const layout = new LayoutMockBuilder({ id: "layout-1" }).build();
|
||||
const item1 = new ItemMockBuilder({ id: "item-1" }).addLayout({ layoutId: "layout-1" }).build();
|
||||
currentBoardState = new BoardMockBuilder().addLayout(layout).addItem(item1).build();
|
||||
mockUpdateBoard = vi.fn();
|
||||
|
||||
let selectionContext!: ReturnType<typeof useBoardSelection>;
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<BoardSelectionProvider>
|
||||
<SelectionConsumer
|
||||
onReady={(ctx) => {
|
||||
selectionContext = ctx;
|
||||
}}
|
||||
/>
|
||||
</BoardSelectionProvider>
|
||||
</MantineProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(selectionContext.isSelected("item-1")).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
selectionContext.toggleSelectItem("item-1");
|
||||
});
|
||||
expect(selectionContext.isSelected("item-1")).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
selectionContext.clearSelection();
|
||||
});
|
||||
expect(selectionContext.isSelected("item-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("moves selected items to the first available positions in a container section", async () => {
|
||||
const layout = new LayoutMockBuilder({ id: "layout-1" }).build();
|
||||
const emptyRoot = new EmptySectionMockBuilder({ id: "empty-root", xOffset: 0 }).build();
|
||||
const container = new ContainerSectionMockBuilder({ id: "container-1" })
|
||||
.addLayout({ layoutId: "layout-1", parentSectionId: "empty-root", width: 4, height: 4 })
|
||||
.build();
|
||||
|
||||
// Existing item inside the container at (0, 0) with 2x2
|
||||
const existingContainerItem = new ItemMockBuilder({ id: "existing-1" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "container-1", xOffset: 0, yOffset: 0, width: 2, height: 2 })
|
||||
.build();
|
||||
|
||||
// Two items to move from emptyRoot
|
||||
const itemA = new ItemMockBuilder({ id: "item-a" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 5, yOffset: 5, width: 2, height: 1 })
|
||||
.build();
|
||||
const itemB = new ItemMockBuilder({ id: "item-b" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 8, yOffset: 8, width: 2, height: 1 })
|
||||
.build();
|
||||
|
||||
currentBoardState = new BoardMockBuilder()
|
||||
.addLayout(layout)
|
||||
.addSection(emptyRoot)
|
||||
.addSection(container)
|
||||
.addItem(existingContainerItem)
|
||||
.addItem(itemA)
|
||||
.addItem(itemB)
|
||||
.build();
|
||||
|
||||
let updatedBoard: Board = currentBoardState;
|
||||
mockUpdateBoard = vi.fn((updater) => {
|
||||
updatedBoard = updater(currentBoardState);
|
||||
currentBoardState = updatedBoard;
|
||||
});
|
||||
|
||||
let selectionContext!: ReturnType<typeof useBoardSelection>;
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<BoardSelectionProvider>
|
||||
<SelectionConsumer
|
||||
onReady={(ctx) => {
|
||||
selectionContext = ctx;
|
||||
}}
|
||||
/>
|
||||
</BoardSelectionProvider>
|
||||
</MantineProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
selectionContext.toggleSelectItem("item-a");
|
||||
selectionContext.toggleSelectItem("item-b");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
selectionContext.moveSelectedItemsToSection("container-1");
|
||||
});
|
||||
|
||||
const movedA = updatedBoard.items.find((i) => i.id === "item-a")?.layouts.find((l) => l.layoutId === "layout-1");
|
||||
const movedB = updatedBoard.items.find((i) => i.id === "item-b")?.layouts.find((l) => l.layoutId === "layout-1");
|
||||
|
||||
expect(movedA?.sectionId).toBe("container-1");
|
||||
expect(movedB?.sectionId).toBe("container-1");
|
||||
|
||||
// Container width is 4. Existing item at (0, 0) occupies (0,0)-(1,1).
|
||||
// itemA (2x1) is placed at first empty spot: (2, 0)
|
||||
expect(movedA?.xOffset).toBe(2);
|
||||
expect(movedA?.yOffset).toBe(0);
|
||||
|
||||
// itemB (2x1) is placed at next first empty spot: (2, 1)
|
||||
expect(movedB?.xOffset).toBe(2);
|
||||
expect(movedB?.yOffset).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps the entire selection in place when the destination cannot fit every item", async () => {
|
||||
const layout = new LayoutMockBuilder({ id: "layout-1" }).build();
|
||||
const emptyRoot = new EmptySectionMockBuilder({ id: "empty-root", xOffset: 0 }).build();
|
||||
const container = new ContainerSectionMockBuilder({ id: "container-1" })
|
||||
.addLayout({ layoutId: "layout-1", parentSectionId: "empty-root", width: 2, height: 1 })
|
||||
.build();
|
||||
const existingContainerItem = new ItemMockBuilder({ id: "existing-1" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "container-1", xOffset: 0, yOffset: 0, width: 1, height: 1 })
|
||||
.build();
|
||||
const itemA = new ItemMockBuilder({ id: "item-a" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 0, yOffset: 0, width: 1, height: 1 })
|
||||
.build();
|
||||
const itemB = new ItemMockBuilder({ id: "item-b" })
|
||||
.addLayout({ layoutId: "layout-1", sectionId: "empty-root", xOffset: 1, yOffset: 0, width: 1, height: 1 })
|
||||
.build();
|
||||
|
||||
currentBoardState = new BoardMockBuilder()
|
||||
.addLayout(layout)
|
||||
.addSection(emptyRoot)
|
||||
.addSection(container)
|
||||
.addItem(existingContainerItem)
|
||||
.addItem(itemA)
|
||||
.addItem(itemB)
|
||||
.build();
|
||||
|
||||
let updatedBoard = currentBoardState;
|
||||
mockUpdateBoard = vi.fn((updater) => {
|
||||
updatedBoard = updater(currentBoardState);
|
||||
currentBoardState = updatedBoard;
|
||||
});
|
||||
|
||||
let selectionContext!: ReturnType<typeof useBoardSelection>;
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<BoardSelectionProvider>
|
||||
<SelectionConsumer
|
||||
onReady={(ctx) => {
|
||||
selectionContext = ctx;
|
||||
}}
|
||||
/>
|
||||
</BoardSelectionProvider>
|
||||
</MantineProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
selectionContext.toggleSelectItem("item-a");
|
||||
selectionContext.toggleSelectItem("item-b");
|
||||
});
|
||||
await act(async () => {
|
||||
selectionContext.moveSelectedItemsToSection("container-1");
|
||||
});
|
||||
|
||||
expect(updatedBoard.items.find((item) => item.id === "item-a")?.layouts[0]?.sectionId).toBe("empty-root");
|
||||
expect(updatedBoard.items.find((item) => item.id === "item-b")?.layouts[0]?.sectionId).toBe("empty-root");
|
||||
expect(selectionContext.isSelected("item-a")).toBe(true);
|
||||
expect(selectionContext.isSelected("item-b")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,7 @@ export const useSetupAnalytics = () => {
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("common.error"),
|
||||
message: t("universalCreate.analyticsError"),
|
||||
message: t("common.analyticsError"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { RankedUniversalCreateAction } from "./universal-create-actions";
|
||||
import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
|
||||
|
||||
describe("getUniversalCreateActionDefinitions", () => {
|
||||
test("only exposes actions allowed without a board context", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({
|
||||
hasBoardContext: false,
|
||||
permissions: ["board-create", "integration-create"],
|
||||
});
|
||||
|
||||
expect(actions.map(({ key }) => key)).toEqual(["integration", "board"]);
|
||||
});
|
||||
|
||||
test("places globally-created apps in the library group", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: false, permissions: ["app-create"] });
|
||||
|
||||
expect(actions).toEqual([expect.objectContaining({ key: "app", group: "library" })]);
|
||||
});
|
||||
|
||||
test("keeps board content actions available without global create permissions", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: true, permissions: [] });
|
||||
|
||||
expect(actions.map(({ key }) => key)).toEqual(["widget", "app", "container"]);
|
||||
});
|
||||
|
||||
test("exposes the complete surface to administrators on a board", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({
|
||||
hasBoardContext: true,
|
||||
permissions: ["admin", "app-create", "integration-create", "board-create"],
|
||||
});
|
||||
|
||||
expect(actions.map(({ key }) => key)).toEqual([
|
||||
"widget",
|
||||
"app",
|
||||
"integration",
|
||||
"container",
|
||||
"board",
|
||||
"workshop",
|
||||
"customWidget",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterAndRankUniversalCreateActions", () => {
|
||||
const actions = [
|
||||
{ key: "widget", group: "currentBoard", priority: 100, name: "Add widget", description: "Add content" },
|
||||
{
|
||||
key: "integration",
|
||||
group: "library",
|
||||
priority: 80,
|
||||
name: "Connect service",
|
||||
description: "Create an integration",
|
||||
keywords: ["provider"],
|
||||
},
|
||||
{ key: "board", group: "boards", priority: 60, name: "Create board", description: "Start a dashboard" },
|
||||
] satisfies RankedUniversalCreateAction[];
|
||||
|
||||
test("uses product priority when no search is entered", () => {
|
||||
expect(filterAndRankUniversalCreateActions(actions, "").map(({ key }) => key)).toEqual([
|
||||
"widget",
|
||||
"integration",
|
||||
"board",
|
||||
]);
|
||||
});
|
||||
|
||||
test("ranks a name prefix ahead of a description or keyword match", () => {
|
||||
const results = filterAndRankUniversalCreateActions(actions, "create");
|
||||
|
||||
expect(results.map(({ key }) => key)).toEqual(["board", "integration"]);
|
||||
});
|
||||
|
||||
test("matches keywords and removes unrelated actions", () => {
|
||||
expect(filterAndRankUniversalCreateActions(actions, "provider").map(({ key }) => key)).toEqual(["integration"]);
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
export const universalCreateActionKeys = [
|
||||
"widget",
|
||||
"app",
|
||||
"integration",
|
||||
"container",
|
||||
"board",
|
||||
"workshop",
|
||||
"customWidget",
|
||||
] as const;
|
||||
|
||||
export type UniversalCreateActionKey = (typeof universalCreateActionKeys)[number];
|
||||
export type UniversalCreateActionGroup = "currentBoard" | "library" | "boards";
|
||||
|
||||
export interface UniversalCreateContext {
|
||||
hasBoardContext: boolean;
|
||||
permissions: readonly GroupPermissionKey[];
|
||||
}
|
||||
|
||||
interface UniversalCreateActionDefinition {
|
||||
key: UniversalCreateActionKey;
|
||||
group: UniversalCreateActionGroup;
|
||||
priority: number;
|
||||
isVisible: (context: UniversalCreateContext) => boolean;
|
||||
}
|
||||
|
||||
const hasPermission = (context: UniversalCreateContext, permission: GroupPermissionKey) =>
|
||||
context.permissions.includes(permission);
|
||||
|
||||
const definitions: readonly UniversalCreateActionDefinition[] = [
|
||||
{
|
||||
key: "widget",
|
||||
group: "currentBoard",
|
||||
priority: 100,
|
||||
isVisible: ({ hasBoardContext }) => hasBoardContext,
|
||||
},
|
||||
{
|
||||
key: "app",
|
||||
group: "currentBoard",
|
||||
priority: 90,
|
||||
isVisible: (context) => context.hasBoardContext || hasPermission(context, "app-create"),
|
||||
},
|
||||
{
|
||||
key: "integration",
|
||||
group: "library",
|
||||
priority: 80,
|
||||
isVisible: (context) => hasPermission(context, "integration-create"),
|
||||
},
|
||||
{
|
||||
key: "container",
|
||||
group: "currentBoard",
|
||||
priority: 70,
|
||||
isVisible: ({ hasBoardContext }) => hasBoardContext,
|
||||
},
|
||||
{
|
||||
key: "board",
|
||||
group: "boards",
|
||||
priority: 60,
|
||||
isVisible: (context) => hasPermission(context, "board-create"),
|
||||
},
|
||||
{
|
||||
key: "workshop",
|
||||
group: "library",
|
||||
priority: 40,
|
||||
isVisible: (context) => hasPermission(context, "admin"),
|
||||
},
|
||||
{
|
||||
key: "customWidget",
|
||||
group: "library",
|
||||
priority: 30,
|
||||
isVisible: (context) => hasPermission(context, "admin"),
|
||||
},
|
||||
];
|
||||
|
||||
export interface RankedUniversalCreateAction {
|
||||
key: UniversalCreateActionKey;
|
||||
group: UniversalCreateActionGroup;
|
||||
priority: number;
|
||||
name: string;
|
||||
description: string;
|
||||
keywords?: readonly string[];
|
||||
}
|
||||
|
||||
export const getUniversalCreateActionDefinitions = (context: UniversalCreateContext) =>
|
||||
definitions
|
||||
.filter((definition) => definition.isVisible(context))
|
||||
.map((definition) =>
|
||||
definition.key === "app" && !context.hasBoardContext ? { ...definition, group: "library" as const } : definition,
|
||||
);
|
||||
|
||||
const normalize = (value: string) => value.trim().toLocaleLowerCase();
|
||||
|
||||
export const filterAndRankUniversalCreateActions = <TAction extends RankedUniversalCreateAction>(
|
||||
actions: readonly TAction[],
|
||||
query: string,
|
||||
) => {
|
||||
const normalizedQuery = normalize(query);
|
||||
|
||||
return actions
|
||||
.map((action) => {
|
||||
const name = normalize(action.name);
|
||||
const searchableText = normalize([action.name, action.description, ...(action.keywords ?? [])].join(" "));
|
||||
const relevance =
|
||||
normalizedQuery.length === 0
|
||||
? 0
|
||||
: name === normalizedQuery
|
||||
? 300
|
||||
: name.startsWith(normalizedQuery)
|
||||
? 200
|
||||
: searchableText.includes(normalizedQuery)
|
||||
? 100
|
||||
: -1;
|
||||
|
||||
return { action, relevance };
|
||||
})
|
||||
.filter(({ relevance }) => relevance >= 0)
|
||||
.toSorted((left, right) => right.relevance - left.relevance || right.action.priority - left.action.priority)
|
||||
.map(({ action }) => action);
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
.action {
|
||||
width: 100%;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
padding: var(--mantine-spacing-sm);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.action:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
declare const classes: {
|
||||
readonly action: string;
|
||||
readonly actionIcon: string;
|
||||
};
|
||||
export default classes;
|
||||
@@ -1,162 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Group, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
|
||||
import {
|
||||
IconApps,
|
||||
IconBox,
|
||||
IconCode,
|
||||
IconLayoutDashboard,
|
||||
IconLayoutGridAdd,
|
||||
IconPlugConnected,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { AddBoardModal } from "@homarr/modals-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import classes from "./universal-create-modal.module.css";
|
||||
import type {
|
||||
RankedUniversalCreateAction,
|
||||
UniversalCreateActionGroup,
|
||||
UniversalCreateActionKey,
|
||||
} from "./universal-create-actions";
|
||||
import { getUniversalCreateActionDefinitions } from "./universal-create-actions";
|
||||
import type { SetupMetricEntryPoint } from "./setup-analytics";
|
||||
import { useSetupAnalytics } from "./setup-analytics";
|
||||
|
||||
type BoardActionKey = "widget" | "app" | "integration" | "container";
|
||||
|
||||
export interface UniversalCreateModalProps {
|
||||
boardActions?: Partial<Record<BoardActionKey, () => void>>;
|
||||
entryPoint?: SetupMetricEntryPoint;
|
||||
}
|
||||
|
||||
const actionIcons = {
|
||||
widget: IconApps,
|
||||
app: IconBox,
|
||||
integration: IconPlugConnected,
|
||||
container: IconLayoutGridAdd,
|
||||
board: IconLayoutDashboard,
|
||||
workshop: IconSearch,
|
||||
customWidget: IconCode,
|
||||
} satisfies Record<UniversalCreateActionKey, TablerIcon>;
|
||||
|
||||
export const UniversalCreateModal = createModal<UniversalCreateModalProps>(({ actions, innerProps }) => {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const t = useScopedI18n("universalCreate");
|
||||
const { openModal: openAddBoardModal } = useModalAction(AddBoardModal);
|
||||
const trackedOpen = useRef(false);
|
||||
const boardActions = innerProps.boardActions;
|
||||
const entryPoint = innerProps.entryPoint ?? (boardActions ? "board" : "header");
|
||||
const trackSetup = useSetupAnalytics();
|
||||
const permissions = (session?.user.permissions ?? []) as GroupPermissionKey[];
|
||||
const definitions = getUniversalCreateActionDefinitions({
|
||||
hasBoardContext: boardActions !== undefined,
|
||||
permissions,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (trackedOpen.current) return;
|
||||
trackedOpen.current = true;
|
||||
trackSetup("surface-opened", {
|
||||
entryPoint,
|
||||
hasBoardContext: boardActions !== undefined,
|
||||
});
|
||||
}, [boardActions, entryPoint, trackSetup]);
|
||||
|
||||
const invokeAction = (key: UniversalCreateActionKey) => {
|
||||
const boardAction = key in (boardActions ?? {}) ? boardActions?.[key as BoardActionKey] : undefined;
|
||||
actions.closeModal();
|
||||
trackSetup("intent-selected", {
|
||||
entryPoint,
|
||||
intent: key,
|
||||
outcome: "continued",
|
||||
hasBoardContext: boardActions !== undefined,
|
||||
});
|
||||
|
||||
if (boardAction) {
|
||||
boardAction();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "app":
|
||||
router.push("/manage/apps/new");
|
||||
break;
|
||||
case "integration":
|
||||
router.push("/manage/integrations/new");
|
||||
break;
|
||||
case "board":
|
||||
openAddBoardModal(undefined);
|
||||
break;
|
||||
case "workshop":
|
||||
router.push("/manage/workshop");
|
||||
break;
|
||||
case "customWidget":
|
||||
router.push("/manage/custom-widgets/new");
|
||||
break;
|
||||
case "widget":
|
||||
case "container":
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const resolvedActions = definitions.map(
|
||||
(definition) =>
|
||||
({
|
||||
...definition,
|
||||
name: t(`action.${definition.key}.label`),
|
||||
description: t(`action.${definition.key}.description`),
|
||||
keywords: t(`action.${definition.key}.keywords`).split(" "),
|
||||
}) satisfies RankedUniversalCreateAction,
|
||||
);
|
||||
const orderedActions = resolvedActions.toSorted((left, right) => right.priority - left.priority);
|
||||
const groups = ["currentBoard", "library", "boards"] satisfies UniversalCreateActionGroup[];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{groups.map((group) => {
|
||||
const groupActions = orderedActions.filter((action) => action.group === group);
|
||||
if (groupActions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack key={group} gap={4}>
|
||||
<Text c="dimmed" fw={600} size="xs" tt="uppercase" px="sm">
|
||||
{t(`group.${group}`)}
|
||||
</Text>
|
||||
{groupActions.map((action) => {
|
||||
const Icon = actionIcons[action.key];
|
||||
return (
|
||||
<UnstyledButton key={action.key} className={classes.action} onClick={() => invokeAction(action.key)}>
|
||||
<Group wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon className={classes.actionIcon} variant="light" radius="md">
|
||||
<Icon size={18} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={1}>
|
||||
<Text fw={600} size="sm">
|
||||
{action.name}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{action.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("universalCreate.title"),
|
||||
size: "lg",
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UniversalCreateModal } from "./universal-create-modal";
|
||||
|
||||
/** Keeps Universal Create available from ordinary Spotlight search, without requiring command mode. */
|
||||
export const UniversalCreateSpotlightResult = () => {
|
||||
const t = useScopedI18n("universalCreate");
|
||||
const { openModal } = useModalAction(UniversalCreateModal);
|
||||
const item = useMemo(
|
||||
() => ({
|
||||
id: "universal-create",
|
||||
name: t("title"),
|
||||
description: t("trigger.description"),
|
||||
icon: IconPlus,
|
||||
alwaysVisible: true,
|
||||
interaction: () => ({
|
||||
type: "javaScript" as const,
|
||||
onSelect: () => {
|
||||
openModal({ entryPoint: "spotlight" });
|
||||
},
|
||||
}),
|
||||
}),
|
||||
[openModal, t],
|
||||
);
|
||||
|
||||
useRegisterSpotlightContextResults("universal-create", [item], [item]);
|
||||
return null;
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { UniversalCreateModal } from "./universal-create-modal";
|
||||
|
||||
export const UniversalCreateHeaderAction = () => {
|
||||
const t = useScopedI18n("universalCreate");
|
||||
const { openModal } = useModalAction(UniversalCreateModal);
|
||||
|
||||
return (
|
||||
<HeaderButton onClick={() => openModal({ entryPoint: "header" })} aria-label={t("trigger.label")}>
|
||||
<IconPlus stroke={1.5} />
|
||||
</HeaderButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const UniversalCreateQueryGate = () => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { openModal } = useModalAction(UniversalCreateModal);
|
||||
const handled = useRef(false);
|
||||
const shouldOpen = searchParams.get("create") === "true";
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldOpen) {
|
||||
handled.current = false;
|
||||
return;
|
||||
}
|
||||
if (handled.current) return;
|
||||
|
||||
handled.current = true;
|
||||
openModal({ entryPoint: "spotlight" });
|
||||
|
||||
const nextSearchParams = new URLSearchParams(searchParams.toString());
|
||||
nextSearchParams.delete("create");
|
||||
const query = nextSearchParams.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
||||
}, [openModal, pathname, router, searchParams, shouldOpen]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Group, ScrollArea, Title, UnstyledButton } from "@mantine/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Button, Group, ScrollArea, Stack, Text, Title, UnstyledButton } from "@mantine/core";
|
||||
import { IconArrowLeft, IconX } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { getIntegrationName } from "@homarr/definitions";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
@@ -27,9 +28,28 @@ interface IntegrationSelectModalProps {
|
||||
|
||||
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const [step, setStep] = useState<"select" | "form">(innerProps.initialKind ? "form" : "select");
|
||||
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(innerProps.initialKind ?? null);
|
||||
const directKind =
|
||||
innerProps.initialKind ?? (innerProps.allowedKinds?.length === 1 ? innerProps.allowedKinds[0] : null);
|
||||
const isDirectForm = Boolean(directKind);
|
||||
const [step, setStep] = useState<"select" | "form">(isDirectForm ? "form" : "select");
|
||||
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(directKind ?? null);
|
||||
const { openModal: openCompletionModal } = useModalAction(IntegrationCompletionModal);
|
||||
const { data: integrationData, isError: isIntegrationDataError, refetch } = clientApi.integration.all.useQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (step === "form" && !isDirectForm) {
|
||||
actions.setCloseInterceptor?.(() => {
|
||||
setStep("select");
|
||||
setSelectedKind(null);
|
||||
return false;
|
||||
});
|
||||
} else {
|
||||
actions.setCloseInterceptor?.(null);
|
||||
}
|
||||
return () => {
|
||||
actions.setCloseInterceptor?.(null);
|
||||
};
|
||||
}, [step, isDirectForm, actions]);
|
||||
|
||||
const handleSelect = (kind: IntegrationKind) => {
|
||||
setSelectedKind(kind);
|
||||
@@ -40,7 +60,7 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
setStep("select");
|
||||
setSelectedKind(null);
|
||||
};
|
||||
const handleFormBack = innerProps.initialKind ? actions.closeModal : handleBack;
|
||||
const handleFormBack = isDirectForm ? actions.closeModal : handleBack;
|
||||
|
||||
const handleSuccess = (result?: CreatedIntegrationResult) => {
|
||||
actions.closeModal();
|
||||
@@ -52,12 +72,8 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
};
|
||||
|
||||
if (step === "form" && selectedKind) {
|
||||
let HeaderIcon = IconArrowLeft;
|
||||
let headerLabel = t("common.action.previous");
|
||||
if (innerProps.initialKind) {
|
||||
HeaderIcon = IconX;
|
||||
headerLabel = t("common.action.close");
|
||||
}
|
||||
const HeaderIcon = isDirectForm ? IconX : IconArrowLeft;
|
||||
const headerLabel = isDirectForm ? t("common.action.close") : t("common.action.previous");
|
||||
|
||||
return (
|
||||
<ScrollArea.Autosize mah="80vh">
|
||||
@@ -79,11 +95,25 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
|
||||
);
|
||||
}
|
||||
|
||||
if (isIntegrationDataError) {
|
||||
return (
|
||||
<Alert color="red" title={t("common.error")}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{t("integration.grid.loadError")}</Text>
|
||||
<Button variant="light" color="red" onClick={() => void refetch()}>
|
||||
{t("common.action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<IntegrationSelectGrid
|
||||
onSelect={handleSelect}
|
||||
enableMockIntegration={innerProps.enableMockIntegration}
|
||||
allowedKinds={innerProps.allowedKinds}
|
||||
integrationData={integrationData}
|
||||
/>
|
||||
);
|
||||
}).withOptions({
|
||||
|
||||
@@ -3,8 +3,6 @@ import { AppShellHeader, Group, UnstyledButton } from "@mantine/core";
|
||||
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
import { UniversalCreateHeaderAction, UniversalCreateQueryGate } from "~/components/create/universal-create-trigger";
|
||||
import { UniversalCreateSpotlightResult } from "~/components/create/universal-create-spotlight";
|
||||
import { ClientBurger } from "./header/burger";
|
||||
import { DesktopSearchInput, MobileSearchButton } from "./header/search";
|
||||
import { TourTarget } from "./header/tour-target";
|
||||
@@ -38,7 +36,6 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
<DesktopSearchInput />
|
||||
</TourTarget>
|
||||
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
|
||||
{hasNavigation && <UniversalCreateHeaderAction />}
|
||||
{actions}
|
||||
<MobileSearchButton />
|
||||
<TourTarget id="board-user-menu">
|
||||
@@ -46,8 +43,6 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
|
||||
</TourTarget>
|
||||
</Group>
|
||||
</Group>
|
||||
<UniversalCreateQueryGate />
|
||||
<UniversalCreateSpotlightResult />
|
||||
<LazySpotlight />
|
||||
</AppShellHeader>
|
||||
);
|
||||
|
||||
+3
-2
@@ -30,8 +30,9 @@
|
||||
"db:studio": "pnpm -F db studio",
|
||||
"dev": "pnpm -F nextjs dev",
|
||||
"dev:benchmark": "node --experimental-strip-types scripts/benchmarks/dev-board.mts",
|
||||
"dev:cli": "go -C tools/homarr-dev run .",
|
||||
"dev:cli:install": "go -C tools/homarr-dev install .",
|
||||
"dev:cli": "go -C tools/homarr-dev run -buildvcs=false .",
|
||||
"dev:cli:build": "go -C tools/homarr-dev build -buildvcs=false .",
|
||||
"dev:cli:install": "go -C tools/homarr-dev install -buildvcs=false .",
|
||||
"dev:docs": "pnpm -F @homarr/docs dev",
|
||||
"docker:docs": "docker compose -f apps/workshop/docker-compose.yml up --build docs",
|
||||
"docker:workshop": "docker compose -f apps/workshop/docker-compose.yml up --build workshop",
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { command, number, positional, string } from "@drizzle-team/brocli";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const environmentSeparator = "\u0000";
|
||||
const validEnvironmentName = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const validHarnessName = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
|
||||
|
||||
const demoEnvironment: ReadonlyArray<[string, string]> = [
|
||||
["DEMO_MODE", "true"],
|
||||
["DEMO_READ_ONLY", "false"],
|
||||
["UNSAFE_ENABLE_MOCK_INTEGRATION", "true"],
|
||||
];
|
||||
|
||||
type HarnessRuntimeOptions = {
|
||||
name: string;
|
||||
env?: string;
|
||||
port?: number;
|
||||
};
|
||||
|
||||
const runtimeOptions = () => ({
|
||||
name: positional("name").required().desc("Name used for the image, container, and data volume"),
|
||||
env: string("env").alias("e").desc("Environment override KEY=VALUE; repeat the flag for more values"),
|
||||
port: number("port").int().min(0).max(65535).desc("Host port, or 0 for an automatically assigned port"),
|
||||
});
|
||||
|
||||
function validateHarnessName(name: string): string {
|
||||
if (!validHarnessName.test(name)) {
|
||||
throw new Error("Harness name must be 1-63 characters and contain only letters, numbers, '.', '_' or '-'");
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
function imageName(name: string): string {
|
||||
return `homarr:${validateHarnessName(name)}`;
|
||||
}
|
||||
|
||||
function containerName(name: string): string {
|
||||
return `homarr-harness-${validateHarnessName(name)}`;
|
||||
}
|
||||
|
||||
function volumeName(name: string): string {
|
||||
return `homarr-harness-${validateHarnessName(name)}-data`;
|
||||
}
|
||||
|
||||
function hasErrorCode(error: unknown, code: string): boolean {
|
||||
return error instanceof Error && "code" in error && error.code === code;
|
||||
}
|
||||
|
||||
function commandError(commandName: string, error: unknown): Error {
|
||||
if (hasErrorCode(error, "ENOENT")) {
|
||||
return new Error(`${commandName} is required but was not found on PATH`);
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return new Error(`${commandName} failed: ${error.message}`);
|
||||
}
|
||||
|
||||
return new Error(`${commandName} failed`);
|
||||
}
|
||||
|
||||
async function execCommand(commandName: string, args: string[], cwd?: string): Promise<string> {
|
||||
try {
|
||||
const result = await execFileAsync(commandName, args, {
|
||||
cwd,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
return result.stdout.trim();
|
||||
} catch (error) {
|
||||
throw commandError(commandName, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCommand(commandName: string, args: string[], cwd?: string): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(commandName, args, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
child.once("error", (error) => reject(commandError(commandName, error)));
|
||||
child.once("close", (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const reason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
|
||||
reject(new Error(`${commandName} failed with ${reason}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function repositoryRoot(): Promise<string> {
|
||||
const root = await execCommand("git", ["rev-parse", "--show-toplevel"], process.cwd());
|
||||
|
||||
try {
|
||||
await access(join(root, "Dockerfile"), constants.R_OK);
|
||||
} catch {
|
||||
throw new Error("Run the harness command from a Homarr checkout containing a Dockerfile");
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function parseEnvironmentOverrides(raw?: string): Map<string, string> {
|
||||
const values = raw?.split(environmentSeparator) ?? [];
|
||||
const environment = new Map(demoEnvironment);
|
||||
|
||||
for (const value of values) {
|
||||
const separator = value.indexOf("=");
|
||||
if (separator <= 0) {
|
||||
throw new Error(`Invalid environment override ${JSON.stringify(value)}; use KEY=VALUE`);
|
||||
}
|
||||
|
||||
const name = value.slice(0, separator);
|
||||
if (!validEnvironmentName.test(name)) {
|
||||
throw new Error(`Invalid environment variable name ${JSON.stringify(name)}`);
|
||||
}
|
||||
|
||||
environment.set(name, value.slice(separator + 1));
|
||||
}
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
function isEncryptionKey(value: string | undefined): value is string {
|
||||
return value !== undefined && /^[a-f0-9]{64}$/i.test(value);
|
||||
}
|
||||
|
||||
async function readEncryptionKey(path: string): Promise<string | undefined> {
|
||||
try {
|
||||
const value = (await readFile(path, "utf8")).trim();
|
||||
if (isEncryptionKey(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid encryption key in ${path}`);
|
||||
} catch (error) {
|
||||
if (hasErrorCode(error, "ENOENT")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function harnessEncryptionKey(name: string): Promise<string> {
|
||||
if (isEncryptionKey(process.env.SECRET_ENCRYPTION_KEY)) {
|
||||
return process.env.SECRET_ENCRYPTION_KEY;
|
||||
}
|
||||
|
||||
const configRoot = process.env.HOMARR_HARNESS_CONFIG_DIR ?? join(homedir(), ".config", "homarr");
|
||||
const keyPath = join(configRoot, "local-harness", `${validateHarnessName(name)}.key`);
|
||||
const existingKey = await readEncryptionKey(keyPath);
|
||||
if (existingKey) {
|
||||
return existingKey;
|
||||
}
|
||||
|
||||
const newKey = randomBytes(32).toString("hex");
|
||||
await mkdir(join(configRoot, "local-harness"), { recursive: true, mode: 0o700 });
|
||||
|
||||
try {
|
||||
await writeFile(keyPath, `${newKey}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||
return newKey;
|
||||
} catch (error) {
|
||||
if (!hasErrorCode(error, "EEXIST")) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const racedKey = await readEncryptionKey(keyPath);
|
||||
if (racedKey) {
|
||||
return racedKey;
|
||||
}
|
||||
|
||||
throw new Error(`Could not read the harness encryption key at ${keyPath}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async function harnessEnvironment(name: string, raw?: string): Promise<string[]> {
|
||||
const environment = parseEnvironmentOverrides(raw);
|
||||
|
||||
if (!environment.has("SECRET_ENCRYPTION_KEY")) {
|
||||
environment.set("SECRET_ENCRYPTION_KEY", await harnessEncryptionKey(name));
|
||||
}
|
||||
|
||||
return Array.from(environment, ([key, value]) => `${key}=${value}`);
|
||||
}
|
||||
|
||||
async function buildHarnessImage(name: string): Promise<void> {
|
||||
const root = await repositoryRoot();
|
||||
const image = imageName(name);
|
||||
|
||||
console.log(`Building ${image} from ${root}`);
|
||||
await runCommand("docker", ["build", "--tag", image, "."], root);
|
||||
console.log(`Built ${image}`);
|
||||
}
|
||||
|
||||
async function removeExistingContainer(name: string): Promise<void> {
|
||||
try {
|
||||
await execCommand("docker", ["rm", "--force", containerName(name)]);
|
||||
} catch {
|
||||
// A missing container is the normal first-run path. Docker reports it as
|
||||
// an error, so the cleanup is intentionally best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
async function publishedPort(name: string): Promise<number> {
|
||||
const output = await execCommand("docker", ["port", containerName(name), "7575/tcp"]);
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
const match = line.trim().match(/:(\d+)$/);
|
||||
if (match) {
|
||||
return Number(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Container ${containerName(name)} has no published port for 7575/tcp`);
|
||||
}
|
||||
|
||||
async function startHarness(options: HarnessRuntimeOptions): Promise<void> {
|
||||
const name = validateHarnessName(options.name);
|
||||
const image = imageName(name);
|
||||
const container = containerName(name);
|
||||
const requestedPort = options.port ?? 0;
|
||||
const environment = await harnessEnvironment(name, options.env);
|
||||
|
||||
await removeExistingContainer(name);
|
||||
console.log(`Starting ${container} from ${image}`);
|
||||
await runCommand("docker", [
|
||||
"run",
|
||||
"--detach",
|
||||
"--name",
|
||||
container,
|
||||
"--publish",
|
||||
`${requestedPort}:7575`,
|
||||
"--volume",
|
||||
`${volumeName(name)}:/appdata`,
|
||||
...environment.flatMap((value) => ["--env", value]),
|
||||
image,
|
||||
]);
|
||||
|
||||
const port = await publishedPort(name);
|
||||
console.log(`Harness ${name} is running`);
|
||||
console.log(` Container : ${container}`);
|
||||
console.log(` Image : ${image}`);
|
||||
console.log(` Port : ${port}`);
|
||||
console.log(` URL : http://127.0.0.1:${port}`);
|
||||
}
|
||||
|
||||
export const normalizeHarnessEnvironmentArgs = (argv: string[]): string[] => {
|
||||
const normalized = argv.slice(0, 2);
|
||||
const args = argv.slice(2);
|
||||
const environmentValues: string[] = [];
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--env" || arg === "-e") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined) {
|
||||
normalized.push(arg);
|
||||
continue;
|
||||
}
|
||||
|
||||
environmentValues.push(value);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--env=")) {
|
||||
environmentValues.push(arg.slice("--env=".length));
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized.push(arg);
|
||||
}
|
||||
|
||||
if (environmentValues.length > 0) {
|
||||
normalized.push(`--env=${environmentValues.join(environmentSeparator)}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const harnessBuild = command({
|
||||
name: "build",
|
||||
desc: "Build the current checkout as a named local Homarr image",
|
||||
options: {
|
||||
name: positional("name").required().desc("Local image tag"),
|
||||
},
|
||||
handler: async ({ name }) => buildHarnessImage(name),
|
||||
});
|
||||
|
||||
const harnessRun = command({
|
||||
name: "run",
|
||||
aliases: ["start", "up"],
|
||||
desc: "Run a named local image in writable demo mode",
|
||||
options: runtimeOptions(),
|
||||
handler: async (options) => startHarness(options),
|
||||
});
|
||||
|
||||
const harnessSetup = command({
|
||||
name: "setup",
|
||||
aliases: ["create"],
|
||||
desc: "Build the current checkout and run its writable demo harness",
|
||||
options: runtimeOptions(),
|
||||
handler: async (options) => {
|
||||
await buildHarnessImage(options.name);
|
||||
await startHarness(options);
|
||||
},
|
||||
});
|
||||
|
||||
const harnessPort = command({
|
||||
name: "port",
|
||||
desc: "Show the published host port and URL for a harness",
|
||||
options: {
|
||||
name: positional("name").required().desc("Harness name"),
|
||||
},
|
||||
handler: async ({ name }) => {
|
||||
const port = await publishedPort(name);
|
||||
console.log(`Port: ${port}`);
|
||||
console.log(`URL: http://127.0.0.1:${port}`);
|
||||
},
|
||||
});
|
||||
|
||||
const harnessStop = command({
|
||||
name: "stop",
|
||||
aliases: ["down"],
|
||||
desc: "Stop a local harness container and keep its data volume",
|
||||
options: {
|
||||
name: positional("name").required().desc("Harness name"),
|
||||
},
|
||||
handler: async ({ name }) => {
|
||||
const container = containerName(name);
|
||||
await execCommand("docker", ["stop", container]);
|
||||
console.log(`Stopped ${container}`);
|
||||
},
|
||||
});
|
||||
|
||||
export const harnessRoot = command({
|
||||
name: "harness",
|
||||
aliases: ["local-harness"],
|
||||
desc: "Build and run a local branch in a browser-ready demo harness",
|
||||
subcommands: [harnessBuild, harnessRun, harnessSetup, harnessPort, harnessStop],
|
||||
});
|
||||
+38
-10
@@ -1,14 +1,42 @@
|
||||
import { run } from "@drizzle-team/brocli";
|
||||
import type { Command } from "@drizzle-team/brocli";
|
||||
|
||||
import { fixUsernames } from "./commands/fix-usernames";
|
||||
import { integrationsRoot } from "./commands/integrations";
|
||||
import { recreateAdmin } from "./commands/recreate-admin";
|
||||
import { resetPassword } from "./commands/reset-password";
|
||||
import { usersRoot } from "./commands/users";
|
||||
import { harnessRoot, normalizeHarnessEnvironmentArgs } from "./commands/harness";
|
||||
|
||||
const commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin];
|
||||
const harnessCommandNames = new Set(["harness", "local-harness"]);
|
||||
|
||||
void run(commands, {
|
||||
name: "homarr-cli",
|
||||
version: "1.0.0",
|
||||
});
|
||||
function isHarnessInvocation(argv: string[]): boolean {
|
||||
return argv.slice(2).some((argument) => harnessCommandNames.has(argument));
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const harnessInvocation = isHarnessInvocation(process.argv);
|
||||
let commands: Command[];
|
||||
|
||||
if (harnessInvocation) {
|
||||
commands = [harnessRoot];
|
||||
} else {
|
||||
const [{ fixUsernames }, { integrationsRoot }, { recreateAdmin }, { resetPassword }, { usersRoot }] =
|
||||
await Promise.all([
|
||||
import("./commands/fix-usernames"),
|
||||
import("./commands/integrations"),
|
||||
import("./commands/recreate-admin"),
|
||||
import("./commands/reset-password"),
|
||||
import("./commands/users"),
|
||||
]);
|
||||
commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin, harnessRoot];
|
||||
}
|
||||
|
||||
let argSource = process.argv;
|
||||
if (harnessInvocation) {
|
||||
argSource = normalizeHarnessEnvironmentArgs(process.argv);
|
||||
}
|
||||
|
||||
await run(commands, {
|
||||
argSource,
|
||||
name: "homarr-cli",
|
||||
version: "1.0.0",
|
||||
});
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -336,7 +336,6 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/whats-up-docker"
|
||||
| "/docs/widgets/clock"
|
||||
| "/docs/widgets/weather"
|
||||
| "/docs/widgets/today"
|
||||
| "/docs/widgets/air-quality"
|
||||
| "/docs/widgets/countdown"
|
||||
| "/docs/widgets/timer"
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { WidgetKind } from "../widget";
|
||||
export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
clock: "clock",
|
||||
weather: "weather",
|
||||
today: "today",
|
||||
airQuality: "air-quality",
|
||||
countdown: "countdown",
|
||||
timer: "timer",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export const widgetKinds = [
|
||||
"clock",
|
||||
"weather",
|
||||
"today",
|
||||
"airQuality",
|
||||
"countdown",
|
||||
"timer",
|
||||
@@ -63,7 +62,6 @@ export const widgetKinds = [
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
|
||||
today: { width: 2, height: 1 },
|
||||
airQuality: { width: 2, height: 1 },
|
||||
countdown: { width: 2, height: 1 },
|
||||
timer: { width: 2, height: 1 },
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AppSelectModal } from "./app-select-modal";
|
||||
|
||||
const AppSelectModalComponent = AppSelectModal.component;
|
||||
|
||||
const sampleApps = [
|
||||
{
|
||||
id: "app-1",
|
||||
name: "Sonarr",
|
||||
description: "Manage TV series downloads",
|
||||
iconUrl: "https://example.com/sonarr.png",
|
||||
href: "http://sonarr.local",
|
||||
},
|
||||
{
|
||||
id: "app-2",
|
||||
name: "Radarr",
|
||||
description: "Manage movie collections",
|
||||
iconUrl: "https://example.com/radarr.png",
|
||||
href: "http://radarr.local",
|
||||
},
|
||||
{
|
||||
id: "app-3",
|
||||
name: "Plex",
|
||||
description: "Stream your media collection",
|
||||
iconUrl: "https://example.com/plex.png",
|
||||
href: "http://plex.local",
|
||||
},
|
||||
];
|
||||
|
||||
const mockUseQuery = vi.fn(() => ({
|
||||
data: sampleApps,
|
||||
isPending: false,
|
||||
}));
|
||||
|
||||
const mockOpenQuickAddAppModal = vi.fn();
|
||||
|
||||
vi.mock("@homarr/api/client", () => ({
|
||||
clientApi: {
|
||||
app: {
|
||||
selectable: {
|
||||
useQuery: () => mockUseQuery(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/modals", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("@homarr/modals");
|
||||
return {
|
||||
...actual,
|
||||
useModalAction: () => ({
|
||||
openModal: mockOpenQuickAddAppModal,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string, values?: { count?: number }) => {
|
||||
const count = values?.count ?? 0;
|
||||
const translations: Record<string, string> = {
|
||||
"app.action.select.search": "Search apps",
|
||||
"app.action.select.title": "Select application",
|
||||
"app.action.select.selected": "Selected",
|
||||
"app.action.select.toggle": "Click to select",
|
||||
"app.action.select.noResults": "No apps found",
|
||||
"app.action.select.multiSelectTip": "Hold Ctrl or Command to select multiple apps",
|
||||
"app.action.select.selectedCount": `${count} selected`,
|
||||
"app.action.select.appsSelected": `${count} ${count === 1 ? "app" : "apps"} selected`,
|
||||
"app.action.select.customApplication": "Custom application",
|
||||
"app.action.select.application": "Application",
|
||||
"app.action.create.title": "Add custom app",
|
||||
"app.action.create.description": "Configure custom URL and icon",
|
||||
"common.action.discard": "Discard",
|
||||
"common.action.add": "Add",
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
vi.clearAllMocks();
|
||||
mockUseQuery.mockReturnValue({
|
||||
data: sampleApps,
|
||||
isPending: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
describe("AppSelectModal", () => {
|
||||
describe("single selection mode", () => {
|
||||
it("renders app cards with name, description, and icon", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Sonarr");
|
||||
expect(host.textContent).toContain("Radarr");
|
||||
expect(host.textContent).toContain("Plex");
|
||||
expect(host.textContent).toContain("Manage TV series downloads");
|
||||
});
|
||||
|
||||
it("selects an app and closes modal when card is clicked", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
|
||||
expect(sonarrCard).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
sonarrCard?.click();
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(sampleApps[0]);
|
||||
expect(closeModal).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("multi selection mode", () => {
|
||||
it("toggles multiple app selections and submits batch on button click", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelectMany = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
|
||||
const radarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Radarr"));
|
||||
|
||||
// Select Sonarr
|
||||
await act(async () => {
|
||||
sonarrCard?.click();
|
||||
});
|
||||
|
||||
// Modal stays open, 1 app selected
|
||||
expect(closeModal).not.toHaveBeenCalled();
|
||||
expect(host.textContent).toContain("1 app selected");
|
||||
|
||||
// Select Radarr
|
||||
await act(async () => {
|
||||
radarrCard?.click();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("2 apps selected");
|
||||
|
||||
// Submit multi select
|
||||
const submitBtn = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Add (2)"));
|
||||
expect(submitBtn).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
submitBtn?.click();
|
||||
});
|
||||
|
||||
expect(onSelectMany).toHaveBeenCalledWith([sampleApps[0], sampleApps[1]]);
|
||||
expect(closeModal).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("unselects an app when clicked a second time", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelectMany = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
|
||||
|
||||
// Select Sonarr
|
||||
await act(async () => {
|
||||
sonarrCard?.click();
|
||||
});
|
||||
expect(host.textContent).toContain("1 app selected");
|
||||
|
||||
// Unselect Sonarr
|
||||
await act(async () => {
|
||||
sonarrCard?.click();
|
||||
});
|
||||
expect(host.textContent).not.toContain("1 app selected");
|
||||
});
|
||||
|
||||
it("clears selection when Discard button is clicked", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelectMany = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelectMany, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const sonarrCard = Array.from(host.querySelectorAll("button")).find((btn) => btn.textContent?.includes("Sonarr"));
|
||||
await act(async () => {
|
||||
sonarrCard?.click();
|
||||
});
|
||||
expect(host.textContent).toContain("1 app selected");
|
||||
|
||||
const discardBtn = Array.from(host.querySelectorAll("button")).find((btn) =>
|
||||
btn.textContent?.includes("Discard"),
|
||||
);
|
||||
await act(async () => {
|
||||
discardBtn?.click();
|
||||
});
|
||||
|
||||
expect(host.textContent).not.toContain("app selected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("search and filtering", () => {
|
||||
it("filters apps in real-time by search query (case-insensitive)", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const searchInput = host.querySelector("input");
|
||||
expect(searchInput).not.toBeNull();
|
||||
|
||||
// Type "plex"
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(searchInput, "plex");
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("Plex");
|
||||
expect(host.textContent).not.toContain("Sonarr");
|
||||
expect(host.textContent).not.toContain("Radarr");
|
||||
});
|
||||
|
||||
it("selects the single matching app when Enter key is pressed in search input", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const searchInput = host.querySelector("input");
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(searchInput, "sonarr");
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
searchInput?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(sampleApps[0]);
|
||||
expect(closeModal).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("displays empty state message when search yields no results", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const searchInput = host.querySelector("input");
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
nativeInputValueSetter?.call(searchInput, "non-matching-query");
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("No apps found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom app creation card", () => {
|
||||
it("renders custom app creation card when withCreate is true", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: true }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Add custom app");
|
||||
expect(host.textContent).toContain("Configure custom URL and icon");
|
||||
});
|
||||
|
||||
it("omits custom app creation card when withCreate is false", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: false }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).not.toContain("Add custom app");
|
||||
});
|
||||
|
||||
it("opens QuickAddAppModal when custom app creation card is clicked in single select mode", async () => {
|
||||
const closeModal = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<AppSelectModalComponent actions={{ closeModal }} innerProps={{ onSelect, withCreate: true }} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const createCard = Array.from(host.querySelectorAll("button")).find((btn) =>
|
||||
btn.textContent?.includes("Add custom app"),
|
||||
);
|
||||
expect(createCard).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
createCard?.click();
|
||||
});
|
||||
|
||||
expect(mockOpenQuickAddAppModal).toHaveBeenCalledOnce();
|
||||
|
||||
// Trigger the onClose callback passed to QuickAddAppModal
|
||||
const options = mockOpenQuickAddAppModal.mock.calls[0]?.[0] as { onClose: (app: (typeof sampleApps)[0]) => void };
|
||||
const newCustomApp = {
|
||||
id: "custom-app-1",
|
||||
name: "Custom Home Assistant",
|
||||
description: "Local instance",
|
||||
iconUrl: "https://example.com/ha.png",
|
||||
href: "http://ha.local",
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
options.onClose(newCustomApp);
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(newCustomApp);
|
||||
expect(closeModal).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,25 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { IconCheck, IconPlus, IconApps } from "@tabler/icons-react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
Input,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { IconPlus, IconSearch } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
|
||||
import { selectGridCols, SelectableCard } from "@homarr/ui";
|
||||
|
||||
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
||||
|
||||
@@ -36,7 +49,15 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
|
||||
const selectedApps = useMemo(() => apps.filter((app) => selectedAppIds.has(app.id)), [apps, selectedAppIds]);
|
||||
|
||||
const handleSelect = (app: SelectableApp) => {
|
||||
const handleSelect = (app: SelectableApp, event?: React.MouseEvent) => {
|
||||
const isModifierPressed = multiSelect && Boolean(event?.shiftKey || event?.ctrlKey || event?.metaKey);
|
||||
|
||||
if (innerProps.onSelect && !isModifierPressed && selectedAppIds.size === 0) {
|
||||
innerProps.onSelect(app);
|
||||
actions.closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (multiSelect) {
|
||||
setSelectedAppIds((current) => {
|
||||
const next = new Set(current);
|
||||
@@ -44,22 +65,6 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
else next.add(app.id);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
innerProps.onSelect?.(app);
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (multiSelect) {
|
||||
innerProps.onSelectMany?.(selectedApps);
|
||||
actions.closeModal();
|
||||
return;
|
||||
}
|
||||
const [first] = filteredApps;
|
||||
if (first) {
|
||||
innerProps.onSelect?.(first);
|
||||
actions.closeModal();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,100 +81,145 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
||||
});
|
||||
};
|
||||
|
||||
const handleMultiSubmit = () => {
|
||||
innerProps.onSelectMany?.(selectedApps);
|
||||
actions.closeModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("app.action.select.search")}...`}
|
||||
ariaLabel={t("app.action.select.search")}
|
||||
onSearchKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
||||
handleSelect(filteredApps[0]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{innerProps.withCreate && (
|
||||
<CatalogItem
|
||||
height={selectGridCardHeight}
|
||||
label={t("app.action.create.title")}
|
||||
status={t("app.action.create.action")}
|
||||
onSelect={handleAddNewApp}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<IconPlus size={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{t("app.action.create.title")}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{t("app.action.create.description")}
|
||||
</Text>
|
||||
<Text size="xs" c="blue" fw={500} mt="auto">
|
||||
{t("app.action.create.action")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</CatalogItem>
|
||||
)}
|
||||
|
||||
{filteredApps.map((app) => (
|
||||
<CatalogItem
|
||||
key={app.id}
|
||||
height={selectGridCardHeight}
|
||||
label={app.name}
|
||||
status={
|
||||
multiSelect
|
||||
? selectedAppIds.has(app.id)
|
||||
? t("app.action.select.selected")
|
||||
: t("app.action.select.toggle")
|
||||
: t("app.action.select.action", { app: app.name })
|
||||
<Stack gap="md">
|
||||
{/* Top Search Input & Multi-Select Tip */}
|
||||
<Stack gap={6}>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={`${t("app.action.select.search")}...`}
|
||||
aria-label={t("app.action.select.search")}
|
||||
data-autofocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
||||
handleSelect(filteredApps[0]);
|
||||
}
|
||||
selected={multiSelect ? selectedAppIds.has(app.id) : undefined}
|
||||
onSelect={() => handleSelect(app)}
|
||||
>
|
||||
<Stack h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<img src={app.iconUrl} alt={app.name} width={22} height={22} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
|
||||
{app.name}
|
||||
</Text>
|
||||
{multiSelect && selectedAppIds.has(app.id) && (
|
||||
<IconCheck size={18} color="var(--mantine-primary-color-filled)" />
|
||||
)}
|
||||
</Group>
|
||||
<Tooltip label={app.description} multiline w={250} disabled={!app.description}>
|
||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
|
||||
{app.description ?? ""}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Text size="xs" c="blue" fw={500} mt="auto">
|
||||
{multiSelect
|
||||
? selectedAppIds.has(app.id)
|
||||
? t("app.action.select.selected")
|
||||
: t("app.action.select.toggle")
|
||||
: t("app.action.select.action", { app: app.name })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</CatalogItem>
|
||||
))}
|
||||
|
||||
{filteredApps.length === 0 && !isPending && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
|
||||
</Center>
|
||||
}}
|
||||
/>
|
||||
{multiSelect && (
|
||||
<Group justify="space-between" px={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("app.action.select.multiSelectTip")}
|
||||
</Text>
|
||||
{selectedAppIds.size > 0 && (
|
||||
<Badge variant="light" color="primaryColor" size="xs">
|
||||
{t("app.action.select.selectedCount", { count: selectedAppIds.size })}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
{multiSelect && (
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button leftSection={<IconApps size={16} />} disabled={selectedAppIds.size === 0} onClick={handleSubmit}>
|
||||
{t("app.action.select.multiple", { count: String(selectedAppIds.size) })}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{/* Scrollable Container with App Cards */}
|
||||
<ScrollArea.Autosize mah="70vh" offsetScrollbars>
|
||||
<Stack gap="md" pt="xs" pr="xs" px={4}>
|
||||
<SimpleGrid cols={selectGridCols} spacing="sm">
|
||||
{innerProps.withCreate && (
|
||||
<SelectableCard
|
||||
onClick={handleAddNewApp}
|
||||
style={{ borderStyle: "dashed" }}
|
||||
icon={
|
||||
<ThemeIcon variant="light" color="primaryColor" size={34} radius="md">
|
||||
<IconPlus size={20} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
title={t("app.action.create.title")}
|
||||
description={t("app.action.create.description")}
|
||||
footerLeft={
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("app.action.select.customApplication")}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{filteredApps.map((app) => (
|
||||
<AppCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
isSelected={selectedAppIds.has(app.id)}
|
||||
multiSelect={multiSelect}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{filteredApps.length === 0 && !isPending && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
|
||||
{/* Multi-Select Action Footer */}
|
||||
{multiSelect && selectedApps.length > 0 && (
|
||||
<Paper withBorder p="xs" radius="md" bg="light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8))">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" fw={600}>
|
||||
{t("app.action.select.appsSelected", { count: selectedApps.length })}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button variant="default" size="xs" onClick={() => setSelectedAppIds(new Set())}>
|
||||
{t("common.action.discard")}
|
||||
</Button>
|
||||
<Button color="primaryColor" size="xs" onClick={handleMultiSubmit}>
|
||||
{t("common.action.add")} ({selectedApps.length})
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</>
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("app.action.select.title"),
|
||||
size: modalSizeSelect,
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// AppCard: Variant 3 (Dashboard Inset) with Medium Title & Large App Icon
|
||||
// =========================================================================
|
||||
const AppCard = ({
|
||||
app,
|
||||
isSelected,
|
||||
multiSelect,
|
||||
onSelect,
|
||||
}: {
|
||||
app: SelectableApp;
|
||||
isSelected: boolean;
|
||||
multiSelect: boolean;
|
||||
onSelect: (app: SelectableApp, event?: React.MouseEvent) => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<SelectableCard
|
||||
onClick={(event) => onSelect(app, event)}
|
||||
aria-label={app.name}
|
||||
selected={isSelected}
|
||||
icon={<Image src={app.iconUrl} alt={app.name} w={28} h={28} fit="contain" style={{ flexShrink: 0 }} />}
|
||||
title={app.name}
|
||||
topRight={
|
||||
isSelected ? (
|
||||
<Badge variant="dot" color="primaryColor" size="xs">
|
||||
{t("app.action.select.selected")}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
description={app.description}
|
||||
footerLeft={
|
||||
<Text size="xs" c="dimmed">
|
||||
{multiSelect && isSelected ? t("app.action.select.selected") : t("app.action.select.application")}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -102,6 +102,13 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
|
||||
content: presentationClasses.inspectorContent,
|
||||
});
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
const canClose = modal.reference.onCloseRequest ? modal.reference.onCloseRequest() : true;
|
||||
if (canClose) {
|
||||
handleCloseModal();
|
||||
}
|
||||
}, [handleCloseModal, modal.reference]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
key={modal.id}
|
||||
@@ -123,7 +130,7 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
|
||||
{...otherModalProps}
|
||||
title={translateIfNecessary(t, modal.props.defaultTitle)}
|
||||
opened={opened}
|
||||
onClose={handleCloseModal}
|
||||
onClose={handleClose}
|
||||
>
|
||||
{modal.reference.content}
|
||||
</Modal>
|
||||
@@ -144,7 +151,6 @@ export const useModalAction = <TModal extends ModalDefinition>(modal: TModal) =>
|
||||
return {
|
||||
openModal: (innerProps: inferInnerProps<TModal>, options: OpenModalOptions | void) => {
|
||||
// void actually is undefined
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
context.openModalInner({ modal, innerProps, options: options ?? {} });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -94,6 +94,8 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
|
||||
const ModalContent = modal.modal.component;
|
||||
|
||||
const { innerProps, ...rest } = modal.props;
|
||||
let closeInterceptor: (() => boolean) | null = null;
|
||||
|
||||
const FullModal = () => {
|
||||
const context = useContext(ModalContext);
|
||||
|
||||
@@ -106,6 +108,9 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
|
||||
innerProps={innerProps}
|
||||
actions={{
|
||||
closeModal: () => context.closeModal(modal.id),
|
||||
setCloseInterceptor: (interceptor: (() => boolean) | null) => {
|
||||
closeInterceptor = interceptor;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -114,5 +119,11 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
|
||||
return {
|
||||
modalProps: rest,
|
||||
content: <FullModal />,
|
||||
onCloseRequest: () => {
|
||||
if (closeInterceptor) {
|
||||
return closeInterceptor();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -5,10 +5,12 @@ import type { stringOrTranslation } from "@homarr/translation";
|
||||
|
||||
import type { ModalPresentation } from "./modal-presentation";
|
||||
|
||||
export type ModalComponent<TInnerProps> = (props: {
|
||||
actions: { closeModal: () => void };
|
||||
innerProps: TInnerProps;
|
||||
}) => ReactNode;
|
||||
export interface ModalActions {
|
||||
closeModal: () => void;
|
||||
setCloseInterceptor?: (interceptor: (() => boolean) | null) => void;
|
||||
}
|
||||
|
||||
export type ModalComponent<TInnerProps> = (props: { actions: ModalActions; innerProps: TInnerProps }) => ReactNode;
|
||||
|
||||
export type CreateModalOptions = Pick<
|
||||
ModalOptions<unknown>,
|
||||
|
||||
@@ -104,7 +104,7 @@ const CredentialsSetup = () => {
|
||||
|
||||
return (
|
||||
<AccountShell title={t("title")} description={t("description")}>
|
||||
<form onSubmit={form.onSubmit((values) => void submitAsync(values))}>
|
||||
<form onSubmit={form.onSubmit((values) => submitAsync(values))}>
|
||||
<Stack gap="md">
|
||||
<TextInput label={t("username")} autoComplete="username" withAsterisk {...form.getInputProps("username")} />
|
||||
<UserCreatePasswordFields
|
||||
@@ -119,7 +119,7 @@ const CredentialsSetup = () => {
|
||||
{submitError}
|
||||
</Alert>
|
||||
) : null}
|
||||
<Button type="submit" size="md" loading={mutation.isPending} rightSection={<IconArrowRight size={18} />}>
|
||||
<Button type="submit" size="md" loading={form.submitting} rightSection={<IconArrowRight size={18} />}>
|
||||
{t("create")}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -236,61 +236,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"universalCreate": {
|
||||
"title": "Create",
|
||||
"analyticsError": "Usage metrics could not be recorded. Your action was not affected.",
|
||||
"trigger": {
|
||||
"label": "Create something",
|
||||
"description": "Add content, connect a service, or create a board from one place."
|
||||
},
|
||||
"search": {
|
||||
"label": "Search create actions",
|
||||
"placeholder": "What do you want to create?",
|
||||
"noResults": "No matching actions"
|
||||
},
|
||||
"group": {
|
||||
"currentBoard": "Current board",
|
||||
"library": "Apps and services",
|
||||
"boards": "Boards"
|
||||
},
|
||||
"action": {
|
||||
"widget": {
|
||||
"label": "Add widget",
|
||||
"description": "Add live information or controls to this board.",
|
||||
"keywords": "item dashboard content"
|
||||
},
|
||||
"app": {
|
||||
"label": "Add or create app",
|
||||
"description": "Place an app shortcut on this board or create one for your library.",
|
||||
"keywords": "shortcut bookmark application"
|
||||
},
|
||||
"integration": {
|
||||
"label": "Connect service",
|
||||
"description": "Connect a service so widgets can use its data.",
|
||||
"keywords": "integration provider connection"
|
||||
},
|
||||
"container": {
|
||||
"label": "Add container",
|
||||
"description": "Group related board content into a resizable section.",
|
||||
"keywords": "section group layout"
|
||||
},
|
||||
"board": {
|
||||
"label": "Create board",
|
||||
"description": "Start a new dashboard and add its first widget.",
|
||||
"keywords": "dashboard page"
|
||||
},
|
||||
"workshop": {
|
||||
"label": "Browse Workshop",
|
||||
"description": "Discover community-built widgets and install them.",
|
||||
"keywords": "community discover install"
|
||||
},
|
||||
"customWidget": {
|
||||
"label": "Create custom widget",
|
||||
"description": "Build a widget tailored to your own workflow.",
|
||||
"keywords": "code jsx developer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"title": "Users",
|
||||
"name": "User",
|
||||
@@ -876,7 +821,12 @@
|
||||
"title": "Select an app to add to this board",
|
||||
"toggle": "Click to select",
|
||||
"selected": "Selected",
|
||||
"multiple": "Add {count}"
|
||||
"multiple": "Add {count}",
|
||||
"multiSelectTip": "Tip: Hold Ctrl / ⌘ to select multiple apps at once",
|
||||
"selectedCount": "{count, plural, one {# selected} other {# selected}}",
|
||||
"appsSelected": "{count, plural, one {# app selected} other {# apps selected}}",
|
||||
"customApplication": "Custom application",
|
||||
"application": "Application"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create new app",
|
||||
@@ -1721,7 +1671,11 @@
|
||||
"grid": {
|
||||
"widgetCount": "{count} widgets",
|
||||
"noWidgets": "No widgets",
|
||||
"detected": "Detected via Docker"
|
||||
"detected": "Detected via Docker",
|
||||
"connected": "{count, plural, one {# connected} other {# connected}}",
|
||||
"tiedWidgets": "Tied widgets",
|
||||
"more": "+{count} more",
|
||||
"loadError": "Integrations could not be loaded. Try again before creating a new connection."
|
||||
},
|
||||
"category": {
|
||||
"dnsHole": "DNS",
|
||||
@@ -2135,6 +2089,7 @@
|
||||
"success": "Success",
|
||||
"beta": "Beta",
|
||||
"error": "Error",
|
||||
"analyticsError": "Setup analytics could not be recorded.",
|
||||
"action": {
|
||||
"add": "Add",
|
||||
"apply": "Apply",
|
||||
@@ -2810,6 +2765,8 @@
|
||||
"needsSetup": "Needs setup",
|
||||
"noConnectionRequired": "No connection required"
|
||||
},
|
||||
"customWidget": "Custom widget",
|
||||
"standalone": "Standalone",
|
||||
"missingIntegration": {
|
||||
"title": "Connect a service first",
|
||||
"noPermission": "This widget needs a compatible service connection. Ask an administrator to connect one, then try again."
|
||||
@@ -2826,6 +2783,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selection": {
|
||||
"summary": "{count, plural, one {# selected item} other {# selected items}}",
|
||||
"selected": "{count, plural, one {# selected} other {# selected}}",
|
||||
"removed": "{count, plural, one {# item removed} other {# items removed}}",
|
||||
"move": "Move",
|
||||
"moveTo": "Move selected items",
|
||||
"delete": "Delete",
|
||||
"clear": "Clear selection",
|
||||
"clearShortcut": "Clear selection (Escape)",
|
||||
"saveErrorTitle": "Unable to update items",
|
||||
"saveErrorMessage": "The changes could not be saved. The board was refreshed.",
|
||||
"moveUnavailableTitle": "Unable to move selected items",
|
||||
"moveUnavailableMessage": "The destination does not have enough room for the entire selection."
|
||||
},
|
||||
"moveResize": {
|
||||
"title": "Move / resize item",
|
||||
"entryLabel": "{name}, column {column}, row {row}",
|
||||
@@ -3465,50 +3436,6 @@
|
||||
"notificationsDenied": "Notifications are blocked in this browser."
|
||||
}
|
||||
},
|
||||
"today": {
|
||||
"name": "Today",
|
||||
"description": "Displays the current date and progress through the calendar.",
|
||||
"option": {
|
||||
"weekConvention": {
|
||||
"label": "Week numbering",
|
||||
"options": {
|
||||
"locale": "Use locale rules",
|
||||
"iso": "ISO 8601"
|
||||
}
|
||||
},
|
||||
"showWeekNumber": {
|
||||
"label": "Show week number"
|
||||
},
|
||||
"showDayOfYear": {
|
||||
"label": "Show day of year"
|
||||
},
|
||||
"showQuarter": {
|
||||
"label": "Show quarter"
|
||||
},
|
||||
"showYearProgress": {
|
||||
"label": "Show year progress"
|
||||
}
|
||||
},
|
||||
"weekNumber": "Week {week}",
|
||||
"dayOfYear": "Day {day} of {total}",
|
||||
"quarter": "Quarter {quarter}",
|
||||
"daysAfterToday": "{days, plural, =0 {Last day of the year} one {# day after today} other {# days after today}}",
|
||||
"weekProgressLabel": "Week progress",
|
||||
"monthProgressLabel": "Month progress",
|
||||
"yearProgressLabel": "Year progress",
|
||||
"yearProgress": "Year progress: {progress}%",
|
||||
"weekConvention": {
|
||||
"locale": "Week numbers follow your locale",
|
||||
"iso": "ISO 8601 week numbers"
|
||||
},
|
||||
"advanced": {
|
||||
"week": "Week number",
|
||||
"dayOfYear": "Day of year",
|
||||
"quarter": "Quarter",
|
||||
"quarterValue": "Q{quarter}",
|
||||
"daysRemaining": "Days after today"
|
||||
}
|
||||
},
|
||||
"minecraftServerStatus": {
|
||||
"name": "Minecraft Server Status",
|
||||
"description": "Displays the status of a Minecraft server",
|
||||
|
||||
@@ -13,6 +13,13 @@ export { CustomPasswordInput } from "./password-input/password-input";
|
||||
export { IntegrationAvatar } from "./integration-avatar";
|
||||
export { BetaBadge } from "./beta-badge";
|
||||
export { CatalogItem } from "./catalog-item";
|
||||
export { SelectableCard } from "./selectable-card";
|
||||
export type {
|
||||
SelectableCardProps,
|
||||
SelectableCardStylesNames,
|
||||
SelectableCardCssVariables,
|
||||
SelectableCardFactory,
|
||||
} from "./selectable-card";
|
||||
export { MaskedImage } from "./masked-image";
|
||||
export { MaskedOrNormalImage } from "./masked-or-normal-image";
|
||||
export { LanguageIcon } from "./language-icon";
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export { SelectableCard } from "./selectable-card";
|
||||
export type {
|
||||
SelectableCardProps,
|
||||
SelectableCardStylesNames,
|
||||
SelectableCardCssVariables,
|
||||
SelectableCardFactory,
|
||||
} from "./selectable-card";
|
||||
@@ -0,0 +1,79 @@
|
||||
.root {
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 175px;
|
||||
height: 100%;
|
||||
padding: var(--mantine-spacing-sm);
|
||||
border-radius: var(--selectable-card-radius, var(--mantine-radius-md));
|
||||
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
box-shadow: var(--mantine-shadow-xs);
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
box-shadow 150ms ease,
|
||||
border-color 150ms ease;
|
||||
overflow: hidden;
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.root:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.root:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.root[data-selected] {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
box-shadow: 0 0 0 1px var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.root:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.header {
|
||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-8));
|
||||
padding-top: var(--mantine-spacing-xs);
|
||||
padding-bottom: var(--mantine-spacing-xs);
|
||||
margin: calc(var(--mantine-spacing-sm) * -1);
|
||||
margin-bottom: 0;
|
||||
padding-left: var(--mantine-spacing-sm);
|
||||
padding-right: var(--mantine-spacing-sm);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--mantine-spacing-xs);
|
||||
margin-top: var(--mantine-spacing-xs);
|
||||
margin-bottom: var(--mantine-spacing-xs);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
border-top: 1px dashed var(--mantine-color-default-border);
|
||||
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
|
||||
padding-top: var(--mantine-spacing-xs);
|
||||
padding-bottom: var(--mantine-spacing-xs);
|
||||
margin: calc(var(--mantine-spacing-sm) * -1);
|
||||
margin-top: auto;
|
||||
padding-left: var(--mantine-spacing-sm);
|
||||
padding-right: var(--mantine-spacing-sm);
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
declare const classes: {
|
||||
readonly root: string;
|
||||
readonly header: string;
|
||||
readonly body: string;
|
||||
readonly footer: string;
|
||||
};
|
||||
export default classes;
|
||||
@@ -0,0 +1,370 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createRef } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { Badge, MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SelectableCard } from "./selectable-card";
|
||||
import classes from "./selectable-card.module.css";
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
describe("SelectableCard", () => {
|
||||
describe("rendering slots", () => {
|
||||
it("renders as a native button with default button type and accessible slots", async () => {
|
||||
const onClick = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
aria-label="Sonarr Card"
|
||||
icon={<span data-testid="card-icon">Icon</span>}
|
||||
title="Sonarr"
|
||||
topRight={<Badge data-testid="card-topright">Media</Badge>}
|
||||
description="Manage TV series downloads"
|
||||
footerLeft={<span data-testid="card-footer-left">Left Meta</span>}
|
||||
footerRight={<span data-testid="card-footer-right">Right Meta</span>}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
expect(button?.type).toBe("button");
|
||||
expect(button?.getAttribute("aria-label")).toBe("Sonarr Card");
|
||||
expect(host.querySelector("[data-testid='card-icon']")).not.toBeNull();
|
||||
expect(host.textContent).toContain("Sonarr");
|
||||
expect(host.querySelector("[data-testid='card-topright']")).not.toBeNull();
|
||||
expect(host.textContent).toContain("Manage TV series downloads");
|
||||
expect(host.querySelector("[data-testid='card-footer-left']")).not.toBeNull();
|
||||
expect(host.querySelector("[data-testid='card-footer-right']")).not.toBeNull();
|
||||
|
||||
await act(async () => button?.click());
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders only header when only header slots (icon, title, topRight) are provided", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
icon={<span data-testid="only-icon">I</span>}
|
||||
title="Only Header"
|
||||
topRight={<span data-testid="only-topright">TR</span>}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.querySelector(`.${classes.header}`)).not.toBeNull();
|
||||
expect(button?.querySelector(`.${classes.body}`)).toBeNull();
|
||||
expect(button?.querySelector(`.${classes.footer}`)).toBeNull();
|
||||
expect(host.querySelector("[data-testid='only-icon']")).not.toBeNull();
|
||||
expect(host.querySelector("[data-testid='only-topright']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders footer when only footerLeft or only footerRight is provided", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Test" footerLeft={<span data-testid="left-only">Left</span>} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector(`.${classes.footer}`)).not.toBeNull();
|
||||
expect(host.querySelector("[data-testid='left-only']")).not.toBeNull();
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Test" footerRight={<span data-testid="right-only">Right</span>} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector(`.${classes.footer}`)).not.toBeNull();
|
||||
expect(host.querySelector("[data-testid='right-only']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("applies Styles API classes for root, header, body, and footer", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
title="Plex"
|
||||
description="Media Server"
|
||||
footerLeft={<span>Footer</span>}
|
||||
classNames={{
|
||||
root: "custom-root",
|
||||
header: "custom-header",
|
||||
body: "custom-body",
|
||||
footer: "custom-footer",
|
||||
}}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.classList.contains(classes.root)).toBe(true);
|
||||
expect(button?.classList.contains("custom-root")).toBe(true);
|
||||
|
||||
const header = button?.querySelector(`.${classes.header}`);
|
||||
expect(header).not.toBeNull();
|
||||
expect(header?.classList.contains("custom-header")).toBe(true);
|
||||
|
||||
const body = button?.querySelector(`.${classes.body}`);
|
||||
expect(body).not.toBeNull();
|
||||
expect(body?.classList.contains("custom-body")).toBe(true);
|
||||
|
||||
const footer = button?.querySelector(`.${classes.footer}`);
|
||||
expect(footer).not.toBeNull();
|
||||
expect(footer?.classList.contains("custom-footer")).toBe(true);
|
||||
});
|
||||
|
||||
it("prioritizes title without wrapping and constrains topRight badge with ellipsis", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
title="Very Long Title Name That Should Not Wrap"
|
||||
topRight={<Badge>Category Badge</Badge>}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
const header = button?.querySelector(`.${classes.header}`);
|
||||
expect(header).not.toBeNull();
|
||||
|
||||
const titleText = header?.querySelector("p, span, div");
|
||||
expect(titleText?.textContent).toContain("Very Long Title Name That Should Not Wrap");
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
title={<span data-testid="custom-title-node">Custom Title Element</span>}
|
||||
topRight={<span data-testid="custom-top-right">Top Right</span>}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("[data-testid='custom-title-node']")).not.toBeNull();
|
||||
expect(host.querySelector("[data-testid='custom-top-right']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders custom children in the body slot over description", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Test" description="This should not appear">
|
||||
<div data-testid="custom-body-children">Custom Child Content</div>
|
||||
</SelectableCard>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("[data-testid='custom-body-children']")).not.toBeNull();
|
||||
expect(host.textContent).toContain("Custom Child Content");
|
||||
expect(host.textContent).not.toContain("This should not appear");
|
||||
});
|
||||
|
||||
it("renders an empty description without fallback copy", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="No Desc Item" description="" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("No Desc Item");
|
||||
expect(host.textContent).not.toContain("No description provided");
|
||||
});
|
||||
|
||||
it("omits header, body, or footer when corresponding props are omitted", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.querySelector(`.${classes.header}`)).toBeNull();
|
||||
expect(button?.querySelector(`.${classes.body}`)).toBeNull();
|
||||
expect(button?.querySelector(`.${classes.footer}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("states", () => {
|
||||
it("handles selected state via data-selected attribute", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Selected Item" selected />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.getAttribute("data-selected")).toBe("true");
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Unselected Item" selected={false} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(button?.hasAttribute("data-selected")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles disabled state and prevents click handlers", async () => {
|
||||
const onClick = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Disabled Item" disabled onClick={onClick} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.disabled).toBe(true);
|
||||
|
||||
await act(async () => button?.click());
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles loading state by disabling the button and showing LoadingOverlay", async () => {
|
||||
const onClick = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Loading Item" loading onClick={onClick} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.disabled).toBe(true);
|
||||
|
||||
await act(async () => button?.click());
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("interactions and accessibility", () => {
|
||||
it("supports focus and click activation", async () => {
|
||||
const onClick = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Interactive Card" onClick={onClick} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
button?.focus();
|
||||
expect(document.activeElement).toBe(button);
|
||||
|
||||
await act(async () => {
|
||||
button?.click();
|
||||
});
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("forwards ref to the underlying HTMLButtonElement", async () => {
|
||||
const ref = createRef<HTMLButtonElement>();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard ref={ref} title="Ref Card" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
|
||||
expect(ref.current?.tagName).toBe("BUTTON");
|
||||
});
|
||||
|
||||
it("resolves radius CSS variables via varsResolver", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Radius Card" radius="lg" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-lg)");
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard title="Radius Card Sm" radius="sm" />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(button?.style.getPropertyValue("--selectable-card-radius")).toBe("var(--mantine-radius-sm)");
|
||||
});
|
||||
|
||||
it("passes arbitrary HTML attributes down to the button element", async () => {
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<SelectableCard
|
||||
title="Custom Props Card"
|
||||
data-testid="my-selectable-card"
|
||||
data-custom="custom-value"
|
||||
tabIndex={0}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const button = host.querySelector("button");
|
||||
expect(button?.getAttribute("data-testid")).toBe("my-selectable-card");
|
||||
expect(button?.getAttribute("data-custom")).toBe("custom-value");
|
||||
expect(button?.tabIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
Box,
|
||||
createVarsResolver,
|
||||
factory,
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Text,
|
||||
Tooltip,
|
||||
useProps,
|
||||
useStyles,
|
||||
} from "@mantine/core";
|
||||
import type { BoxProps, ElementProps, Factory, MantineRadius, StylesApiProps } from "@mantine/core";
|
||||
|
||||
import classes from "./selectable-card.module.css";
|
||||
|
||||
export type SelectableCardStylesNames = "root" | "header" | "body" | "footer";
|
||||
export type SelectableCardCssVariables = {
|
||||
root: "--selectable-card-radius";
|
||||
};
|
||||
|
||||
export interface SelectableCardProps
|
||||
extends BoxProps, StylesApiProps<SelectableCardFactory>, Omit<ElementProps<"button">, "title"> {
|
||||
icon?: ReactNode;
|
||||
title?: ReactNode;
|
||||
topRight?: ReactNode;
|
||||
description?: string | null;
|
||||
footerLeft?: ReactNode;
|
||||
footerRight?: ReactNode;
|
||||
selected?: boolean;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
radius?: MantineRadius;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export type SelectableCardFactory = Factory<{
|
||||
props: SelectableCardProps;
|
||||
ref: HTMLButtonElement;
|
||||
stylesNames: SelectableCardStylesNames;
|
||||
vars: SelectableCardCssVariables;
|
||||
}>;
|
||||
|
||||
const defaultProps: Partial<SelectableCardProps> = {
|
||||
type: "button",
|
||||
};
|
||||
|
||||
const varsResolver = createVarsResolver<SelectableCardFactory>((_theme, { radius }) => ({
|
||||
root: {
|
||||
"--selectable-card-radius": radius ? `var(--mantine-radius-${radius})` : undefined,
|
||||
},
|
||||
}));
|
||||
|
||||
export const SelectableCard = factory<SelectableCardFactory>((_props) => {
|
||||
const props = useProps("SelectableCard", defaultProps, _props);
|
||||
const {
|
||||
ref,
|
||||
classNames,
|
||||
className,
|
||||
style,
|
||||
styles,
|
||||
unstyled,
|
||||
vars,
|
||||
attributes,
|
||||
icon,
|
||||
title,
|
||||
topRight,
|
||||
description,
|
||||
footerLeft,
|
||||
footerRight,
|
||||
selected,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
children,
|
||||
type = "button",
|
||||
...others
|
||||
} = props;
|
||||
|
||||
const getStyles = useStyles<SelectableCardFactory>({
|
||||
name: "SelectableCard",
|
||||
classes,
|
||||
props,
|
||||
className,
|
||||
style,
|
||||
classNames,
|
||||
styles,
|
||||
unstyled,
|
||||
vars,
|
||||
attributes,
|
||||
varsResolver,
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="button"
|
||||
{...others}
|
||||
type={type}
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
data-selected={selected || undefined}
|
||||
aria-pressed={selected !== undefined ? selected : undefined}
|
||||
{...getStyles("root")}
|
||||
>
|
||||
<LoadingOverlay visible={loading} loaderProps={{ size: "sm" }} />
|
||||
|
||||
{/* Header Section: Inset background with Title that never elides before the category */}
|
||||
{(icon || title || topRight) && (
|
||||
<Box {...getStyles("header")}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
gap="xs"
|
||||
style={{ width: "100%", overflow: "hidden" }}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap" style={{ flex: "1 1 auto", minWidth: 0, overflow: "hidden" }}>
|
||||
{icon}
|
||||
{typeof title === "string" ? (
|
||||
<Text
|
||||
fw={700}
|
||||
size="md"
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</Group>
|
||||
{topRight && (
|
||||
<Box
|
||||
style={{
|
||||
minWidth: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{topRight}
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Body: Custom Children or Default Description */}
|
||||
{children ? (
|
||||
<Box {...getStyles("body")}>{children}</Box>
|
||||
) : description !== undefined ? (
|
||||
<Box {...getStyles("body")}>
|
||||
<Tooltip label={description} multiline w={260} disabled={!description}>
|
||||
<Text size="sm" c="dimmed" lineClamp={2} lh={1.35}>
|
||||
{description}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{/* Footer Section: Inset Dashed Bottom Meta */}
|
||||
{(footerLeft || footerRight) && (
|
||||
<Box {...getStyles("footer")}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap" gap="xs" style={{ width: "100%" }}>
|
||||
<Box style={{ minWidth: 0, flexShrink: 1 }}>{footerLeft}</Box>
|
||||
<Box style={{ minWidth: 0, flexShrink: 0 }}>{footerRight}</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
SelectableCard.displayName = "@homarr/ui/SelectableCard";
|
||||
SelectableCard.classes = classes;
|
||||
@@ -0,0 +1,279 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
|
||||
import { IntegrationSelectGrid } from "./integration-select-grid";
|
||||
|
||||
vi.mock("@homarr/definitions", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@homarr/definitions")>();
|
||||
return {
|
||||
...actual,
|
||||
getWidgetKindsForIntegration: (kind: IntegrationKind) => {
|
||||
if (kind === "wud") return [];
|
||||
return actual.getWidgetKindsForIntegration(kind);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string, values?: { count?: number }) => {
|
||||
const translations: Record<string, string> = {
|
||||
"integration.page.list.search": "Search integrations",
|
||||
"common.noResults": "No results found",
|
||||
"integration.grid.noWidgets": "No widgets tied to this service",
|
||||
"integration.grid.tiedWidgets": "Tied Widgets",
|
||||
"integration.grid.connected": `${values?.count ?? 0} connected`,
|
||||
"integration.grid.more": `+${values?.count ?? 0} more`,
|
||||
"widget.mediaServer.name": "Media Server",
|
||||
"widget.calendar.name": "Calendar",
|
||||
"widget.downloads.name": "Downloads",
|
||||
"integration.category.mediaService": "Media Service",
|
||||
"integration.category.downloadClient": "Download Client",
|
||||
"integration.category.calendar": "Calendar",
|
||||
"integration.category.smartHomeServer": "Smart Home",
|
||||
"integration.category.usenet": "Usenet",
|
||||
"integration.category.torrent": "Torrent",
|
||||
};
|
||||
return translations[key] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
window.matchMedia = vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
describe("IntegrationSelectGrid", () => {
|
||||
it("renders a 4-column responsive grid of selectable integration cards", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr", "radarr", "sabNzbd"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const cards = host.querySelectorAll("button");
|
||||
expect(cards.length).toBe(4);
|
||||
expect(host.textContent).toContain("Jellyfin");
|
||||
expect(host.textContent).toContain("Sonarr");
|
||||
expect(host.textContent).toContain("Radarr");
|
||||
expect(host.textContent).toContain("SABnzbd");
|
||||
});
|
||||
|
||||
it("renders tied widgets with icons and labels inside integration cards", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Tied Widgets");
|
||||
// Jellyfin is tied to Media Server widget
|
||||
expect(host.textContent).toContain("Media Server");
|
||||
});
|
||||
|
||||
it("displays fallback message when integration has no tied widgets", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["wud"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.querySelector("button")).not.toBeNull();
|
||||
expect(host.textContent).toContain("No widgets tied to this service");
|
||||
});
|
||||
|
||||
it("displays '{count} connected' in footerLeft when integration instances exist", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const integrationData = [
|
||||
{ kind: "jellyfin" as IntegrationKind, name: "Home Jellyfin" },
|
||||
{ kind: "jellyfin" as IntegrationKind, name: "Remote Jellyfin" },
|
||||
{ kind: "sonarr" as IntegrationKind, name: "Sonarr Instance" },
|
||||
];
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid
|
||||
onSelect={onSelect}
|
||||
allowedKinds={["jellyfin", "radarr"]}
|
||||
integrationData={integrationData}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
// Jellyfin has 2 connected
|
||||
expect(host.textContent).toContain("2 connected");
|
||||
|
||||
// Radarr has 0 connected, so it doesn't show "0 connected"
|
||||
expect(host.textContent).not.toContain("0 connected");
|
||||
});
|
||||
|
||||
it("filters integrations dynamically by search input", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr", "radarr", "plex"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const searchInput = host.querySelector("input");
|
||||
expect(searchInput).not.toBeNull();
|
||||
|
||||
// Type "jelly"
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
nativeInputValueSetter?.call(searchInput, "jelly");
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
const cards = host.querySelectorAll("button");
|
||||
expect(cards.length).toBe(1);
|
||||
expect(host.textContent).toContain("Jellyfin");
|
||||
expect(host.textContent).not.toContain("Sonarr");
|
||||
expect(host.textContent).not.toContain("Radarr");
|
||||
});
|
||||
|
||||
it("displays empty state when no integrations match search query", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const searchInput = host.querySelector("input");
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
nativeInputValueSetter?.call(searchInput, "nonexistent-integration-query");
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("No results found");
|
||||
expect(host.querySelectorAll("button").length).toBe(0);
|
||||
});
|
||||
|
||||
it("calls onSelect when Enter is pressed with exactly 1 search match", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["jellyfin", "sonarr"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const searchInput = host.querySelector("input");
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
nativeInputValueSetter?.call(searchInput, "jelly");
|
||||
searchInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
searchInput.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
searchInput?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("jellyfin");
|
||||
});
|
||||
|
||||
it("calls onSelect when an integration card is clicked", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} allowedKinds={["sonarr"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const cardButton = host.querySelector("button");
|
||||
await act(async () => cardButton?.click());
|
||||
expect(onSelect).toHaveBeenCalledWith("sonarr");
|
||||
});
|
||||
|
||||
it("includes mock integration when enableMockIntegration is true", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} enableMockIntegration allowedKinds={["mock", "sonarr"]} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain("Mock");
|
||||
expect(host.textContent).toContain("Sonarr");
|
||||
});
|
||||
|
||||
it("excludes mock integration when enableMockIntegration is false", async () => {
|
||||
const onSelect = vi.fn();
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<MantineProvider>
|
||||
<IntegrationSelectGrid onSelect={onSelect} enableMockIntegration={false} />
|
||||
</MantineProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
const buttons = Array.from(host.querySelectorAll("button"));
|
||||
const mockButton = buttons.find((btn) => btn.getAttribute("aria-label")?.toLowerCase() === "mock");
|
||||
expect(mockButton).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge, Card, Center, Group, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { IconPuzzle } from "@tabler/icons-react";
|
||||
import { Badge, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import {
|
||||
IconAd,
|
||||
IconApps,
|
||||
IconBinaryTree,
|
||||
IconBookmark,
|
||||
IconBrandDocker,
|
||||
IconBrandMinecraft,
|
||||
IconBrowser,
|
||||
IconBuildingBank,
|
||||
IconBusStop,
|
||||
IconCalendar,
|
||||
IconChartBar,
|
||||
IconClock,
|
||||
IconClockPlay,
|
||||
IconCloud,
|
||||
IconDeviceCctv,
|
||||
IconDeviceGamepad,
|
||||
IconDownload,
|
||||
IconGraphFilled,
|
||||
IconHeartRateMonitor,
|
||||
IconHourglass,
|
||||
IconMessage,
|
||||
IconMovie,
|
||||
IconNotes,
|
||||
IconPuzzle,
|
||||
IconReportSearch,
|
||||
IconRobot,
|
||||
IconRocket,
|
||||
IconRss,
|
||||
IconSearch,
|
||||
IconServer2,
|
||||
IconTicket,
|
||||
IconTopologyFull,
|
||||
IconTransform,
|
||||
IconVideo,
|
||||
IconWall,
|
||||
IconWind,
|
||||
IconZoomQuestion,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { IntegrationAvatar } from "../components/integration-avatar";
|
||||
import { SelectGridLayout, selectGridCardHeight } from "../select-grid-layout";
|
||||
import { SelectableCard } from "../components/selectable-card";
|
||||
import { selectGridCols } from "../select-grid-layout";
|
||||
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
|
||||
import classes from "./integration-select-grid.module.css";
|
||||
import type { IntegrationGridItem } from "./integration-grid-shared";
|
||||
|
||||
export interface IntegrationSelectGridProps {
|
||||
onSelect: (kind: IntegrationKind) => void;
|
||||
enableMockIntegration?: boolean;
|
||||
allowedKinds?: readonly IntegrationKind[];
|
||||
integrationData?: { kind: IntegrationKind; name?: string }[];
|
||||
}
|
||||
|
||||
export const IntegrationSelectGrid = ({
|
||||
onSelect,
|
||||
enableMockIntegration = false,
|
||||
allowedKinds,
|
||||
integrationData,
|
||||
}: IntegrationSelectGridProps) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const t = useI18n();
|
||||
@@ -32,75 +73,182 @@ export const IntegrationSelectGrid = ({
|
||||
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
|
||||
|
||||
return (
|
||||
<SelectGridLayout
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder={`${t("integration.page.list.search")}...`}
|
||||
ariaLabel={t("integration.page.list.search")}
|
||||
>
|
||||
{filtered.map((integration) => (
|
||||
<UnstyledButton
|
||||
className={classes.choice}
|
||||
key={integration.kind}
|
||||
onClick={() => onSelect(integration.kind)}
|
||||
aria-label={integration.name}
|
||||
>
|
||||
<Card h={selectGridCardHeight} className={classes.card} withBorder>
|
||||
<Stack justify="space-between" h="100%" gap="xs">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
<IntegrationAvatar kind={integration.kind} size="sm" />
|
||||
<Text fw={500} lh={1.2} size="sm" lineClamp={2}>
|
||||
{integration.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<IntegrationGridMeta integration={integration} />
|
||||
</Stack>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Center p="xl" style={{ gridColumn: "1 / -1" }}>
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</SelectGridLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const IntegrationGridMeta = ({ integration }: { integration: ReturnType<typeof buildSortedIntegrations>[number] }) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Stack gap={4} mt="auto">
|
||||
<Group gap={4} wrap="wrap">
|
||||
{integration.categories.slice(0, 2).map((category) => (
|
||||
<Badge key={category} variant="light" size="xs">
|
||||
{categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
{integration.widgets.length > 0 ? (
|
||||
<Tooltip
|
||||
multiline
|
||||
w={200}
|
||||
label={
|
||||
<Stack gap={4}>
|
||||
{integration.widgets.map((widgetKind) => (
|
||||
<Text key={widgetKind} size="xs">
|
||||
{t(`widget.${widgetKind}.name`)}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
<Stack gap="md">
|
||||
{/* Top Search Input */}
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={`${t("integration.page.list.search")}...`}
|
||||
aria-label={t("integration.page.list.search")}
|
||||
data-autofocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && filtered.length === 1 && filtered[0]) {
|
||||
onSelect(filtered[0].kind);
|
||||
}
|
||||
>
|
||||
<Badge variant="light" color="blue" size="sm" leftSection={<IconPuzzle size={12} />}>
|
||||
{t("integration.grid.widgetCount", { count: String(integration.widgets.length) })}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{t("integration.grid.noWidgets")}
|
||||
</Badge>
|
||||
)}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Scrollable Container with Integration Cards */}
|
||||
<ScrollArea.Autosize mah="70vh" offsetScrollbars>
|
||||
<Stack gap="md" pt="xs" pr="xs" px={4}>
|
||||
<SimpleGrid cols={selectGridCols} spacing="sm">
|
||||
{filtered.map((integration) => (
|
||||
<IntegrationCard
|
||||
key={integration.kind}
|
||||
integration={integration}
|
||||
onSelect={onSelect}
|
||||
connectedCount={(integrationData ?? []).filter((i) => i.kind === integration.kind).length}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// IntegrationCard: Shared Modular SelectableCard with Tied Widgets
|
||||
// =========================================================================
|
||||
const IntegrationCard = ({
|
||||
integration,
|
||||
onSelect,
|
||||
connectedCount = 0,
|
||||
}: {
|
||||
integration: IntegrationGridItem;
|
||||
onSelect: (kind: IntegrationKind) => void;
|
||||
connectedCount?: number;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<SelectableCard
|
||||
onClick={() => onSelect(integration.kind)}
|
||||
aria-label={integration.name}
|
||||
icon={<IntegrationAvatar kind={integration.kind} size="sm" />}
|
||||
title={integration.name}
|
||||
topRight={<IntegrationCategoryBadges categories={integration.categories} limit={1} />}
|
||||
footerLeft={
|
||||
connectedCount > 0 ? (
|
||||
<Badge variant="light" color="teal" size="xs" radius="xs">
|
||||
{t("integration.grid.connected", { count: connectedCount })}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Text size="10px" tt="uppercase" fw={700} c="dimmed">
|
||||
{t("integration.grid.tiedWidgets")}
|
||||
</Text>
|
||||
<IntegrationTiedWidgets widgets={integration.widgets} />
|
||||
</SelectableCard>
|
||||
);
|
||||
};
|
||||
|
||||
const widgetIconsMap: Partial<Record<WidgetKind, typeof IconPuzzle>> = {
|
||||
clock: IconClock,
|
||||
weather: IconCloud,
|
||||
airQuality: IconWind,
|
||||
countdown: IconHourglass,
|
||||
timer: IconClockPlay,
|
||||
app: IconApps,
|
||||
assistant: IconRobot,
|
||||
iframe: IconBrowser,
|
||||
video: IconDeviceCctv,
|
||||
notebook: IconNotes,
|
||||
anchorNote: IconNotes,
|
||||
dnsHoleSummary: IconAd,
|
||||
dnsHoleControls: IconDeviceGamepad,
|
||||
"smartHome-entityState": IconBinaryTree,
|
||||
"smartHome-executeAutomation": IconBinaryTree,
|
||||
stockPrice: IconBuildingBank,
|
||||
mediaServer: IconVideo,
|
||||
calendar: IconCalendar,
|
||||
downloads: IconDownload,
|
||||
"mediaRequests-requestList": IconZoomQuestion,
|
||||
"mediaRequests-requestStats": IconChartBar,
|
||||
mediaTranscoding: IconTransform,
|
||||
mediaMissing: IconMovie,
|
||||
minecraftServerStatus: IconBrandMinecraft,
|
||||
networkControllerSummary: IconTopologyFull,
|
||||
networkControllerStatus: IconTopologyFull,
|
||||
rssFeed: IconRss,
|
||||
bookmarks: IconBookmark,
|
||||
indexerManager: IconReportSearch,
|
||||
healthMonitoring: IconHeartRateMonitor,
|
||||
releases: IconRocket,
|
||||
mediaReleases: IconTicket,
|
||||
dockerContainers: IconBrandDocker,
|
||||
firewall: IconWall,
|
||||
notifications: IconMessage,
|
||||
systemResources: IconGraphFilled,
|
||||
coolify: IconCloud,
|
||||
systemDisks: IconServer2,
|
||||
timetable: IconBusStop,
|
||||
};
|
||||
|
||||
const IntegrationTiedWidgets = ({ widgets, limit }: { widgets: WidgetKind[]; limit?: number }) => {
|
||||
const t = useI18n();
|
||||
if (widgets.length === 0) {
|
||||
return (
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
{t("integration.grid.noWidgets")}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const items = limit ? widgets.slice(0, limit) : widgets;
|
||||
const moreCount = limit ? widgets.length - limit : 0;
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{items.map((widgetKind) => {
|
||||
const WidgetIcon = widgetIconsMap[widgetKind] ?? IconPuzzle;
|
||||
return (
|
||||
<Badge
|
||||
key={widgetKind}
|
||||
variant="default"
|
||||
radius="xs"
|
||||
size="xs"
|
||||
leftSection={<WidgetIcon size={12} />}
|
||||
style={{ fontWeight: 500 }}
|
||||
>
|
||||
{t(`widget.${widgetKind}.name`)}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{moreCount > 0 && (
|
||||
<Badge variant="subtle" color="gray" size="xs" radius="xs">
|
||||
{t("integration.grid.more", { count: moreCount })}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const IntegrationCategoryBadges = ({
|
||||
categories,
|
||||
limit,
|
||||
}: {
|
||||
categories: IntegrationGridItem["categories"];
|
||||
limit?: number;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const items = limit ? categories.slice(0, limit) : categories;
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{items.map((category) => (
|
||||
<Badge key={category} variant="light" size="xs" color="gray" radius="xs">
|
||||
{categoryTranslationKeys[category] ? t(categoryTranslationKeys[category] as never) : category}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -13,7 +13,9 @@
|
||||
},
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
|
||||
"./inputs": "./src/_inputs/index.ts",
|
||||
"./inputs/form": "./src/_inputs/form.ts",
|
||||
"./inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
|
||||
"./custom-api/component": "./src/custom-api/component.tsx",
|
||||
"./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx",
|
||||
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
|
||||
|
||||
@@ -17,6 +17,12 @@ import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
const renderHereLink = (children: React.ReactNode) => (
|
||||
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
|
||||
{children}
|
||||
</Anchor>
|
||||
);
|
||||
|
||||
export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">) => {
|
||||
const t = useI18n();
|
||||
const tInput = useWidgetInputTranslation(kind, property);
|
||||
@@ -49,13 +55,9 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
|
||||
}
|
||||
inputWrapperOrder={["label", "input", "description", "error"]}
|
||||
description={
|
||||
<Text size="xs">
|
||||
<Text component="span" size="xs">
|
||||
{t.rich("widget.common.app.description", {
|
||||
here: () => (
|
||||
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
|
||||
{t("common.here")}
|
||||
</Anchor>
|
||||
),
|
||||
here: renderHereLink,
|
||||
})}
|
||||
</Text>
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
const t = useI18n();
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const appQuery = clientApi.app.byId.useQuery({ id: options.appId });
|
||||
const appQuery = clientApi.app.byId.useQuery({ id: options.appId }, { enabled: Boolean(options.appId) });
|
||||
const app = getUsableWidgetQueryData(appQuery);
|
||||
const href = getSafeAppHref(app?.href);
|
||||
useRegisterSpotlightContextResults(
|
||||
@@ -50,6 +50,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
|
||||
[app, href, options.openInNewTab],
|
||||
);
|
||||
|
||||
if (!options.appId) return <WidgetEmptyState />;
|
||||
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
|
||||
if (!app) return <WidgetEmptyState />;
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
IconBuildingBank,
|
||||
IconBusStop,
|
||||
IconCalendar,
|
||||
IconCalendarWeek,
|
||||
IconChartAreaLine,
|
||||
IconChartBar,
|
||||
IconClock,
|
||||
@@ -60,7 +59,6 @@ import type { TablerIcon } from "@homarr/ui";
|
||||
export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
|
||||
clock: IconClock,
|
||||
weather: IconCloud,
|
||||
today: IconCalendarWeek,
|
||||
airQuality: IconWind,
|
||||
countdown: IconHourglass,
|
||||
timer: IconClockPlay,
|
||||
|
||||
@@ -63,7 +63,6 @@ import * as beszelAlerts from "./beszel-alerts";
|
||||
import * as beszelSystemStats from "./beszel-system-stats";
|
||||
import * as video from "./video";
|
||||
import * as weather from "./weather";
|
||||
import * as today from "./today";
|
||||
import * as airQuality from "./air-quality";
|
||||
import * as countdown from "./countdown";
|
||||
import * as timer from "./timer";
|
||||
@@ -98,7 +97,6 @@ export { AssistantWidgetRendererProvider } from "./assistant/context";
|
||||
export const widgetImports = {
|
||||
clock,
|
||||
weather,
|
||||
today,
|
||||
airQuality,
|
||||
countdown,
|
||||
timer,
|
||||
|
||||
@@ -24,7 +24,6 @@ export type WidgetResources = {
|
||||
const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
|
||||
clock: () => import("./clock") as Promise<WidgetModule>,
|
||||
weather: () => import("./weather") as Promise<WidgetModule>,
|
||||
today: () => import("./today") as Promise<WidgetModule>,
|
||||
airQuality: () => import("./air-quality") as Promise<WidgetModule>,
|
||||
countdown: () => import("./countdown") as Promise<WidgetModule>,
|
||||
timer: () => import("./timer") as Promise<WidgetModule>,
|
||||
@@ -89,7 +88,6 @@ const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
|
||||
const componentLoaders: Record<WidgetKind, () => Promise<WidgetComponentModule>> = {
|
||||
clock: () => import("./clock/component") as Promise<WidgetComponentModule>,
|
||||
weather: () => import("./weather/component") as Promise<WidgetComponentModule>,
|
||||
today: () => import("./today/component") as Promise<WidgetComponentModule>,
|
||||
airQuality: () => import("./air-quality/component") as Promise<WidgetComponentModule>,
|
||||
countdown: () => import("./countdown/component") as Promise<WidgetComponentModule>,
|
||||
timer: () => import("./timer/component") as Promise<WidgetComponentModule>,
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
|
||||
appId?: string;
|
||||
integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
|
||||
onIntegrationSaved?: () => void;
|
||||
onOpenNewIntegration?: (onCreated?: (id: string) => void) => void;
|
||||
}
|
||||
|
||||
export const getSelectedWidgetIntegrations = (
|
||||
@@ -217,6 +218,17 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
handleSubmit(event);
|
||||
};
|
||||
|
||||
const canCreateIntegration = maxIntegrations === 1 || form.values.integrationIds.length < maxIntegrations;
|
||||
const handleOpenNewIntegration = innerProps.onOpenNewIntegration
|
||||
? () => {
|
||||
innerProps.onOpenNewIntegration?.((newId) => {
|
||||
if (newId) {
|
||||
form.setFieldValue("integrationIds", [...(maxIntegrations > 1 ? form.values.integrationIds : []), newId]);
|
||||
}
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const widgetFormContent = (
|
||||
<Stack>
|
||||
{canConfigureWidget && innerProps.integrationSupport && (
|
||||
@@ -225,6 +237,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
data={innerProps.integrationData}
|
||||
canSelectMultiple={maxIntegrations > 1}
|
||||
withAsterisk={integrationsRequired}
|
||||
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
|
||||
{...form.getInputProps("integrationIds")}
|
||||
/>
|
||||
)}
|
||||
@@ -413,6 +426,5 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
|
||||
return t("item.edit.title");
|
||||
},
|
||||
size: modalSizeForm,
|
||||
presentation: "inspector",
|
||||
closeOnClickOutside: false,
|
||||
});
|
||||
@@ -1,223 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Group,
|
||||
Paper,
|
||||
Progress,
|
||||
RingProgress,
|
||||
ScrollArea,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
VisuallyHidden,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { formatLocalizedDate } from "../common/locale";
|
||||
import { useWidgetNow } from "../common/use-widget-now";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { getTodayLayout, getTodayStats } from "./date-stats";
|
||||
|
||||
const formatProgress = (value: number) => Math.round(value);
|
||||
|
||||
export default function TodayWidget({ options, width, height, displayMode }: WidgetComponentProps<"today">) {
|
||||
const now = useWidgetNow("minute");
|
||||
const locale = useCurrentIntlLocale();
|
||||
const t = useScopedI18n("widget.today");
|
||||
|
||||
if (now === null) {
|
||||
return (
|
||||
<Stack h="100%" align="center" justify="center">
|
||||
<Text c="dimmed">—</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = getTodayStats(now, options.weekConvention, locale);
|
||||
if (displayMode === "advanced") {
|
||||
return <AdvancedToday now={now} locale={locale} options={options} stats={stats} />;
|
||||
}
|
||||
|
||||
const layout = getTodayLayout(width, height);
|
||||
const weekday = formatLocalizedDate(now, locale, { weekday: "long" });
|
||||
const month = formatLocalizedDate(now, locale, { month: "long" });
|
||||
const fullDate = formatLocalizedDate(now, locale, { dateStyle: "long" });
|
||||
|
||||
if (layout.tiny) {
|
||||
return (
|
||||
<Stack h="100%" align="center" justify="center" gap={0} px="xs">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600} truncate>
|
||||
{weekday} · {month}
|
||||
</Text>
|
||||
<Text component="time" dateTime={toDateTime(now)} fz={34} fw={700} lh={1}>
|
||||
{now.getDate()}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" justify="center" gap="xs" px="md" py="sm" style={{ overflow: "hidden" }}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600} truncate>
|
||||
{weekday}
|
||||
</Text>
|
||||
<Text component="time" dateTime={toDateTime(now)} fz={layout.showFullDate ? "lg" : 30} fw={700} truncate>
|
||||
{layout.showFullDate ? fullDate : `${month} ${now.getDate()}`}
|
||||
</Text>
|
||||
</Stack>
|
||||
{options.showYearProgress && (
|
||||
<Box>
|
||||
<RingProgress
|
||||
aria-hidden
|
||||
size={54}
|
||||
thickness={5}
|
||||
sections={[{ value: stats.yearProgress, color: "blue" }]}
|
||||
label={
|
||||
<Text size="xs" ta="center" fw={700}>
|
||||
{formatProgress(stats.yearProgress)}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<VisuallyHidden>
|
||||
<progress value={stats.yearProgress} max={100}>
|
||||
{t("yearProgress", { progress: formatProgress(stats.yearProgress) })}
|
||||
</progress>
|
||||
</VisuallyHidden>
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{layout.showStats && (
|
||||
<Group gap={6} wrap="wrap">
|
||||
{options.showWeekNumber && <Badge variant="light">{t("weekNumber", { week: stats.weekNumber })}</Badge>}
|
||||
{options.showDayOfYear && (
|
||||
<Badge variant="light">{t("dayOfYear", { day: stats.dayOfYear, total: stats.daysInYear })}</Badge>
|
||||
)}
|
||||
{layout.showQuarter && options.showQuarter && (
|
||||
<Badge variant="light">{t("quarter", { quarter: stats.quarter })}</Badge>
|
||||
)}
|
||||
{layout.showQuarter && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("daysAfterToday", { days: stats.daysAfterToday })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{layout.showMonthProgress && <ProgressMetric label={t("monthProgressLabel")} value={stats.monthProgress} />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface AdvancedTodayProps {
|
||||
now: Date;
|
||||
locale: string;
|
||||
options: WidgetComponentProps<"today">["options"];
|
||||
stats: ReturnType<typeof getTodayStats>;
|
||||
}
|
||||
|
||||
const AdvancedToday = ({ now, locale, options, stats }: AdvancedTodayProps) => {
|
||||
const t = useScopedI18n("widget.today");
|
||||
const numberFormatter = new Intl.NumberFormat(locale);
|
||||
const visibleMetrics = [
|
||||
options.showWeekNumber && { label: t("advanced.week"), value: numberFormatter.format(stats.weekNumber) },
|
||||
options.showDayOfYear && {
|
||||
label: t("advanced.dayOfYear"),
|
||||
value: `${numberFormatter.format(stats.dayOfYear)} / ${numberFormatter.format(stats.daysInYear)}`,
|
||||
},
|
||||
options.showQuarter && {
|
||||
label: t("advanced.quarter"),
|
||||
value: t("advanced.quarterValue", { quarter: stats.quarter }),
|
||||
},
|
||||
{ label: t("advanced.daysRemaining"), value: numberFormatter.format(stats.daysAfterToday) },
|
||||
].filter((metric): metric is { label: string; value: string } => Boolean(metric));
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" w="100%" type="auto" offsetScrollbars scrollbarSize={6}>
|
||||
<Stack gap="md" p="md">
|
||||
<Stack gap={0}>
|
||||
<Text component="time" dateTime={toDateTime(now)} fz="xl" fw={700}>
|
||||
{formatLocalizedDate(now, locale, { dateStyle: "full" })}
|
||||
</Text>
|
||||
{options.showWeekNumber && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(`weekConvention.${options.weekConvention}`)}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
|
||||
{visibleMetrics.map((metric) => (
|
||||
<Paper key={metric.label} withBorder p="sm">
|
||||
<Text size="xs" c="dimmed">
|
||||
{metric.label}
|
||||
</Text>
|
||||
<Text fz="xl" fw={700}>
|
||||
{metric.value}
|
||||
</Text>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
|
||||
<ProgressMetric label={t("weekProgressLabel")} value={stats.weekProgress} />
|
||||
<ProgressMetric label={t("monthProgressLabel")} value={stats.monthProgress} />
|
||||
{options.showYearProgress && <ProgressMetric label={t("yearProgressLabel")} value={stats.yearProgress} />}
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={{ base: 3, sm: 4, md: 6 }} spacing="xs">
|
||||
{Array.from({ length: 12 }, (_, monthIndex) => {
|
||||
const date = new Date(now.getFullYear(), monthIndex, 1);
|
||||
const isCurrent = monthIndex === stats.monthIndex;
|
||||
return (
|
||||
<Paper
|
||||
key={monthIndex}
|
||||
withBorder
|
||||
p="xs"
|
||||
bg={isCurrent ? "var(--mantine-primary-color-light)" : undefined}
|
||||
>
|
||||
<Text size="sm" fw={isCurrent ? 700 : 500} ta="center">
|
||||
{formatLocalizedDate(date, locale, { month: "short" })}
|
||||
</Text>
|
||||
{isCurrent && (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{now.getDate()}
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
const ProgressMetric = ({ label, value }: { label: string; value: number }) => {
|
||||
const roundedValue = formatProgress(value);
|
||||
return (
|
||||
<Box>
|
||||
<Group justify="space-between" gap="xs" mb={3}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" fw={600}>
|
||||
{roundedValue}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={value} aria-label={`${label}: ${roundedValue}%`} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const toDateTime = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getTodayLayout, getTodayStats } from "./date-stats";
|
||||
|
||||
describe("today date statistics", () => {
|
||||
it("accounts for leap years", () => {
|
||||
const stats = getTodayStats(new Date(2024, 11, 31, 12), "iso");
|
||||
expect(stats.dayOfYear).toBe(366);
|
||||
expect(stats.daysInYear).toBe(366);
|
||||
expect(stats.daysAfterToday).toBe(0);
|
||||
});
|
||||
|
||||
it("uses the requested week convention", () => {
|
||||
const date = new Date(2023, 0, 1, 12);
|
||||
expect(getTodayStats(date, "iso").weekNumber).toBe(52);
|
||||
expect(getTodayStats(date, "locale", "en-US").weekNumber).toBe(1);
|
||||
expect(getTodayStats(date, "locale", "de-DE").weekNumber).toBe(52);
|
||||
});
|
||||
|
||||
it("returns bounded calendar progress", () => {
|
||||
const stats = getTodayStats(new Date(2026, 7, 20, 12), "iso");
|
||||
expect(stats.quarter).toBe(3);
|
||||
expect(stats.weekProgress).toBeGreaterThanOrEqual(0);
|
||||
expect(stats.weekProgress).toBeLessThanOrEqual(100);
|
||||
expect(stats.monthProgress).toBeGreaterThanOrEqual(0);
|
||||
expect(stats.monthProgress).toBeLessThanOrEqual(100);
|
||||
expect(stats.yearProgress).toBeGreaterThanOrEqual(0);
|
||||
expect(stats.yearProgress).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("today compact layout", () => {
|
||||
it("keeps tiny widgets focused on the date", () => {
|
||||
expect(getTodayLayout(140, 120)).toMatchObject({ tiny: true, showStats: false });
|
||||
});
|
||||
|
||||
it("reveals secondary information only when both axes have room", () => {
|
||||
expect(getTodayLayout(240, 120)).toMatchObject({ showStats: true, showQuarter: false });
|
||||
expect(getTodayLayout(300, 180)).toEqual({
|
||||
tiny: false,
|
||||
showFullDate: true,
|
||||
showStats: true,
|
||||
showQuarter: true,
|
||||
showMonthProgress: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,172 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import dayOfYear from "dayjs/plugin/dayOfYear";
|
||||
import quarterOfYear from "dayjs/plugin/quarterOfYear";
|
||||
|
||||
dayjs.extend(dayOfYear);
|
||||
dayjs.extend(quarterOfYear);
|
||||
|
||||
export type WeekConvention = "locale" | "iso";
|
||||
|
||||
export interface TodayStats {
|
||||
dayOfYear: number;
|
||||
daysInYear: number;
|
||||
daysAfterToday: number;
|
||||
monthIndex: number;
|
||||
quarter: number;
|
||||
weekNumber: number;
|
||||
weekProgress: number;
|
||||
monthProgress: number;
|
||||
yearProgress: number;
|
||||
}
|
||||
|
||||
const getProgress = (value: number, start: number, end: number) => {
|
||||
if (end <= start) return 0;
|
||||
return Math.min(100, Math.max(0, ((value - start) / (end - start)) * 100));
|
||||
};
|
||||
|
||||
export const getTodayStats = (date: Date, weekConvention: WeekConvention, locale = "en-US"): TodayStats => {
|
||||
const value = dayjs(date);
|
||||
const yearStart = value.startOf("year");
|
||||
const nextYearStart = yearStart.add(1, "year");
|
||||
const monthStart = value.startOf("month");
|
||||
const nextMonthStart = monthStart.add(1, "month");
|
||||
const weekRules = getWeekRules(weekConvention, locale);
|
||||
const weekDay = value.day() === 0 ? 7 : value.day();
|
||||
const daysSinceWeekStart = (weekDay - weekRules.firstDay + 7) % 7;
|
||||
const weekStart = new Date(date.getFullYear(), date.getMonth(), date.getDate() - daysSinceWeekStart);
|
||||
const nextWeekStart = new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate() + 7);
|
||||
const currentDayOfYear = value.dayOfYear();
|
||||
const daysInYear = yearStart.endOf("year").dayOfYear();
|
||||
|
||||
return {
|
||||
dayOfYear: currentDayOfYear,
|
||||
daysInYear,
|
||||
daysAfterToday: daysInYear - currentDayOfYear,
|
||||
monthIndex: value.month(),
|
||||
quarter: value.quarter(),
|
||||
weekNumber: getWeekNumber(date, weekRules),
|
||||
weekProgress: getProgress(value.valueOf(), weekStart.getTime(), nextWeekStart.getTime()),
|
||||
monthProgress: getProgress(value.valueOf(), monthStart.valueOf(), nextMonthStart.valueOf()),
|
||||
yearProgress: getProgress(value.valueOf(), yearStart.valueOf(), nextYearStart.valueOf()),
|
||||
};
|
||||
};
|
||||
|
||||
interface WeekRules {
|
||||
firstDay: number;
|
||||
minimalDays: number;
|
||||
}
|
||||
|
||||
interface LocaleWeekInfo {
|
||||
firstDay: number;
|
||||
minimalDays?: number;
|
||||
}
|
||||
|
||||
type LocaleWithWeekInfo = Intl.Locale & {
|
||||
weekInfo?: LocaleWeekInfo;
|
||||
getWeekInfo?: () => LocaleWeekInfo;
|
||||
};
|
||||
|
||||
const fourDayMinimumRegions = new Set([
|
||||
"AD",
|
||||
"AN",
|
||||
"AT",
|
||||
"AX",
|
||||
"BE",
|
||||
"BG",
|
||||
"CH",
|
||||
"CZ",
|
||||
"DE",
|
||||
"DK",
|
||||
"EE",
|
||||
"ES",
|
||||
"FI",
|
||||
"FJ",
|
||||
"FO",
|
||||
"FR",
|
||||
"GF",
|
||||
"GB",
|
||||
"GG",
|
||||
"GI",
|
||||
"GP",
|
||||
"GR",
|
||||
"IE",
|
||||
"IM",
|
||||
"IS",
|
||||
"IT",
|
||||
"LI",
|
||||
"LT",
|
||||
"LU",
|
||||
"MC",
|
||||
"MQ",
|
||||
"NL",
|
||||
"NO",
|
||||
"PL",
|
||||
"PT",
|
||||
"RE",
|
||||
"RU",
|
||||
"SE",
|
||||
"SJ",
|
||||
"SK",
|
||||
"VA",
|
||||
]);
|
||||
|
||||
const getWeekRules = (convention: WeekConvention, locale: string): WeekRules => {
|
||||
if (convention === "iso") return { firstDay: 1, minimalDays: 4 };
|
||||
try {
|
||||
const localeInfo = new Intl.Locale(locale) as LocaleWithWeekInfo;
|
||||
const weekInfo = localeInfo.weekInfo ?? localeInfo.getWeekInfo?.();
|
||||
if (weekInfo) {
|
||||
const region = localeInfo.maximize().region;
|
||||
const fallbackMinimalDays = region && fourDayMinimumRegions.has(region) ? 4 : 1;
|
||||
return { firstDay: weekInfo.firstDay, minimalDays: weekInfo.minimalDays ?? fallbackMinimalDays };
|
||||
}
|
||||
} catch {
|
||||
// Invalid locales fall back to the common US week convention.
|
||||
}
|
||||
return { firstDay: 7, minimalDays: 1 };
|
||||
};
|
||||
|
||||
const getWeekNumber = (date: Date, rules: WeekRules): number => {
|
||||
const dateDay = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
let weekYear = date.getFullYear();
|
||||
let firstWeekStart = getFirstWeekStart(weekYear, rules);
|
||||
|
||||
if (dateDay < firstWeekStart) {
|
||||
weekYear -= 1;
|
||||
firstWeekStart = getFirstWeekStart(weekYear, rules);
|
||||
} else {
|
||||
const nextFirstWeekStart = getFirstWeekStart(weekYear + 1, rules);
|
||||
if (dateDay >= nextFirstWeekStart) return 1;
|
||||
}
|
||||
|
||||
return Math.floor((dateDay - firstWeekStart) / (7 * 86_400_000)) + 1;
|
||||
};
|
||||
|
||||
const getFirstWeekStart = (year: number, rules: WeekRules): number => {
|
||||
const januaryFirst = Date.UTC(year, 0, 1);
|
||||
const sundayBasedWeekDay = new Date(januaryFirst).getUTCDay();
|
||||
const weekDay = sundayBasedWeekDay === 0 ? 7 : sundayBasedWeekDay;
|
||||
const daysBeforeJanuary = (weekDay - rules.firstDay + 7) % 7;
|
||||
const daysInFirstWeek = 7 - daysBeforeJanuary;
|
||||
if (daysInFirstWeek >= rules.minimalDays) return januaryFirst - daysBeforeJanuary * 86_400_000;
|
||||
return januaryFirst + daysInFirstWeek * 86_400_000;
|
||||
};
|
||||
|
||||
export interface TodayLayout {
|
||||
tiny: boolean;
|
||||
showFullDate: boolean;
|
||||
showStats: boolean;
|
||||
showQuarter: boolean;
|
||||
showMonthProgress: boolean;
|
||||
}
|
||||
|
||||
export const getTodayLayout = (width: number, height: number): TodayLayout => {
|
||||
const tiny = width < 150 || height < 96;
|
||||
return {
|
||||
tiny,
|
||||
showFullDate: !tiny && width >= 180,
|
||||
showStats: !tiny && width >= 210 && height >= 115,
|
||||
showQuarter: width >= 240 && height >= 145,
|
||||
showMonthProgress: width >= 300 && height >= 170,
|
||||
};
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import { IconCalendarWeek } from "@tabler/icons-react";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("today", {
|
||||
icon: IconCalendarWeek,
|
||||
supportsAdvancedFocus: true,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
weekConvention: factory.select({
|
||||
defaultValue: "locale",
|
||||
options: [
|
||||
{ value: "locale", label: (t) => t("widget.today.option.weekConvention.options.locale") },
|
||||
{ value: "iso", label: (t) => t("widget.today.option.weekConvention.options.iso") },
|
||||
],
|
||||
}),
|
||||
showWeekNumber: factory.switch({ defaultValue: true }),
|
||||
showDayOfYear: factory.switch({ defaultValue: true }),
|
||||
showQuarter: factory.switch({ defaultValue: true }),
|
||||
showYearProgress: factory.switch({ defaultValue: true }),
|
||||
}));
|
||||
},
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
@@ -32,6 +32,7 @@ interface WidgetIntegrationSelectProps {
|
||||
canSelectMultiple?: boolean;
|
||||
data: IntegrationSelectOption[];
|
||||
withAsterisk?: boolean;
|
||||
onOpenNewIntegration?: () => void;
|
||||
}
|
||||
export const WidgetIntegrationSelect = ({
|
||||
data,
|
||||
@@ -40,6 +41,7 @@ export const WidgetIntegrationSelect = ({
|
||||
label,
|
||||
canSelectMultiple = true,
|
||||
withAsterisk = false,
|
||||
onOpenNewIntegration,
|
||||
...props
|
||||
}: WidgetIntegrationSelectProps) => {
|
||||
const t = useI18n();
|
||||
@@ -109,11 +111,19 @@ export const WidgetIntegrationSelect = ({
|
||||
<PillsInput
|
||||
inputWrapperOrder={["label", "input", "description", "error"]}
|
||||
description={
|
||||
<Text size="xs" span>
|
||||
<Anchor size="xs" component={Link} target="_blank" href="/manage/integrations">
|
||||
{t("widget.common.integration.manage")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
onOpenNewIntegration ? (
|
||||
<Text size="xs" span>
|
||||
<Anchor size="xs" component="button" type="button" onClick={onOpenNewIntegration}>
|
||||
{t("integration.action.create")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" span>
|
||||
<Anchor size="xs" component={Link} target="_blank" href="/manage/integrations">
|
||||
{t("widget.common.integration.manage")}
|
||||
</Anchor>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
pointer
|
||||
onClick={() => combobox.openDropdown()}
|
||||
|
||||
+42
-25
@@ -56,52 +56,69 @@ homarr dash Start on the instance dashboard
|
||||
homarr build <name> Build this checkout as homarr:<name>
|
||||
homarr build --pr <number> Build a PR locally from a temporary checkout
|
||||
homarr rebuild <name> Rebuild from recorded provenance
|
||||
homarr images List local images with build provenance
|
||||
homarr data List instance data volumes
|
||||
homarr prune Remove stopped instances
|
||||
homarr list Script-friendly instance list
|
||||
homarr logs <container> Follow logs
|
||||
homarr shell <container> Open an interactive shell in a running instance
|
||||
homarr ci [pr] [--watch] View or watch GitHub CI checks for a pull request
|
||||
homarr open <container> Open in browser
|
||||
homarr stop <container> Stop
|
||||
homarr restart <container> Restart
|
||||
homarr remove <container> Force-remove
|
||||
homarr doctor Check Docker and optional CLI integrations
|
||||
homarr doctor Check Docker, Engine API, and optional integrations
|
||||
```
|
||||
|
||||
## Development Browser
|
||||
|
||||
```text
|
||||
/ Filter by PR number, title, author, branch, CI, image, state, or port
|
||||
↑/↓ or j/k Select
|
||||
Enter or Space Start the matching local build, otherwise pull remote; stop when running
|
||||
R Build a remote PR locally in the background or rebuild a local image
|
||||
p Pull a remote PR image and redeploy, preserving port and volume
|
||||
m Toggle demo mode for the next launch
|
||||
↑/↓ or j/k Select row
|
||||
Enter or Space Start the available image; choose local or remote when both exist; stop when running
|
||||
R Build locally in background (or rebuild local image)
|
||||
p Pull remote PR image in background and start container
|
||||
m Manage mode (chords: b rebuild, i delete image, d delete data, c remove container, p prune)
|
||||
t Background tasks overlay (status, progress, cancellation)
|
||||
Tab Cycle sidebar tab (logs / build / ci)
|
||||
l Toggle sidebar visibility
|
||||
L Pin logs to currently selected container
|
||||
f Follow or pause logs
|
||||
Page Up/Down Scroll logs
|
||||
M Toggle demo mode for next launch
|
||||
b Toggle bot PRs
|
||||
o Open PR on GitHub
|
||||
a Open running app
|
||||
l Toggle inline logs for the selected running PR
|
||||
f Follow or pause inline logs
|
||||
Page Up/Down Scroll inline logs
|
||||
b Show or hide bot PRs
|
||||
r Refresh
|
||||
d Switch to the instance dashboard
|
||||
q Quit; during a pull, cancel it
|
||||
a Open running instance in browser
|
||||
c Copy instance URL to clipboard
|
||||
r Refresh data
|
||||
1 / 2 / d Switch screen (development / instances)
|
||||
? Help overlay
|
||||
q Quit (cancels background tasks)
|
||||
```
|
||||
|
||||
Pull progress, layer completion, local build provenance, selected PR details, CI state, image availability, inline logs, and the final port remain inside the TUI.
|
||||
Local PR images are folded into their remote PR row only when the recorded revision exactly matches the current GitHub head SHA. They are marked `◆ local` and preferred by Enter; outdated builds remain separate local rows. Navigation and the `d` dashboard toggle remain available while a local build runs.
|
||||
Pull progress with live layer bytes, throughput, BuildKit step streaming, local build provenance, CI rollup badges, container health status, and live output streams are non-blocking. When a background pull completes, the sidebar automatically transitions to streaming the running instance's live logs.
|
||||
|
||||
## Instance Dashboard
|
||||
|
||||
```text
|
||||
/ Filter by name, image, state, health, port, or URL
|
||||
↑/↓ or j/k Select
|
||||
l Toggle inline logs
|
||||
f Follow or pause logs
|
||||
↑/↓ or j/k Select row
|
||||
Enter or Space Start / stop selected instance
|
||||
s Stop instance
|
||||
S Restart instance
|
||||
x Remove container (keeps data)
|
||||
m Manage mode for selected instance
|
||||
t Background tasks overlay
|
||||
Tab Cycle sidebar tab (logs / build / ci)
|
||||
l Toggle sidebar visibility
|
||||
L Pin logs
|
||||
f Follow / pause logs
|
||||
Page Up/Down Scroll logs
|
||||
s Stop
|
||||
R Restart
|
||||
x Remove with confirmation
|
||||
o Open in browser
|
||||
c Copy URL
|
||||
o Open running instance in browser
|
||||
c Copy URL to clipboard
|
||||
R Rebuild from recorded provenance
|
||||
r Refresh
|
||||
d Switch to the development browser
|
||||
1 / 2 / d Switch screen
|
||||
? Help overlay
|
||||
q Quit
|
||||
```
|
||||
@@ -0,0 +1,74 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/run"
|
||||
)
|
||||
|
||||
func (a *App) buildCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "build [name]",
|
||||
Short: "Build a local Homarr image from this checkout or a pull request",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
tag := ""
|
||||
if len(args) > 0 {
|
||||
tag = args[0]
|
||||
}
|
||||
if a.buildPR < 0 {
|
||||
return fmt.Errorf("PR number must be positive")
|
||||
}
|
||||
if a.buildPR > 0 {
|
||||
if tag == "" {
|
||||
tag = fmt.Sprintf("pr-%d", a.buildPR)
|
||||
}
|
||||
return run.BuildPRImage(a.buildPR, tag)
|
||||
}
|
||||
if tag == "" {
|
||||
return fmt.Errorf("image name is required when --pr is not set")
|
||||
}
|
||||
return run.BuildCurrentImage(tag, ".")
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVarP(&a.buildPR, "pr", "p", 0, "Build a pull request in a temporary checkout")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (a *App) rebuildCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "rebuild <name>",
|
||||
Short: "Rebuild a local image from its recorded checkout or pull request",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
image, err := findLocalImage(cmd, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return run.RebuildImage(image)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// findLocalImage resolves a tag or full reference to a local image, so both
|
||||
// `homarr rebuild dev` and `homarr rebuild homarr:dev` work.
|
||||
func findLocalImage(cmd *cobra.Command, name string) (docker.Image, error) {
|
||||
images, err := docker.ListLocalImages(cmd.Context())
|
||||
if err != nil {
|
||||
return docker.Image{}, err
|
||||
}
|
||||
tag := name
|
||||
if strings.Contains(name, ":") {
|
||||
_, tag = docker.SplitImageReference(name)
|
||||
}
|
||||
for _, image := range images {
|
||||
if image.Tag == tag {
|
||||
return image, nil
|
||||
}
|
||||
}
|
||||
return docker.Image{}, fmt.Errorf("local image homarr:%s was not found", tag)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// Package cli wires the command line surface. The interactive application
|
||||
// lives in internal/tui; everything here is the scriptable path that does not
|
||||
// need a terminal.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/run"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/tui"
|
||||
)
|
||||
|
||||
// App holds command state. The three function fields are seams: they let the
|
||||
// command wiring be tested without opening a terminal, and they are the only
|
||||
// mutable state in the package — the previous version reached for package-level
|
||||
// variables, which meant tests had to mutate globals to observe dispatch.
|
||||
type App struct {
|
||||
demo bool
|
||||
pr int
|
||||
env []string
|
||||
detach bool
|
||||
buildPR int
|
||||
|
||||
RunBrowser func() error
|
||||
RunInstances func() error
|
||||
Launch func(cmd *cobra.Command, args []string) error
|
||||
}
|
||||
|
||||
func NewApp() *App {
|
||||
app := &App{RunBrowser: tui.RunDev, RunInstances: tui.RunDashboard}
|
||||
app.Launch = app.launch
|
||||
return app
|
||||
}
|
||||
|
||||
// Execute runs the CLI.
|
||||
func Execute() error {
|
||||
args := os.Args[1:]
|
||||
if len(args) > 0 && args[0] == "--" {
|
||||
args = args[1:]
|
||||
}
|
||||
root := NewApp().Root()
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func (a *App) Root() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "homarr",
|
||||
Short: "Launch and manage Homarr Docker instances",
|
||||
SilenceUsage: true,
|
||||
Long: `Launch and manage Homarr Docker containers.
|
||||
|
||||
Start a local image, a pull-request build from ghcr.io, browse open pull
|
||||
requests, or manage running instances — all from one interactive terminal
|
||||
application where builds and pulls run in the background.
|
||||
|
||||
Examples:
|
||||
homarr # interactive browser
|
||||
homarr --pr 6390 # launch a pull-request image directly
|
||||
homarr --pr 6390 -e FOO=bar # with environment overrides
|
||||
homarr run dev # launch local homarr:dev
|
||||
homarr run --detach dev # start in the background
|
||||
homarr dash # open on the instances screen
|
||||
homarr build feature # build this checkout as homarr:feature
|
||||
homarr build --pr 6390 # build a pull request locally
|
||||
homarr images # local images with provenance
|
||||
homarr data # instance data volumes
|
||||
homarr prune # remove stopped instances
|
||||
homarr doctor # check Docker and optional integrations`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: a.runRoot,
|
||||
}
|
||||
|
||||
a.addRunFlags(root)
|
||||
root.AddCommand(
|
||||
a.runCommand(),
|
||||
a.devCommand(),
|
||||
a.dashCommand(),
|
||||
a.buildCommand(),
|
||||
a.rebuildCommand(),
|
||||
a.listCommand(),
|
||||
a.imagesCommand(),
|
||||
a.dataCommand(),
|
||||
a.pruneCommand(),
|
||||
a.logsCommand(),
|
||||
a.stopCommand(),
|
||||
a.restartCommand(),
|
||||
a.removeCommand(),
|
||||
a.openCommand(),
|
||||
a.shellCommand(),
|
||||
a.ciCommand(),
|
||||
a.doctorCommand(),
|
||||
)
|
||||
return root
|
||||
}
|
||||
|
||||
func (a *App) addRunFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().BoolVarP(&a.demo, "demo", "m", false, "Demo mode (mock integrations)")
|
||||
cmd.Flags().IntVarP(&a.pr, "pr", "p", 0, "Launch a pull-request image from ghcr.io")
|
||||
cmd.Flags().StringArrayVarP(&a.env, "env", "e", nil, "Extra env vars (repeatable, KEY=VALUE)")
|
||||
cmd.Flags().BoolVar(&a.detach, "detach", false, "Start in the background")
|
||||
}
|
||||
|
||||
// runRoot opens the browser unless a launch flag was given, in which case the
|
||||
// bare `homarr` invocation is shorthand for `homarr run`.
|
||||
func (a *App) runRoot(cmd *cobra.Command, args []string) error {
|
||||
for _, name := range []string{"pr", "env", "demo", "detach"} {
|
||||
if cmd.Flags().Changed(name) {
|
||||
return a.Launch(cmd, args)
|
||||
}
|
||||
}
|
||||
return a.RunBrowser()
|
||||
}
|
||||
|
||||
func (a *App) runCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "run [tag]",
|
||||
Short: "Launch a local image or a pull-request image",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return a.Launch(cmd, args) },
|
||||
}
|
||||
a.addRunFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (a *App) devCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "dev",
|
||||
Short: "Browse pull requests and local development images",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(*cobra.Command, []string) error { return a.RunBrowser() },
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) dashCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "dash",
|
||||
Aliases: []string{"instances"},
|
||||
Short: "Open the interactive application on the instances screen",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(*cobra.Command, []string) error { return a.RunInstances() },
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) launch(cmd *cobra.Command, args []string) error {
|
||||
tag := ""
|
||||
if len(args) > 0 {
|
||||
tag = args[0]
|
||||
}
|
||||
if a.pr < 0 {
|
||||
return fmt.Errorf("PR number must be positive")
|
||||
}
|
||||
if a.pr > 0 && tag != "" {
|
||||
return fmt.Errorf("tag and --pr cannot be used together")
|
||||
}
|
||||
if a.pr == 0 && tag == "" {
|
||||
if err := cmd.Usage(); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("provide a tag or --pr")
|
||||
}
|
||||
|
||||
plan, err := run.BuildPlan(run.Options{
|
||||
Context: cmd.Context(),
|
||||
PR: a.pr,
|
||||
Tag: tag,
|
||||
Demo: a.demo,
|
||||
Env: a.env,
|
||||
FetchTitle: a.pr > 0,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Starting Homarr (%s)\n", plan.Label)
|
||||
fmt.Printf(" Image : %s\n", plan.Image)
|
||||
fmt.Printf(" Container : %s\n", plan.Name)
|
||||
fmt.Printf(" Volume : %s\n", plan.Volume)
|
||||
fmt.Printf(" Port : %d:7575\n", plan.HostPort)
|
||||
fmt.Printf(" URL : http://localhost:%d\n", plan.HostPort)
|
||||
if plan.PRNumber > 0 {
|
||||
fmt.Printf(" PR : https://github.com/homarr-labs/homarr/pull/%d\n", plan.PRNumber)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if a.detach {
|
||||
if err := run.StartDetached(plan); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Started in background: http://localhost:%d\n", plan.HostPort)
|
||||
return nil
|
||||
}
|
||||
return run.StartForeground(plan)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestRootCommandOpensBrowser(t *testing.T) {
|
||||
want := errors.New("browser started")
|
||||
app := NewApp()
|
||||
app.RunBrowser = func() error { return want }
|
||||
|
||||
if err := app.runRoot(&cobra.Command{}, nil); !errors.Is(err, want) {
|
||||
t.Fatalf("root command error = %v, want %v", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootCommandLaunchesWhenRunFlagIsProvided(t *testing.T) {
|
||||
want := errors.New("PR launched")
|
||||
app := NewApp()
|
||||
app.Launch = func(*cobra.Command, []string) error { return want }
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().Bool("pr", false, "")
|
||||
if err := cmd.Flags().Set("pr", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := app.runRoot(cmd, nil); !errors.Is(err, want) {
|
||||
t.Fatalf("root command error = %v, want %v", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchRejectsTagWithPR(t *testing.T) {
|
||||
app := NewApp()
|
||||
// Registering the flags binds them to app.pr and resets it, so the command
|
||||
// has to exist before the test sets the value it wants to assert on.
|
||||
cmd := app.runCommand()
|
||||
app.pr = 42
|
||||
|
||||
err := app.launch(cmd, []string{"feature"})
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot be used together") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchRejectsNegativePR(t *testing.T) {
|
||||
app := NewApp()
|
||||
cmd := app.runCommand()
|
||||
app.pr = -1
|
||||
|
||||
err := app.launch(cmd, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "must be positive") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchRejectsMissingImage(t *testing.T) {
|
||||
app := NewApp()
|
||||
|
||||
err := app.launch(app.runCommand(), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "provide a tag or --pr") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The root command doubles as `run`, so both must accept the same launch flags.
|
||||
func TestRootAndRunShareLaunchFlags(t *testing.T) {
|
||||
app := NewApp()
|
||||
root := app.Root()
|
||||
run, _, err := root.Find([]string{"run"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"pr", "demo", "env", "detach"} {
|
||||
if root.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("root command is missing the %q flag", name)
|
||||
}
|
||||
if run.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("run command is missing the %q flag", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCICommandRegisteredAndValidated(t *testing.T) {
|
||||
app := NewApp()
|
||||
root := app.Root()
|
||||
ciCmd, _, err := root.Find([]string{"ci"})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to find 'ci' command: %v", err)
|
||||
}
|
||||
if ciCmd.Name() != "ci" {
|
||||
t.Fatalf("expected command name 'ci', got %q", ciCmd.Name())
|
||||
}
|
||||
if ciCmd.Flags().Lookup("watch") == nil {
|
||||
t.Fatalf("expected --watch flag on 'ci' command")
|
||||
}
|
||||
|
||||
// Test invalid PR argument
|
||||
err = ciCmd.RunE(ciCmd, []string{"invalid-num"})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid PR number") {
|
||||
t.Fatalf("expected invalid PR number error, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/registry"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui"
|
||||
)
|
||||
|
||||
func (a *App) doctorCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: "Check Docker, the Engine API, GHCR and optional integrations",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
failed := false
|
||||
if output, err := exec.CommandContext(ctx, "docker", "info", "--format", "{{.ServerVersion}}").CombinedOutput(); err != nil {
|
||||
failed = true
|
||||
fail("Docker daemon", strings.TrimSpace(string(output)))
|
||||
} else {
|
||||
pass("Docker daemon", strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
// The Engine API is what gives pulls byte-level progress. Losing it
|
||||
// is not fatal — the CLI falls back to parsing `docker pull` — so
|
||||
// this reports a downgrade rather than a failure.
|
||||
host := docker.EngineHost()
|
||||
if docker.EngineAvailable(ctx) {
|
||||
pass("Engine API", host)
|
||||
} else {
|
||||
optional("Engine API unreachable at "+host, "pull progress falls back to coarse layer states")
|
||||
}
|
||||
|
||||
if tags, err := registry.PublishedTags(ctx, true); err != nil {
|
||||
optional("GHCR tag listing unavailable", err.Error())
|
||||
} else {
|
||||
pass("GHCR test images", fmt.Sprintf("%d published tags", len(tags)))
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("gh"); err != nil {
|
||||
optional("GitHub CLI not found", "required for pull-request features")
|
||||
} else if output, err := exec.CommandContext(ctx, "gh", "auth", "status").CombinedOutput(); err != nil {
|
||||
optional("GitHub CLI is not authenticated", strings.TrimSpace(string(output)))
|
||||
} else {
|
||||
pass("GitHub CLI authentication", "")
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("homarr"); err != nil {
|
||||
optional("homarr is not on PATH", "use `pnpm dev:cli -- ...` or run `pnpm dev:cli:install`")
|
||||
} else {
|
||||
pass("homarr is on PATH", "")
|
||||
}
|
||||
|
||||
if failed {
|
||||
return fmt.Errorf("one or more checks failed")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func pass(label, detail string) {
|
||||
if detail == "" {
|
||||
fmt.Printf("%s %s\n", ui.OK.Render(ui.IconPass), label)
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s %s: %s\n", ui.OK.Render(ui.IconPass), label, detail)
|
||||
}
|
||||
|
||||
func fail(label, detail string) {
|
||||
fmt.Printf("%s %s: %s\n", ui.Alert.Render(ui.IconFail), label, detail)
|
||||
}
|
||||
|
||||
func optional(label, detail string) {
|
||||
if detail == "" {
|
||||
fmt.Printf("%s %s (optional)\n", ui.Dim.Render(ui.IconNone), label)
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s %s (optional; %s)\n", ui.Dim.Render(ui.IconNone), label, detail)
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/platform"
|
||||
)
|
||||
|
||||
func (a *App) listCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls", "ps"},
|
||||
Short: "List Homarr instances without opening the application",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
containers, err := docker.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(containers) == 0 {
|
||||
fmt.Println("No Homarr instances found.")
|
||||
return nil
|
||||
}
|
||||
writer := newTable("NAME", "STATE", "PORT", "IMAGE", "STATUS")
|
||||
for _, container := range containers {
|
||||
row(writer, container.Name, container.State, container.HostPort(), container.Image, container.Status)
|
||||
}
|
||||
return writer.Flush()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) imagesCommand() *cobra.Command {
|
||||
images := &cobra.Command{
|
||||
Use: "images",
|
||||
Aliases: []string{"image"},
|
||||
Short: "List local Homarr images and their build provenance",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
found, err := docker.ListLocalImages(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(found) == 0 {
|
||||
fmt.Println("No local homarr images found. Build one with `homarr build <name>`.")
|
||||
return nil
|
||||
}
|
||||
writer := newTable("IMAGE", "SIZE", "CREATED", "PR", "REVISION", "SOURCE")
|
||||
for _, image := range found {
|
||||
pr := ""
|
||||
if image.PRNumber > 0 {
|
||||
pr = fmt.Sprintf("#%d", image.PRNumber)
|
||||
}
|
||||
revision := image.Revision
|
||||
if len(revision) > 12 {
|
||||
revision = revision[:12]
|
||||
}
|
||||
source := image.Source
|
||||
if source == "" {
|
||||
source = "unknown"
|
||||
}
|
||||
row(writer, image.Reference(), image.Size, image.Created, pr, revision, source)
|
||||
}
|
||||
return writer.Flush()
|
||||
},
|
||||
}
|
||||
images.AddCommand(&cobra.Command{
|
||||
Use: "rm <name>...",
|
||||
Aliases: []string{"remove", "delete"},
|
||||
Short: "Delete local Homarr images",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
for _, name := range args {
|
||||
reference := name
|
||||
if !strings.Contains(reference, ":") {
|
||||
reference = "homarr:" + reference
|
||||
}
|
||||
if err := docker.RemoveImage(cmd.Context(), reference); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("removed " + reference)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return images
|
||||
}
|
||||
|
||||
func (a *App) dataCommand() *cobra.Command {
|
||||
data := &cobra.Command{
|
||||
Use: "data",
|
||||
Aliases: []string{"volumes", "volume"},
|
||||
Short: "List Homarr instance data volumes",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
volumes, err := docker.ListVolumes(cmd.Context(), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(volumes) == 0 {
|
||||
fmt.Println("No Homarr data volumes found.")
|
||||
return nil
|
||||
}
|
||||
writer := newTable("VOLUME", "SIZE", "IN USE", "BELONGS TO")
|
||||
for _, volume := range volumes {
|
||||
owner := volume.Tag()
|
||||
if number := volume.PRNumber(); number > 0 {
|
||||
owner = fmt.Sprintf("PR #%d", number)
|
||||
}
|
||||
inUse := "no"
|
||||
if volume.InUse {
|
||||
inUse = "yes"
|
||||
}
|
||||
row(writer, volume.Name, volume.Size, inUse, owner)
|
||||
}
|
||||
return writer.Flush()
|
||||
},
|
||||
}
|
||||
data.AddCommand(&cobra.Command{
|
||||
Use: "rm <volume>...",
|
||||
Aliases: []string{"remove", "delete"},
|
||||
Short: "Delete instance data, removing the owning container first",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
for _, name := range args {
|
||||
// Docker refuses to delete an attached volume, and the data is
|
||||
// going away regardless, so the container goes first.
|
||||
_ = docker.Remove(strings.TrimSuffix(name, "_data"))
|
||||
if err := docker.RemoveVolume(cmd.Context(), name); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("removed " + name)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
func (a *App) pruneCommand() *cobra.Command {
|
||||
var force bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "prune",
|
||||
Short: "Remove stopped Homarr containers, keeping their data",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
containers, err := docker.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stopped := make([]docker.Container, 0, len(containers))
|
||||
for _, container := range containers {
|
||||
if !container.Running() {
|
||||
stopped = append(stopped, container)
|
||||
}
|
||||
}
|
||||
if len(stopped) == 0 {
|
||||
fmt.Println("No stopped Homarr containers.")
|
||||
return nil
|
||||
}
|
||||
if !force {
|
||||
fmt.Printf("Would remove %d stopped containers:\n", len(stopped))
|
||||
for _, container := range stopped {
|
||||
fmt.Println(" " + container.Name)
|
||||
}
|
||||
fmt.Println("\nRe-run with --force to remove them.")
|
||||
return nil
|
||||
}
|
||||
for _, container := range stopped {
|
||||
if err := docker.Remove(container.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("removed " + container.Name)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVarP(&force, "force", "f", false, "Actually remove the containers")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (a *App) logsCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "logs <container>",
|
||||
Short: "Follow logs of a running instance",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return docker.FollowLogs(args[0]) },
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) stopCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "stop <container>",
|
||||
Short: "Stop a running instance",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return docker.Stop(args[0]) },
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) restartCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "restart <container>",
|
||||
Short: "Restart an instance",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return docker.Restart(args[0]) },
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) removeCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "remove <container>",
|
||||
Short: "Force-remove an instance, keeping its data",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return docker.Remove(args[0]) },
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) openCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "open <container>",
|
||||
Short: "Open a running instance in the browser",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
containers, err := docker.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, container := range containers {
|
||||
if container.Name != args[0] {
|
||||
continue
|
||||
}
|
||||
if !container.Running() {
|
||||
return fmt.Errorf("container %q is not running", args[0])
|
||||
}
|
||||
port := container.HostPort()
|
||||
if port == "" {
|
||||
return fmt.Errorf("container %q does not publish the Homarr port", args[0])
|
||||
}
|
||||
return platform.OpenURL("http://localhost:" + port)
|
||||
}
|
||||
return fmt.Errorf("container %q was not found", args[0])
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) shellCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "shell <container> [cmd...]",
|
||||
Aliases: []string{"sh", "exec"},
|
||||
Short: "Open an interactive shell or run a command in a running Homarr container",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
shellArgs := []string{"sh"}
|
||||
if len(args) > 1 {
|
||||
shellArgs = args[1:]
|
||||
}
|
||||
return docker.ExecInteractive(cmd.Context(), args[0], shellArgs...)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) ciCommand() *cobra.Command {
|
||||
var watch bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "ci [pr]",
|
||||
Aliases: []string{"checks", "check"},
|
||||
Short: "View or watch GitHub CI checks for a pull request",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var prNumber int
|
||||
if len(args) > 0 {
|
||||
num, err := strconv.Atoi(strings.TrimPrefix(args[0], "#"))
|
||||
if err != nil || num <= 0 {
|
||||
return fmt.Errorf("invalid PR number %q", args[0])
|
||||
}
|
||||
prNumber = num
|
||||
} else {
|
||||
currentPR, err := gh.CurrentBranchPR(cmd.Context())
|
||||
if err != nil || currentPR == nil {
|
||||
return fmt.Errorf("could not detect PR for current branch: specify a PR number with `homarr ci <number>`")
|
||||
}
|
||||
prNumber = currentPR.Number
|
||||
}
|
||||
|
||||
if watch {
|
||||
return gh.WatchPRChecks(cmd.Context(), prNumber)
|
||||
}
|
||||
|
||||
checks, err := gh.GetPRChecks(cmd.Context(), prNumber, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(checks) == 0 {
|
||||
fmt.Printf("No CI checks found for PR #%d.\n", prNumber)
|
||||
return nil
|
||||
}
|
||||
|
||||
writer := newTable("CHECK", "STATUS", "DURATION", "WORKFLOW", "LINK")
|
||||
for _, check := range checks {
|
||||
dur := check.Duration()
|
||||
if dur == "" {
|
||||
dur = "—"
|
||||
}
|
||||
row(writer, check.Name, check.State, dur, check.Workflow, check.Link)
|
||||
}
|
||||
return writer.Flush()
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVarP(&watch, "watch", "w", false, "Watch CI checks until completion")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newTable(headers ...string) *tabwriter.Writer {
|
||||
writer := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
|
||||
row(writer, headers...)
|
||||
return writer
|
||||
}
|
||||
|
||||
func row(writer *tabwriter.Writer, cells ...string) {
|
||||
fmt.Fprintln(writer, strings.Join(cells, "\t"))
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// engineAPIVersion is the oldest Engine API version that supports every
|
||||
// endpoint this CLI relies on. The daemon negotiates downwards on its own.
|
||||
const engineAPIVersion = "v1.41"
|
||||
|
||||
// PullMessage is one record of the JSON progress stream returned by the Docker
|
||||
// Engine when an image is pulled. Unlike the plain-text output of `docker
|
||||
// pull`, this stream carries per-layer byte counters even when stdout is not a
|
||||
// terminal, which is what makes a real progress animation possible.
|
||||
type PullMessage struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Detail struct {
|
||||
Current int64 `json:"current"`
|
||||
Total int64 `json:"total"`
|
||||
} `json:"progressDetail"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type engineEndpoint struct {
|
||||
client *http.Client
|
||||
base string
|
||||
err error
|
||||
}
|
||||
|
||||
var engineOnce struct {
|
||||
sync.Once
|
||||
endpoint engineEndpoint
|
||||
}
|
||||
|
||||
// EngineHost reports the Docker endpoint this machine talks to, honouring
|
||||
// DOCKER_HOST and the active `docker context` (OrbStack, Colima, and Docker
|
||||
// Desktop all install their own socket).
|
||||
func EngineHost() string {
|
||||
if host := strings.TrimSpace(os.Getenv("DOCKER_HOST")); host != "" {
|
||||
return host
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, "docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}").Output()
|
||||
if err == nil {
|
||||
if host := strings.TrimSpace(string(out)); host != "" {
|
||||
return host
|
||||
}
|
||||
}
|
||||
return "unix:///var/run/docker.sock"
|
||||
}
|
||||
|
||||
func engine() (*http.Client, string, error) {
|
||||
engineOnce.Do(func() { engineOnce.endpoint = dialEngine(EngineHost()) })
|
||||
e := engineOnce.endpoint
|
||||
return e.client, e.base, e.err
|
||||
}
|
||||
|
||||
func dialEngine(host string) engineEndpoint {
|
||||
switch {
|
||||
case strings.HasPrefix(host, "unix://"):
|
||||
path := strings.TrimPrefix(host, "unix://")
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", path)
|
||||
},
|
||||
}
|
||||
return engineEndpoint{client: &http.Client{Transport: transport}, base: "http://docker/" + engineAPIVersion}
|
||||
case strings.HasPrefix(host, "tcp://"), strings.HasPrefix(host, "http://"):
|
||||
authority := host
|
||||
for _, scheme := range []string{"tcp://", "http://"} {
|
||||
authority = strings.TrimPrefix(authority, scheme)
|
||||
}
|
||||
return engineEndpoint{client: &http.Client{}, base: "http://" + authority + "/" + engineAPIVersion}
|
||||
default:
|
||||
return engineEndpoint{err: fmt.Errorf("unsupported Docker endpoint %q", host)}
|
||||
}
|
||||
}
|
||||
|
||||
// SplitImageReference separates a reference into its repository and tag,
|
||||
// tolerating registries that carry an explicit port.
|
||||
func SplitImageReference(reference string) (repository, tag string) {
|
||||
slash := strings.LastIndex(reference, "/")
|
||||
colon := strings.LastIndex(reference, ":")
|
||||
if colon > slash {
|
||||
return reference[:colon], reference[colon+1:]
|
||||
}
|
||||
return reference, "latest"
|
||||
}
|
||||
|
||||
// PullStream starts an image pull against the Engine API and returns its
|
||||
// progress records. The channel closes when the pull finishes; a message
|
||||
// carrying Error describes a failure. Cancelling ctx aborts the pull.
|
||||
func PullStream(ctx context.Context, image, platform string) (<-chan PullMessage, error) {
|
||||
client, base, err := engine()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repository, tag := SplitImageReference(image)
|
||||
query := url.Values{"fromImage": {repository}, "tag": {tag}}
|
||||
if platform != "" {
|
||||
query.Set("platform", platform)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/images/create?"+query.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("contact Docker engine: %w", err)
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode > 299 {
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
|
||||
_ = response.Body.Close()
|
||||
return nil, fmt.Errorf("pull %s: %s", image, engineErrorMessage(response.Status, body))
|
||||
}
|
||||
|
||||
messages := make(chan PullMessage, 64)
|
||||
go func() {
|
||||
defer close(messages)
|
||||
defer response.Body.Close()
|
||||
decoder := json.NewDecoder(response.Body)
|
||||
for {
|
||||
var message PullMessage
|
||||
if err := decoder.Decode(&message); err != nil {
|
||||
if err != io.EOF && ctx.Err() == nil {
|
||||
select {
|
||||
case messages <- PullMessage{Error: err.Error()}:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case messages <- message:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func engineErrorMessage(status string, body []byte) string {
|
||||
var payload struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err == nil && payload.Message != "" {
|
||||
return payload.Message
|
||||
}
|
||||
if trimmed := strings.TrimSpace(string(body)); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// EngineAvailable reports whether the Engine API answers, so callers can fall
|
||||
// back to the `docker` binary instead of failing outright.
|
||||
func EngineAvailable(ctx context.Context) bool {
|
||||
client, base, err := engine()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/_ping", nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer response.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
return response.StatusCode >= 200 && response.StatusCode < 300
|
||||
}
|
||||
@@ -378,27 +378,29 @@ func IsPortConflict(err error) bool {
|
||||
strings.Contains(message, "failed programming external connectivity")
|
||||
}
|
||||
|
||||
func Stop(name string) error {
|
||||
cmd := exec.Command("docker", "stop", name)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
func Stop(name string) error { return StopContext(context.Background(), name) }
|
||||
|
||||
func Restart(name string) error { return RestartContext(context.Background(), name) }
|
||||
|
||||
func Remove(name string) error { return RemoveContext(context.Background(), name) }
|
||||
|
||||
// The context-aware variants let a background job be cancelled while the daemon
|
||||
// is still thinking, which matters when Docker is wedged and the user wants the
|
||||
// interface back.
|
||||
func StopContext(ctx context.Context, name string) error {
|
||||
return runContainerCommand(ctx, "stop", name)
|
||||
}
|
||||
|
||||
func Restart(name string) error {
|
||||
cmd := exec.Command("docker", "restart", name)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
func RestartContext(ctx context.Context, name string) error {
|
||||
return runContainerCommand(ctx, "restart", name)
|
||||
}
|
||||
|
||||
func Remove(name string) error {
|
||||
cmd := exec.Command("docker", "rm", "-f", name)
|
||||
out, err := cmd.CombinedOutput()
|
||||
func RemoveContext(ctx context.Context, name string) error {
|
||||
return runContainerCommand(ctx, "rm", "-f", name)
|
||||
}
|
||||
|
||||
func runContainerCommand(ctx context.Context, args ...string) error {
|
||||
out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
@@ -425,3 +427,15 @@ func FollowLogs(name string) error {
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func ExecInteractive(ctx context.Context, name string, command ...string) error {
|
||||
if len(command) == 0 {
|
||||
command = []string{"sh"}
|
||||
}
|
||||
args := append([]string{"exec", "-it", name}, command...)
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -27,6 +27,8 @@ type Image struct {
|
||||
Source string
|
||||
Revision string
|
||||
PRNumber int
|
||||
Size string
|
||||
Created string
|
||||
}
|
||||
|
||||
func (image Image) Reference() string {
|
||||
@@ -34,9 +36,11 @@ func (image Image) Reference() string {
|
||||
}
|
||||
|
||||
type imageListRow struct {
|
||||
ID string
|
||||
Repository string
|
||||
Tag string
|
||||
ID string
|
||||
Repository string
|
||||
Tag string
|
||||
Size string
|
||||
CreatedSince string
|
||||
}
|
||||
|
||||
func parseImageList(output []byte) ([]Image, error) {
|
||||
@@ -50,7 +54,7 @@ func parseImageList(output []byte) ([]Image, error) {
|
||||
return nil, fmt.Errorf("parse local Docker image: %w", err)
|
||||
}
|
||||
if row.Repository == "homarr" && row.Tag != "<none>" {
|
||||
images = append(images, Image{ID: row.ID, Tag: row.Tag})
|
||||
images = append(images, Image{ID: row.ID, Tag: row.Tag, Size: row.Size, Created: row.CreatedSince})
|
||||
}
|
||||
}
|
||||
return images, nil
|
||||
@@ -86,22 +90,39 @@ func inspectImageLabels(ctx context.Context, images []Image) ([]map[string]strin
|
||||
args = append(args, image.Reference())
|
||||
}
|
||||
out, err := exec.CommandContext(ctx, "docker", args...).Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect local Homarr images: %w", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
if len(lines) != len(images) {
|
||||
return nil, fmt.Errorf("inspect returned %d images, expected %d", len(lines), len(images))
|
||||
if err == nil {
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
if len(lines) == len(images) {
|
||||
labelsByImage := make([]map[string]string, len(images))
|
||||
valid := true
|
||||
for index, line := range lines {
|
||||
labelsByImage[index] = make(map[string]string)
|
||||
if line == "null" || line == "" {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &labelsByImage[index]); err != nil {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if valid {
|
||||
return labelsByImage, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
labelsByImage := make([]map[string]string, len(images))
|
||||
for index, line := range lines {
|
||||
for index, image := range images {
|
||||
labelsByImage[index] = make(map[string]string)
|
||||
if line == "null" {
|
||||
singleOut, singleErr := exec.CommandContext(ctx, "docker", "image", "inspect", "--format", "{{json .Config.Labels}}", image.Reference()).Output()
|
||||
if singleErr != nil {
|
||||
continue
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &labelsByImage[index]); err != nil {
|
||||
return nil, fmt.Errorf("parse labels for %s: %w", images[index].Reference(), err)
|
||||
trimmed := strings.TrimSpace(string(singleOut))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
continue
|
||||
}
|
||||
_ = json.Unmarshal([]byte(trimmed), &labelsByImage[index])
|
||||
}
|
||||
return labelsByImage, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// Layer is the download and extraction state of a single image layer.
|
||||
type Layer struct {
|
||||
ID string
|
||||
Status string
|
||||
Current int64
|
||||
Total int64
|
||||
}
|
||||
|
||||
// Fraction reports how far this layer is towards being usable, treating
|
||||
// download and extraction as two halves of the work. Docker restarts the byte
|
||||
// counters between the two phases, so they are weighted rather than summed.
|
||||
func (l Layer) Fraction() float64 {
|
||||
switch normalizeLayerStatus(l.Status) {
|
||||
case "already exists", "pull complete":
|
||||
return 1
|
||||
case "downloading":
|
||||
return 0.5 * ratio(l.Current, l.Total)
|
||||
case "verifying checksum", "download complete":
|
||||
return 0.5
|
||||
case "extracting":
|
||||
return 0.5 + 0.5*ratio(l.Current, l.Total)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (l Layer) Done() bool {
|
||||
status := normalizeLayerStatus(l.Status)
|
||||
return status == "pull complete" || status == "already exists"
|
||||
}
|
||||
|
||||
func ratio(current, total int64) float64 {
|
||||
if total <= 0 {
|
||||
return 0
|
||||
}
|
||||
if current >= total {
|
||||
return 1
|
||||
}
|
||||
return float64(current) / float64(total)
|
||||
}
|
||||
|
||||
func normalizeLayerStatus(status string) string {
|
||||
return strings.ToLower(strings.TrimSpace(status))
|
||||
}
|
||||
|
||||
// PullTracker folds a pull progress stream into stable, ordered per-layer
|
||||
// state so the UI can redraw it every frame without flicker.
|
||||
type PullTracker struct {
|
||||
order []string
|
||||
layers map[string]*Layer
|
||||
status string
|
||||
started time.Time
|
||||
sampleAt time.Time
|
||||
sampled int64
|
||||
rate float64
|
||||
}
|
||||
|
||||
func NewPullTracker() *PullTracker {
|
||||
now := time.Now()
|
||||
return &PullTracker{layers: make(map[string]*Layer), started: now, sampleAt: now}
|
||||
}
|
||||
|
||||
// Apply folds one Engine API record into the tracker.
|
||||
func (t *PullTracker) Apply(message PullMessage) {
|
||||
if message.ID == "" {
|
||||
if message.Status != "" {
|
||||
t.status = message.Status
|
||||
}
|
||||
return
|
||||
}
|
||||
// Digest and manifest records reuse the ID field but are not layers.
|
||||
if strings.HasPrefix(message.ID, "sha256:") || len(message.ID) > 24 {
|
||||
t.status = message.Status
|
||||
return
|
||||
}
|
||||
layer, exists := t.layers[message.ID]
|
||||
if !exists {
|
||||
layer = &Layer{ID: message.ID}
|
||||
t.layers[message.ID] = layer
|
||||
t.order = append(t.order, message.ID)
|
||||
}
|
||||
if message.Status != "" {
|
||||
layer.Status = message.Status
|
||||
}
|
||||
if message.Detail.Total > 0 {
|
||||
layer.Total = message.Detail.Total
|
||||
}
|
||||
if message.Detail.Current > 0 || message.Detail.Total > 0 {
|
||||
layer.Current = message.Detail.Current
|
||||
}
|
||||
if layer.Done() {
|
||||
layer.Current = layer.Total
|
||||
}
|
||||
t.sample()
|
||||
}
|
||||
|
||||
var plainPullPattern = regexp.MustCompile(`^([0-9a-zA-Z_-]{4,64}):\s+(.+)$`)
|
||||
|
||||
// ApplyLine folds a line of plain `docker pull` output into the tracker. It is
|
||||
// the fallback used when the Engine API is unreachable; the resulting progress
|
||||
// is coarser because the CLI omits byte counters when stdout is not a TTY.
|
||||
func (t *PullTracker) ApplyLine(line string) {
|
||||
line = strings.TrimSpace(ansi.Strip(line))
|
||||
if line == "" {
|
||||
return
|
||||
}
|
||||
match := plainPullPattern.FindStringSubmatch(line)
|
||||
if match == nil {
|
||||
t.status = line
|
||||
return
|
||||
}
|
||||
status, current, total := parsePlainProgress(match[2])
|
||||
t.Apply(PullMessage{
|
||||
ID: match[1],
|
||||
Status: status,
|
||||
Detail: struct {
|
||||
Current int64 `json:"current"`
|
||||
Total int64 `json:"total"`
|
||||
}{Current: current, Total: total},
|
||||
})
|
||||
}
|
||||
|
||||
var plainSizePattern = regexp.MustCompile(`([0-9.]+\s*[A-Za-z]*B)\s*/\s*([0-9.]+\s*[A-Za-z]*B)`)
|
||||
|
||||
func parsePlainProgress(rest string) (status string, current, total int64) {
|
||||
status = rest
|
||||
if bracket := strings.Index(rest, "["); bracket >= 0 {
|
||||
status = strings.TrimSpace(rest[:bracket])
|
||||
}
|
||||
if match := plainSizePattern.FindStringSubmatch(rest); match != nil {
|
||||
current = ParseSize(match[1])
|
||||
total = ParseSize(match[2])
|
||||
}
|
||||
return status, current, total
|
||||
}
|
||||
|
||||
var sizeUnits = map[string]int64{
|
||||
"b": 1,
|
||||
"kb": 1000, "mb": 1000 * 1000, "gb": 1000 * 1000 * 1000, "tb": 1000 * 1000 * 1000 * 1000,
|
||||
"kib": 1024, "mib": 1024 * 1024, "gib": 1024 * 1024 * 1024, "tib": 1024 * 1024 * 1024 * 1024,
|
||||
}
|
||||
|
||||
// ParseSize converts a human readable Docker size such as "12.3MB" into bytes.
|
||||
func ParseSize(value string) int64 {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
index := 0
|
||||
for index < len(value) && (value[index] >= '0' && value[index] <= '9' || value[index] == '.') {
|
||||
index++
|
||||
}
|
||||
number, err := strconv.ParseFloat(strings.TrimSpace(value[:index]), 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
unit := strings.TrimSpace(value[index:])
|
||||
if unit == "" {
|
||||
return int64(number)
|
||||
}
|
||||
multiplier, known := sizeUnits[unit]
|
||||
if !known {
|
||||
return int64(number)
|
||||
}
|
||||
return int64(number * float64(multiplier))
|
||||
}
|
||||
|
||||
// FormatSize renders a byte count the way the Docker CLI does.
|
||||
func FormatSize(bytes int64) string {
|
||||
const unit = 1000
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%dB", bytes)
|
||||
}
|
||||
value := float64(bytes)
|
||||
for _, suffix := range []string{"kB", "MB", "GB", "TB"} {
|
||||
value /= unit
|
||||
if value < unit {
|
||||
return fmt.Sprintf("%.1f%s", value, suffix)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%.1fPB", value/unit)
|
||||
}
|
||||
|
||||
func (t *PullTracker) sample() {
|
||||
now := time.Now()
|
||||
elapsed := now.Sub(t.sampleAt)
|
||||
if elapsed < 500*time.Millisecond {
|
||||
return
|
||||
}
|
||||
current, _ := t.Bytes()
|
||||
if delta := current - t.sampled; delta > 0 {
|
||||
t.rate = float64(delta) / elapsed.Seconds()
|
||||
}
|
||||
t.sampled = current
|
||||
t.sampleAt = now
|
||||
}
|
||||
|
||||
// Layers returns the layers in the order Docker announced them.
|
||||
func (t *PullTracker) Layers() []Layer {
|
||||
layers := make([]Layer, 0, len(t.order))
|
||||
for _, id := range t.order {
|
||||
layers = append(layers, *t.layers[id])
|
||||
}
|
||||
return layers
|
||||
}
|
||||
|
||||
// Bytes reports downloaded and total bytes across every layer that announced a
|
||||
// size.
|
||||
func (t *PullTracker) Bytes() (current, total int64) {
|
||||
for _, layer := range t.layers {
|
||||
if layer.Total <= 0 {
|
||||
continue
|
||||
}
|
||||
total += layer.Total
|
||||
if layer.Done() {
|
||||
current += layer.Total
|
||||
continue
|
||||
}
|
||||
current += min(layer.Current, layer.Total)
|
||||
}
|
||||
return current, total
|
||||
}
|
||||
|
||||
// Complete reports how many layers are fully pulled out of how many are known.
|
||||
func (t *PullTracker) Complete() (complete, total int) {
|
||||
for _, layer := range t.layers {
|
||||
total++
|
||||
if layer.Done() {
|
||||
complete++
|
||||
}
|
||||
}
|
||||
return complete, total
|
||||
}
|
||||
|
||||
// Percent is the overall progress of the pull in the range [0,1].
|
||||
func (t *PullTracker) Percent() float64 {
|
||||
if len(t.order) == 0 {
|
||||
return 0
|
||||
}
|
||||
sum := 0.0
|
||||
for _, id := range t.order {
|
||||
sum += t.layers[id].Fraction()
|
||||
}
|
||||
return sum / float64(len(t.order))
|
||||
}
|
||||
|
||||
// Rate is the recent download throughput in bytes per second.
|
||||
func (t *PullTracker) Rate() float64 { return t.rate }
|
||||
|
||||
// Status is the most recent message that was not tied to a single layer.
|
||||
func (t *PullTracker) Status() string { return t.status }
|
||||
|
||||
// Elapsed reports how long the pull has been running.
|
||||
func (t *PullTracker) Elapsed() time.Duration { return time.Since(t.started) }
|
||||
|
||||
// BuildStep is one BuildKit vertex, such as a Dockerfile instruction.
|
||||
type BuildStep struct {
|
||||
ID string
|
||||
Label string
|
||||
Status string
|
||||
Output []string
|
||||
Done bool
|
||||
Cached bool
|
||||
Failed bool
|
||||
Canceled bool
|
||||
Elapsed string
|
||||
}
|
||||
|
||||
// BuildTracker folds `docker build --progress=plain` output into ordered steps.
|
||||
type BuildTracker struct {
|
||||
order []string
|
||||
steps map[string]*BuildStep
|
||||
total int
|
||||
started time.Time
|
||||
last string
|
||||
}
|
||||
|
||||
func NewBuildTracker() *BuildTracker {
|
||||
return &BuildTracker{steps: make(map[string]*BuildStep), started: time.Now()}
|
||||
}
|
||||
|
||||
var (
|
||||
buildLinePattern = regexp.MustCompile(`^#(\d+)\s*(.*)$`)
|
||||
buildStagePattern = regexp.MustCompile(`^\[[^\]]*?(\d+)/(\d+)\]`)
|
||||
buildTimePattern = regexp.MustCompile(`^\d+\.\d+\s`)
|
||||
)
|
||||
|
||||
// ApplyLine folds one line of BuildKit plain output into the tracker.
|
||||
func (t *BuildTracker) ApplyLine(line string) {
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
trimmed := strings.TrimSpace(ansi.Strip(line))
|
||||
if trimmed == "" {
|
||||
return
|
||||
}
|
||||
match := buildLinePattern.FindStringSubmatch(trimmed)
|
||||
if match == nil {
|
||||
t.last = trimmed
|
||||
return
|
||||
}
|
||||
id, rest := match[1], strings.TrimSpace(match[2])
|
||||
// BuildKit reserves #0 for its own preamble ("building with ... driver").
|
||||
// It never completes, so counting it would leave every finished build one
|
||||
// step short of done.
|
||||
if id == "0" {
|
||||
t.last = rest
|
||||
return
|
||||
}
|
||||
step, exists := t.steps[id]
|
||||
if !exists {
|
||||
step = &BuildStep{ID: id}
|
||||
t.steps[id] = step
|
||||
t.order = append(t.order, id)
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(rest, "DONE"):
|
||||
step.Done = true
|
||||
step.Status = "done"
|
||||
step.Elapsed = strings.TrimSpace(strings.TrimPrefix(rest, "DONE"))
|
||||
case rest == "CACHED":
|
||||
step.Done, step.Cached, step.Status = true, true, "cached"
|
||||
case strings.HasPrefix(rest, "ERROR"):
|
||||
step.Failed, step.Done, step.Status = true, true, "failed"
|
||||
step.Output = appendStepOutput(step.Output, rest)
|
||||
t.last = rest
|
||||
case strings.HasPrefix(rest, "CANCELED"):
|
||||
step.Done, step.Status, step.Canceled = true, "canceled", true
|
||||
case buildTimePattern.MatchString(rest):
|
||||
// `#12 1.234 <output>` — timestamped output from inside the step.
|
||||
_, output, _ := strings.Cut(rest, " ")
|
||||
if output = strings.TrimSpace(output); output != "" {
|
||||
step.Output = appendStepOutput(step.Output, output)
|
||||
t.last = output
|
||||
}
|
||||
case step.Label == "":
|
||||
step.Label = rest
|
||||
if stage := buildStagePattern.FindStringSubmatch(rest); stage != nil {
|
||||
if total, err := strconv.Atoi(stage[2]); err == nil && total > t.total {
|
||||
t.total = total
|
||||
}
|
||||
}
|
||||
default:
|
||||
step.Output = appendStepOutput(step.Output, rest)
|
||||
}
|
||||
}
|
||||
|
||||
func appendStepOutput(output []string, line string) []string {
|
||||
const retained = 200
|
||||
output = append(output, line)
|
||||
if len(output) > retained {
|
||||
output = output[len(output)-retained:]
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
// Steps returns every announced step in declaration order.
|
||||
func (t *BuildTracker) Steps() []BuildStep {
|
||||
steps := make([]BuildStep, 0, len(t.order))
|
||||
for _, id := range t.order {
|
||||
steps = append(steps, *t.steps[id])
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
// Active returns the step BuildKit is currently working on, if any.
|
||||
func (t *BuildTracker) Active() (BuildStep, bool) {
|
||||
for index := len(t.order) - 1; index >= 0; index-- {
|
||||
if step := t.steps[t.order[index]]; !step.Done && step.Label != "" {
|
||||
return *step, true
|
||||
}
|
||||
}
|
||||
return BuildStep{}, false
|
||||
}
|
||||
|
||||
// Complete reports finished steps out of the best known total. The total grows
|
||||
// as BuildKit announces stages, so it is never allowed to shrink below what has
|
||||
// already been seen. A step BuildKit reported as canceled or failed is finished
|
||||
// but not complete — counting it would report a killed build as fully built.
|
||||
func (t *BuildTracker) Complete() (complete, total int) {
|
||||
for _, step := range t.steps {
|
||||
if step.Done && !step.Canceled && !step.Failed {
|
||||
complete++
|
||||
}
|
||||
}
|
||||
return complete, max(t.total, len(t.order))
|
||||
}
|
||||
|
||||
// Percent is the overall build progress in the range [0,1].
|
||||
func (t *BuildTracker) Percent() float64 {
|
||||
complete, total := t.Complete()
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
return ratio(int64(complete), int64(total))
|
||||
}
|
||||
|
||||
// LastMessage is the most recent human readable line, used for error context.
|
||||
func (t *BuildTracker) LastMessage() string { return t.last }
|
||||
|
||||
// Elapsed reports how long the build has been running.
|
||||
func (t *BuildTracker) Elapsed() time.Duration { return time.Since(t.started) }
|
||||
@@ -0,0 +1,163 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLayerFraction(t *testing.T) {
|
||||
cases := []struct {
|
||||
status string
|
||||
current int64
|
||||
total int64
|
||||
want float64
|
||||
}{
|
||||
{"pull complete", 0, 0, 1.0},
|
||||
{"already exists", 0, 0, 1.0},
|
||||
{"download complete", 100, 100, 0.5},
|
||||
{"verifying checksum", 100, 100, 0.5},
|
||||
{"downloading", 50, 100, 0.25},
|
||||
{"extracting", 50, 100, 0.75},
|
||||
{"waiting", 0, 100, 0.0},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
layer := Layer{Status: c.status, Current: c.current, Total: c.total}
|
||||
if got := layer.Fraction(); got != c.want {
|
||||
t.Errorf("Layer{%q, %d, %d}.Fraction() = %v, want %v", c.status, c.current, c.total, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSizeAndFormatSize(t *testing.T) {
|
||||
if got := ParseSize("12.3MB"); got != 12300000 {
|
||||
t.Errorf("ParseSize(12.3MB) = %d, want 12300000", got)
|
||||
}
|
||||
if got := ParseSize("10MiB"); got != 10*1024*1024 {
|
||||
t.Errorf("ParseSize(10MiB) = %d, want %d", got, 10*1024*1024)
|
||||
}
|
||||
if got := ParseSize("500B"); got != 500 {
|
||||
t.Errorf("ParseSize(500B) = %d, want 500", got)
|
||||
}
|
||||
if got := ParseSize("invalid"); got != 0 {
|
||||
t.Errorf("ParseSize(invalid) = %d, want 0", got)
|
||||
}
|
||||
|
||||
if got := FormatSize(500); got != "500B" {
|
||||
t.Errorf("FormatSize(500) = %q, want 500B", got)
|
||||
}
|
||||
if got := FormatSize(1500000); got != "1.5MB" {
|
||||
t.Errorf("FormatSize(1500000) = %q, want 1.5MB", got)
|
||||
}
|
||||
if got := FormatSize(2500000000); got != "2.5GB" {
|
||||
t.Errorf("FormatSize(2500000000) = %q, want 2.5GB", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullTrackerApplyAndProgress(t *testing.T) {
|
||||
tracker := NewPullTracker()
|
||||
tracker.Apply(PullMessage{
|
||||
ID: "layer1",
|
||||
Status: "Downloading",
|
||||
Detail: struct {
|
||||
Current int64 `json:"current"`
|
||||
Total int64 `json:"total"`
|
||||
}{Current: 50, Total: 100},
|
||||
})
|
||||
tracker.Apply(PullMessage{
|
||||
ID: "layer2",
|
||||
Status: "Extracting",
|
||||
Detail: struct {
|
||||
Current int64 `json:"current"`
|
||||
Total int64 `json:"total"`
|
||||
}{Current: 50, Total: 100},
|
||||
})
|
||||
|
||||
layers := tracker.Layers()
|
||||
if len(layers) != 2 {
|
||||
t.Fatalf("expected 2 layers, got %d", len(layers))
|
||||
}
|
||||
if layers[0].ID != "layer1" || layers[1].ID != "layer2" {
|
||||
t.Errorf("unexpected layers order: %v, %v", layers[0].ID, layers[1].ID)
|
||||
}
|
||||
|
||||
currentBytes, totalBytes := tracker.Bytes()
|
||||
if currentBytes != 100 || totalBytes != 200 {
|
||||
t.Errorf("Bytes() = %d, %d; want 100, 200", currentBytes, totalBytes)
|
||||
}
|
||||
|
||||
complete, total := tracker.Complete()
|
||||
if complete != 0 || total != 2 {
|
||||
t.Errorf("Complete() = %d, %d; want 0, 2", complete, total)
|
||||
}
|
||||
|
||||
if percent := tracker.Percent(); percent != 0.5 {
|
||||
t.Errorf("Percent() = %v, want 0.5", percent)
|
||||
}
|
||||
|
||||
tracker.ApplyLine("c0ffee123456: Pull complete")
|
||||
if len(tracker.Layers()) != 3 {
|
||||
t.Fatalf("expected 3 layers after ApplyLine, got %d", len(tracker.Layers()))
|
||||
}
|
||||
complete, total = tracker.Complete()
|
||||
if complete != 1 || total != 3 {
|
||||
t.Errorf("Complete() = %d, %d; want 1, 3", complete, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildTrackerStepsAndProgress(t *testing.T) {
|
||||
tracker := NewBuildTracker()
|
||||
|
||||
tracker.ApplyLine("#0 building with \"default\" instance using docker driver")
|
||||
if len(tracker.Steps()) != 0 {
|
||||
t.Fatalf("expected step #0 to be skipped, got %d steps", len(tracker.Steps()))
|
||||
}
|
||||
|
||||
tracker.ApplyLine("#1 [internal] load build definition from Dockerfile")
|
||||
tracker.ApplyLine("#1 transferring dockerfile: 3.42kB done")
|
||||
tracker.ApplyLine("#1 DONE 0.0s")
|
||||
|
||||
tracker.ApplyLine("#2 [1/3] FROM docker.io/library/alpine:3.20")
|
||||
tracker.ApplyLine("#2 CACHED")
|
||||
|
||||
tracker.ApplyLine("#3 [2/3] RUN echo step2")
|
||||
tracker.ApplyLine("#3 0.123 hello from build")
|
||||
|
||||
steps := tracker.Steps()
|
||||
if len(steps) != 3 {
|
||||
t.Fatalf("expected 3 steps, got %d", len(steps))
|
||||
}
|
||||
|
||||
if !steps[0].Done {
|
||||
t.Errorf("step #1 should be marked Done")
|
||||
}
|
||||
if !steps[1].Cached || !steps[1].Done {
|
||||
t.Errorf("step #2 should be marked Cached and Done")
|
||||
}
|
||||
if steps[2].Done {
|
||||
t.Errorf("step #3 should not be Done yet")
|
||||
}
|
||||
|
||||
active, running := tracker.Active()
|
||||
if !running || active.ID != "3" {
|
||||
t.Errorf("Active() = %+v, %v; want ID 3, true", active, running)
|
||||
}
|
||||
|
||||
complete, total := tracker.Complete()
|
||||
if complete != 2 || total < 3 {
|
||||
t.Errorf("Complete() = %d, %d; want 2, >=3", complete, total)
|
||||
}
|
||||
|
||||
if last := tracker.LastMessage(); last != "hello from build" {
|
||||
t.Errorf("LastMessage() = %q, want 'hello from build'", last)
|
||||
}
|
||||
|
||||
tracker.ApplyLine("#3 CANCELED")
|
||||
steps = tracker.Steps()
|
||||
if !steps[2].Canceled {
|
||||
t.Errorf("step #3 should be marked Canceled")
|
||||
}
|
||||
complete, _ = tracker.Complete()
|
||||
if complete != 2 {
|
||||
t.Errorf("Complete() after cancel = %d, want 2", complete)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StreamLine is one line of output from a streamed command. Err is set only on
|
||||
// the final line, which also has Done set.
|
||||
type StreamLine struct {
|
||||
Text string
|
||||
Err error
|
||||
Done bool
|
||||
}
|
||||
|
||||
// splitLines is a bufio.SplitFunc that treats both newline and carriage return
|
||||
// as terminators so in-place progress updates arrive as discrete lines.
|
||||
func splitLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
for index, char := range data {
|
||||
if char == '\n' || char == '\r' {
|
||||
return index + 1, data[:index], nil
|
||||
}
|
||||
}
|
||||
if atEOF && len(data) > 0 {
|
||||
return len(data), data, nil
|
||||
}
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
// StreamCommand runs cmd with stdout and stderr merged and delivers its output
|
||||
// line by line. The channel is closed after a final Done line carrying the exit
|
||||
// status. Cancelling the command's context terminates the process.
|
||||
func StreamCommand(cmd *exec.Cmd) (<-chan StreamLine, error) {
|
||||
reader, writer := io.Pipe()
|
||||
cmd.Stdout = writer
|
||||
cmd.Stderr = writer
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = reader.Close()
|
||||
_ = writer.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Wait runs on its own goroutine and closes the write end, which is what
|
||||
// gives the scanner an EOF. Waiting first and closing afterwards deadlocks:
|
||||
// the scanner would block forever on a pipe this process still holds open.
|
||||
// Wait only returns once every byte has been copied into the pipe, so no
|
||||
// output can be lost to the close.
|
||||
waited := make(chan error, 1)
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
_ = writer.Close()
|
||||
waited <- err
|
||||
}()
|
||||
|
||||
lines := make(chan StreamLine, 256)
|
||||
go func() {
|
||||
defer close(lines)
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
scanner.Split(splitLines)
|
||||
last := ""
|
||||
for scanner.Scan() {
|
||||
text := strings.TrimRight(scanner.Text(), "\r\n")
|
||||
if strings.TrimSpace(text) == "" {
|
||||
continue
|
||||
}
|
||||
last = text
|
||||
lines <- StreamLine{Text: text}
|
||||
}
|
||||
_ = reader.Close()
|
||||
err := <-waited
|
||||
if err != nil && last != "" {
|
||||
err = fmt.Errorf("%w: %s", err, strings.TrimSpace(last))
|
||||
}
|
||||
lines <- StreamLine{Done: true, Err: err}
|
||||
}()
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// StreamContainerLogs follows a container's logs, replaying the last tail lines
|
||||
// first. The stream ends when the container stops or ctx is cancelled.
|
||||
func StreamContainerLogs(ctx context.Context, name string, tail int) (<-chan StreamLine, error) {
|
||||
cmd := exec.CommandContext(ctx, "docker", "logs", "--follow", "--tail", fmt.Sprint(tail), name)
|
||||
cmd.Cancel = func() error { return cmd.Process.Kill() }
|
||||
return StreamCommand(cmd)
|
||||
}
|
||||
|
||||
// BuildCommandStreaming returns a build command configured for BuildKit's plain
|
||||
// progress output, which is the only format that can be parsed line by line.
|
||||
func BuildCommandStreaming(ctx context.Context, options BuildOptions) (*exec.Cmd, error) {
|
||||
cmd, err := BuildCommand(ctx, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd.Args = append(cmd.Args[:len(cmd.Args)-1], "--progress=plain", cmd.Args[len(cmd.Args)-1])
|
||||
cmd.Env = append(cmd.Environ(), "BUILDKIT_PROGRESS=plain", "DOCKER_BUILDKIT=1")
|
||||
return cmd, nil
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Volume is a Docker volume that belongs to a Homarr instance.
|
||||
type Volume struct {
|
||||
Name string
|
||||
Driver string
|
||||
Mountpoint string
|
||||
Size string
|
||||
InUse bool
|
||||
}
|
||||
|
||||
// PRNumber returns the pull request a volume stores data for, or zero.
|
||||
func (v Volume) PRNumber() int {
|
||||
var number int
|
||||
if _, err := fmt.Sscanf(v.Name, "homarr_pr_%d_data", &number); err == nil {
|
||||
return number
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Tag returns the local image tag a volume stores data for, or an empty string.
|
||||
func (v Volume) Tag() string {
|
||||
if v.PRNumber() > 0 {
|
||||
return ""
|
||||
}
|
||||
name := strings.TrimPrefix(v.Name, "homarr_")
|
||||
if name == v.Name {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSuffix(name, "_data")
|
||||
}
|
||||
|
||||
// ListVolumes returns the Homarr data volumes, optionally annotated with disk
|
||||
// usage when includeSizes is true.
|
||||
func ListVolumes(ctx context.Context, includeSizes bool) ([]Volume, error) {
|
||||
out, err := exec.CommandContext(ctx, "docker", "volume", "ls", "--filter", "name=homarr", "--format", "{{json .}}").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list Homarr volumes: %w", err)
|
||||
}
|
||||
volumes, err := parseVolumeList(out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var sizes map[string]string
|
||||
if includeSizes {
|
||||
sizes = volumeSizes(ctx)
|
||||
}
|
||||
inUse := volumesInUse(ctx)
|
||||
for index := range volumes {
|
||||
if sizes != nil {
|
||||
if size, known := sizes[volumes[index].Name]; known {
|
||||
volumes[index].Size = size
|
||||
}
|
||||
}
|
||||
volumes[index].InUse = inUse[volumes[index].Name]
|
||||
}
|
||||
return volumes, nil
|
||||
}
|
||||
|
||||
func parseVolumeList(output []byte) ([]Volume, error) {
|
||||
volumes := make([]Volume, 0)
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var row struct {
|
||||
Name string
|
||||
Driver string
|
||||
Mountpoint string
|
||||
Size string
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &row); err != nil {
|
||||
return nil, fmt.Errorf("parse Docker volume: %w", err)
|
||||
}
|
||||
// buildx keeps its cache in volumes that match the same name filter.
|
||||
if !strings.HasPrefix(row.Name, "homarr_") && row.Name != "homarr" {
|
||||
continue
|
||||
}
|
||||
size := row.Size
|
||||
if size == "N/A" {
|
||||
size = ""
|
||||
}
|
||||
volumes = append(volumes, Volume{Name: row.Name, Driver: row.Driver, Mountpoint: row.Mountpoint, Size: size})
|
||||
}
|
||||
return volumes, nil
|
||||
}
|
||||
|
||||
// volumeSizes reads reclaimable space per volume. `docker system df -v` is slow
|
||||
// and optional, so failures degrade to unknown sizes rather than an error.
|
||||
func volumeSizes(ctx context.Context) map[string]string {
|
||||
sizes := make(map[string]string)
|
||||
out, err := exec.CommandContext(ctx, "docker", "system", "df", "-v", "--format", "{{json .Volumes}}").Output()
|
||||
if err != nil {
|
||||
return sizes
|
||||
}
|
||||
var rows []struct {
|
||||
Name string
|
||||
Size string
|
||||
}
|
||||
if err := json.Unmarshal(out, &rows); err != nil {
|
||||
return sizes
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Size != "" && row.Size != "N/A" {
|
||||
sizes[row.Name] = row.Size
|
||||
}
|
||||
}
|
||||
return sizes
|
||||
}
|
||||
|
||||
func volumesInUse(ctx context.Context) map[string]bool {
|
||||
inUse := make(map[string]bool)
|
||||
out, err := exec.CommandContext(ctx, "docker", "ps", "-a", "--filter", "name=homarr", "--format", "{{.Mounts}}").Output()
|
||||
if err != nil {
|
||||
return inUse
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
for _, mount := range strings.Split(line, ",") {
|
||||
if mount = strings.TrimSpace(mount); mount != "" {
|
||||
inUse[mount] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return inUse
|
||||
}
|
||||
|
||||
// RemoveVolume deletes a volume and the instance data it holds.
|
||||
func RemoveVolume(ctx context.Context, name string) error {
|
||||
out, err := exec.CommandContext(ctx, "docker", "volume", "rm", name).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove volume %s: %w: %s", name, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveImage force-deletes a local image, including any tags pointing at it.
|
||||
func RemoveImage(ctx context.Context, reference string) error {
|
||||
out, err := exec.CommandContext(ctx, "docker", "image", "rm", "-f", reference).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove image %s: %w: %s", reference, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -197,3 +198,151 @@ func GetPR(ctx context.Context, number int) (*PR, error) {
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type Check struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Bucket string `json:"bucket"`
|
||||
Workflow string `json:"workflow"`
|
||||
Link string `json:"link"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
CompletedAt string `json:"completedAt"`
|
||||
Description string `json:"description"`
|
||||
Event string `json:"event"`
|
||||
}
|
||||
|
||||
var checkBucketKeys = map[string]string{
|
||||
"pass": "pass",
|
||||
"fail": "fail",
|
||||
"pending": "pending",
|
||||
"skipping": "skipping",
|
||||
"cancel": "cancel",
|
||||
}
|
||||
|
||||
func (c Check) BucketKey() string {
|
||||
if key, found := checkBucketKeys[strings.ToLower(c.Bucket)]; found {
|
||||
return key
|
||||
}
|
||||
return "pending"
|
||||
}
|
||||
|
||||
func (c Check) IsPending() bool {
|
||||
return c.Bucket == "pending" || c.State == "PENDING" || c.State == "IN_PROGRESS" || c.State == "STARTING"
|
||||
}
|
||||
|
||||
func (c Check) IsSuccess() bool {
|
||||
return c.Bucket == "pass" || c.State == "SUCCESS"
|
||||
}
|
||||
|
||||
func (c Check) IsFailure() bool {
|
||||
return c.Bucket == "fail" || c.State == "FAILURE" || c.State == "ERROR"
|
||||
}
|
||||
|
||||
func (c Check) IsSkipped() bool {
|
||||
return c.Bucket == "skipping" || c.Bucket == "cancel" || c.State == "SKIPPED" || c.State == "CANCELLED"
|
||||
}
|
||||
|
||||
func (c Check) Duration() string {
|
||||
if c.StartedAt == "" {
|
||||
return ""
|
||||
}
|
||||
start, err := time.Parse(time.RFC3339, c.StartedAt)
|
||||
if err != nil || start.IsZero() {
|
||||
return ""
|
||||
}
|
||||
end := time.Now()
|
||||
if c.CompletedAt != "" {
|
||||
if tEnd, err := time.Parse(time.RFC3339, c.CompletedAt); err == nil && !tEnd.IsZero() {
|
||||
end = tEnd
|
||||
}
|
||||
}
|
||||
d := end.Sub(start).Round(time.Second)
|
||||
if d < 0 {
|
||||
return ""
|
||||
}
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60)
|
||||
}
|
||||
return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
|
||||
}
|
||||
|
||||
var prChecksCache = struct {
|
||||
sync.Mutex
|
||||
entries map[int]cachedChecks
|
||||
}{entries: make(map[int]cachedChecks)}
|
||||
|
||||
type cachedChecks struct {
|
||||
checks []Check
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
func GetPRChecks(ctx context.Context, number int, refresh bool) ([]Check, error) {
|
||||
if number <= 0 {
|
||||
return nil, fmt.Errorf("invalid PR number: %d", number)
|
||||
}
|
||||
prChecksCache.Lock()
|
||||
entry, found := prChecksCache.entries[number]
|
||||
prChecksCache.Unlock()
|
||||
if !refresh && found && time.Since(entry.fetchedAt) < 15*time.Second {
|
||||
return append([]Check(nil), entry.checks...), nil
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "gh", "pr", "checks", fmt.Sprint(number),
|
||||
"--repo", Repo,
|
||||
"--json", "name,state,bucket,workflow,link,startedAt,completedAt,description,event",
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil && len(out) == 0 {
|
||||
return nil, commandError(err, out)
|
||||
}
|
||||
|
||||
var checks []Check
|
||||
if jsonErr := json.Unmarshal(out, &checks); jsonErr != nil {
|
||||
if err != nil {
|
||||
return nil, commandError(err, out)
|
||||
}
|
||||
return nil, fmt.Errorf("parse checks JSON: %w", jsonErr)
|
||||
}
|
||||
|
||||
prChecksCache.Lock()
|
||||
prChecksCache.entries[number] = cachedChecks{checks: checks, fetchedAt: time.Now()}
|
||||
prChecksCache.Unlock()
|
||||
|
||||
return checks, nil
|
||||
}
|
||||
|
||||
func WatchPRChecks(ctx context.Context, number int) error {
|
||||
cmd := exec.CommandContext(ctx, "gh", "pr", "checks", fmt.Sprint(number), "--repo", Repo, "--watch")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func CurrentBranchPR(ctx context.Context) (*PR, error) {
|
||||
out, err := exec.CommandContext(ctx, "gh", "pr", "view",
|
||||
"--repo", Repo,
|
||||
"--json", "number,title,author,headRefName,headRefOid,updatedAt,isDraft,statusCheckRollup",
|
||||
).CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, commandError(err, out)
|
||||
}
|
||||
|
||||
var r rawPR
|
||||
if err := json.Unmarshal(out, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PR{
|
||||
Number: r.Number,
|
||||
Title: r.Title,
|
||||
Author: r.Author.Login,
|
||||
HeadRef: r.HeadRefName,
|
||||
HeadSHA: r.HeadRefOID,
|
||||
CIState: rollupState(r.StatusCheckRollup),
|
||||
IsDraft: r.IsDraft,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
@@ -89,3 +89,47 @@ func TestListPRsExcludesBotsIntegration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHelpersAndDuration(t *testing.T) {
|
||||
c1 := Check{
|
||||
Name: "Test Job",
|
||||
State: "SUCCESS",
|
||||
Bucket: "pass",
|
||||
StartedAt: "2026-08-21T10:00:00Z",
|
||||
CompletedAt: "2026-08-21T10:05:30Z",
|
||||
}
|
||||
if !c1.IsSuccess() || c1.IsFailure() || c1.IsPending() || c1.IsSkipped() {
|
||||
t.Fatalf("unexpected state helpers for c1: success=%v, fail=%v, pend=%v, skip=%v", c1.IsSuccess(), c1.IsFailure(), c1.IsPending(), c1.IsSkipped())
|
||||
}
|
||||
if d := c1.Duration(); d != "5m30s" {
|
||||
t.Fatalf("Duration() = %q, want 5m30s", d)
|
||||
}
|
||||
|
||||
c2 := Check{
|
||||
Name: "Fail Job",
|
||||
State: "FAILURE",
|
||||
Bucket: "fail",
|
||||
}
|
||||
if !c2.IsFailure() {
|
||||
t.Fatalf("expected c2 to be failure")
|
||||
}
|
||||
if c2.Duration() != "" {
|
||||
t.Fatalf("expected empty duration for missing timestamps")
|
||||
}
|
||||
|
||||
c3 := Check{
|
||||
Name: "Pending Job",
|
||||
Bucket: "pending",
|
||||
}
|
||||
if !c3.IsPending() {
|
||||
t.Fatalf("expected c3 to be pending")
|
||||
}
|
||||
|
||||
c4 := Check{
|
||||
Name: "Skipped Job",
|
||||
Bucket: "skipping",
|
||||
}
|
||||
if !c4.IsSkipped() {
|
||||
t.Fatalf("expected c4 to be skipped")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// Package logs keeps live `docker logs --follow` streams for running
|
||||
// containers so the sidebar can show output as it happens instead of polling.
|
||||
package logs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||
)
|
||||
|
||||
// retainedLines bounds memory per container. Homarr is chatty at boot, so the
|
||||
// buffer has to be deep enough to still contain the startup banner once the
|
||||
// app settles.
|
||||
const retainedLines = 2000
|
||||
|
||||
// maxStreams bounds how many containers are followed at once. Each stream is a
|
||||
// `docker logs --follow` process plus a buffer, which is cheap for the handful
|
||||
// of instances a developer normally runs but not for thirty. The least recently
|
||||
// viewed stream is dropped when the cap is reached; re-attaching replays the
|
||||
// tail, so nothing is lost beyond the scrollback.
|
||||
const maxStreams = 6
|
||||
|
||||
// State describes what a stream is currently doing.
|
||||
type State int
|
||||
|
||||
const (
|
||||
StateStarting State = iota
|
||||
StateStreaming
|
||||
StateStopped
|
||||
StateFailed
|
||||
)
|
||||
|
||||
// Stream is the retained output of one container.
|
||||
type Stream struct {
|
||||
mu sync.Mutex
|
||||
name string
|
||||
lines []string
|
||||
state State
|
||||
err error
|
||||
cancel context.CancelFunc
|
||||
since time.Time
|
||||
used time.Time
|
||||
}
|
||||
|
||||
// Registry owns every active log stream and notifies a single listener when new
|
||||
// output arrives.
|
||||
type Registry struct {
|
||||
mu sync.Mutex
|
||||
streams map[string]*Stream
|
||||
notify chan struct{}
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{streams: make(map[string]*Stream), notify: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
// Changed is a coalescing wake-up channel, mirroring task.Manager. A container
|
||||
// logging a thousand lines a second still costs one redraw per frame.
|
||||
func (r *Registry) Changed() <-chan struct{} { return r.notify }
|
||||
|
||||
func (r *Registry) touch() {
|
||||
select {
|
||||
case r.notify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Attach starts following a container's logs if it is not already being
|
||||
// followed. It returns immediately; output arrives on the Changed channel.
|
||||
func (r *Registry) Attach(name string) {
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
r.mu.Lock()
|
||||
if _, exists := r.streams[name]; exists {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
stream := &Stream{name: name, state: StateStarting, cancel: cancel, since: now, used: now}
|
||||
r.streams[name] = stream
|
||||
evicted := r.evictLocked()
|
||||
r.mu.Unlock()
|
||||
|
||||
for _, stale := range evicted {
|
||||
stale.cancel()
|
||||
}
|
||||
go r.follow(ctx, stream)
|
||||
r.touch()
|
||||
}
|
||||
|
||||
// evictLocked drops the least recently viewed streams above the cap. The caller
|
||||
// holds the lock and cancels the returned streams outside it.
|
||||
func (r *Registry) evictLocked() []*Stream {
|
||||
evicted := make([]*Stream, 0)
|
||||
for len(r.streams) > maxStreams {
|
||||
var oldest *Stream
|
||||
for _, stream := range r.streams {
|
||||
if oldest == nil || stream.used.Before(oldest.used) {
|
||||
oldest = stream
|
||||
}
|
||||
}
|
||||
if oldest == nil {
|
||||
break
|
||||
}
|
||||
delete(r.streams, oldest.name)
|
||||
evicted = append(evicted, oldest)
|
||||
}
|
||||
return evicted
|
||||
}
|
||||
|
||||
func (r *Registry) follow(ctx context.Context, stream *Stream) {
|
||||
lines, err := docker.StreamContainerLogs(ctx, stream.name, 400)
|
||||
if err != nil {
|
||||
stream.set(StateFailed, err)
|
||||
r.touch()
|
||||
return
|
||||
}
|
||||
stream.set(StateStreaming, nil)
|
||||
r.touch()
|
||||
for line := range lines {
|
||||
if line.Done {
|
||||
if ctx.Err() != nil {
|
||||
stream.set(StateStopped, nil)
|
||||
} else if line.Err != nil {
|
||||
stream.set(StateFailed, line.Err)
|
||||
} else {
|
||||
stream.set(StateStopped, nil)
|
||||
}
|
||||
r.touch()
|
||||
return
|
||||
}
|
||||
stream.append(line.Text)
|
||||
r.touch()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stream) append(line string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lines = append(s.lines, line)
|
||||
if len(s.lines) > retainedLines {
|
||||
s.lines = s.lines[len(s.lines)-retainedLines:]
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stream) set(state State, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.state, s.err = state, err
|
||||
}
|
||||
|
||||
// Detach stops following a container and drops its buffer.
|
||||
func (r *Registry) Detach(name string) {
|
||||
r.mu.Lock()
|
||||
stream, exists := r.streams[name]
|
||||
delete(r.streams, name)
|
||||
r.mu.Unlock()
|
||||
if exists && stream.cancel != nil {
|
||||
stream.cancel()
|
||||
}
|
||||
r.touch()
|
||||
}
|
||||
|
||||
// Retain stops streams for containers that no longer exist, so a removed
|
||||
// instance does not keep a dead `docker logs` process around.
|
||||
func (r *Registry) Retain(names []string) {
|
||||
live := make(map[string]bool, len(names))
|
||||
for _, name := range names {
|
||||
live[name] = true
|
||||
}
|
||||
r.mu.Lock()
|
||||
stale := make([]*Stream, 0)
|
||||
for name, stream := range r.streams {
|
||||
if !live[name] {
|
||||
stale = append(stale, stream)
|
||||
delete(r.streams, name)
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
for _, stream := range stale {
|
||||
if stream.cancel != nil {
|
||||
stream.cancel()
|
||||
}
|
||||
}
|
||||
if len(stale) > 0 {
|
||||
r.touch()
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops every stream.
|
||||
func (r *Registry) Close() { r.Retain(nil) }
|
||||
|
||||
// Snapshot is an immutable view of one stream.
|
||||
type Snapshot struct {
|
||||
Name string
|
||||
Lines []string
|
||||
State State
|
||||
Err error
|
||||
}
|
||||
|
||||
// Text joins the retained lines for rendering into a viewport.
|
||||
func (s Snapshot) Text() string { return strings.Join(s.Lines, "\n") }
|
||||
|
||||
// Snapshot returns the retained output for a container. The second result is
|
||||
// false when the container is not being followed.
|
||||
func (r *Registry) Snapshot(name string) (Snapshot, bool) {
|
||||
r.mu.Lock()
|
||||
stream, exists := r.streams[name]
|
||||
if exists {
|
||||
// Viewing a stream is what keeps it alive under the cap.
|
||||
stream.used = time.Now()
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if !exists {
|
||||
return Snapshot{}, false
|
||||
}
|
||||
stream.mu.Lock()
|
||||
defer stream.mu.Unlock()
|
||||
return Snapshot{
|
||||
Name: stream.name,
|
||||
Lines: append([]string(nil), stream.lines...),
|
||||
State: stream.state,
|
||||
Err: stream.err,
|
||||
}, true
|
||||
}
|
||||
|
||||
// Attached lists the containers currently being followed.
|
||||
func (r *Registry) Attached() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
names := make([]string, 0, len(r.streams))
|
||||
for name := range r.streams {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRegistryAttachAndSnapshot(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
defer reg.Close()
|
||||
|
||||
snap, attached := reg.Snapshot("nonexistent")
|
||||
if attached {
|
||||
t.Fatalf("expected attached=false for unattached container")
|
||||
}
|
||||
|
||||
reg.Attach("test-container")
|
||||
snap, attached = reg.Snapshot("test-container")
|
||||
if !attached {
|
||||
t.Fatalf("expected attached=true after Attach")
|
||||
}
|
||||
if snap.State != StateStarting && snap.State != StateStreaming {
|
||||
t.Fatalf("expected StateStarting or StateStreaming, got %v", snap.State)
|
||||
}
|
||||
|
||||
reg.Detach("test-container")
|
||||
snap, attached = reg.Snapshot("test-container")
|
||||
if attached {
|
||||
t.Fatalf("expected attached=false after Detach")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryChangedNotification(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
defer reg.Close()
|
||||
|
||||
ch := reg.Changed()
|
||||
if ch == nil {
|
||||
t.Fatal("expected non-nil Changed channel")
|
||||
}
|
||||
|
||||
reg.touch()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
t.Fatal("expected notification on Changed channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamLineBufferingAndBounds(t *testing.T) {
|
||||
s := &Stream{
|
||||
name: "test",
|
||||
since: time.Now(),
|
||||
used: time.Now(),
|
||||
}
|
||||
|
||||
for i := 0; i < retainedLines+50; i++ {
|
||||
s.append(fmt.Sprintf("line %d", i))
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
lines := make([]string, len(s.lines))
|
||||
copy(lines, s.lines)
|
||||
s.mu.Unlock()
|
||||
|
||||
if len(lines) != retainedLines {
|
||||
t.Fatalf("expected %d retained lines, got %d", retainedLines, len(lines))
|
||||
}
|
||||
|
||||
if lines[0] != "line 50" {
|
||||
t.Errorf("first retained line = %q, want 'line 50'", lines[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryRetainAndEviction(t *testing.T) {
|
||||
reg := NewRegistry()
|
||||
defer reg.Close()
|
||||
|
||||
reg.Attach("c1")
|
||||
reg.Attach("c2")
|
||||
reg.Attach("c3")
|
||||
|
||||
// Retain only c1 and c3
|
||||
reg.Retain([]string{"c1", "c3"})
|
||||
|
||||
if _, attached := reg.Snapshot("c2"); attached {
|
||||
t.Errorf("c2 should have been dropped by Retain")
|
||||
}
|
||||
if _, attached := reg.Snapshot("c1"); !attached {
|
||||
t.Errorf("c1 should still be attached")
|
||||
}
|
||||
if _, attached := reg.Snapshot("c3"); !attached {
|
||||
t.Errorf("c3 should still be attached")
|
||||
}
|
||||
}
|
||||
@@ -14,15 +14,16 @@ func OpenURL(url string) error {
|
||||
return exec.Command(name, args...).Start()
|
||||
}
|
||||
|
||||
func openCommand(goos, url string) (string, []string, error) {
|
||||
switch goos {
|
||||
case "darwin":
|
||||
return "open", []string{url}, nil
|
||||
case "linux":
|
||||
return "xdg-open", []string{url}, nil
|
||||
case "windows":
|
||||
return "rundll32", []string{"url.dll,FileProtocolHandler", url}, nil
|
||||
default:
|
||||
return "", nil, fmt.Errorf("opening URLs is not supported on %s", goos)
|
||||
}
|
||||
var platformOpeners = map[string]func(string) (string, []string){
|
||||
"darwin": func(u string) (string, []string) { return "open", []string{u} },
|
||||
"linux": func(u string) (string, []string) { return "xdg-open", []string{u} },
|
||||
"windows": func(u string) (string, []string) { return "rundll32", []string{"url.dll,FileProtocolHandler", u} },
|
||||
}
|
||||
|
||||
func openCommand(goos, url string) (string, []string, error) {
|
||||
if opener, supported := platformOpeners[goos]; supported {
|
||||
name, args := opener(url)
|
||||
return name, args, nil
|
||||
}
|
||||
return "", nil, fmt.Errorf("opening URLs is not supported on %s", goos)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Package registry queries GHCR for the pull-request test images Homarr's CI
|
||||
// publishes.
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestImageRepository is where CI pushes one image per open pull request.
|
||||
const TestImageRepository = "homarr-labs/homarr-test"
|
||||
|
||||
// ImageReference returns the GHCR reference for a pull request's test image.
|
||||
func ImageReference(pr int) string {
|
||||
return fmt.Sprintf("ghcr.io/%s:pr-%d", TestImageRepository, pr)
|
||||
}
|
||||
|
||||
const (
|
||||
tagCacheTTL = 2 * time.Minute
|
||||
httpTimeout = 20 * time.Second
|
||||
)
|
||||
|
||||
var tagCache struct {
|
||||
sync.Mutex
|
||||
tags map[string]bool
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
var client = &http.Client{Timeout: httpTimeout}
|
||||
|
||||
// PublishedTags lists every tag in the test image repository in a single
|
||||
// request. Checking availability one pull request at a time with `docker
|
||||
// manifest inspect` costs one network round trip per row, which dominates
|
||||
// startup on a busy repository; the registry's own tag listing answers the same
|
||||
// question for every row at once.
|
||||
func PublishedTags(ctx context.Context, refresh bool) (map[string]bool, error) {
|
||||
tagCache.Lock()
|
||||
cached, fetchedAt := tagCache.tags, tagCache.fetchedAt
|
||||
tagCache.Unlock()
|
||||
if !refresh && cached != nil && time.Since(fetchedAt) < tagCacheTTL {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
token, err := anonymousToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags, err := listTags(ctx, token)
|
||||
if err != nil {
|
||||
if cached != nil {
|
||||
return cached, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tagCache.Lock()
|
||||
tagCache.tags, tagCache.fetchedAt = tags, time.Now()
|
||||
tagCache.Unlock()
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
// HasPRImage reports whether CI has published an image for a pull request.
|
||||
func HasPRImage(tags map[string]bool, pr int) bool {
|
||||
return tags["pr-"+strconv.Itoa(pr)]
|
||||
}
|
||||
|
||||
func anonymousToken(ctx context.Context) (string, error) {
|
||||
url := fmt.Sprintf("https://ghcr.io/token?scope=repository:%s:pull&service=ghcr.io", TestImageRepository)
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request GHCR token: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("request GHCR token: %s", response.Status)
|
||||
}
|
||||
var payload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||
return "", fmt.Errorf("decode GHCR token: %w", err)
|
||||
}
|
||||
return payload.Token, nil
|
||||
}
|
||||
|
||||
func listTags(ctx context.Context, token string) (map[string]bool, error) {
|
||||
tags := make(map[string]bool)
|
||||
next := fmt.Sprintf("https://ghcr.io/v2/%s/tags/list?n=1000", TestImageRepository)
|
||||
for page := 0; next != "" && page < 20; page++ {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list GHCR tags: %w", err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
||||
_ = response.Body.Close()
|
||||
return nil, fmt.Errorf("list GHCR tags: %s: %s", response.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
var payload struct {
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
err = json.NewDecoder(response.Body).Decode(&payload)
|
||||
linkHeader := response.Header.Get("Link")
|
||||
_ = response.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode GHCR tags: %w", err)
|
||||
}
|
||||
for _, tag := range payload.Tags {
|
||||
tags[tag] = true
|
||||
}
|
||||
next = nextPageURL(linkHeader)
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
// nextPageURL follows the RFC 5988 Link header the registry uses for paging.
|
||||
func nextPageURL(link string) string {
|
||||
for _, part := range strings.Split(link, ",") {
|
||||
if !strings.Contains(part, `rel="next"`) {
|
||||
continue
|
||||
}
|
||||
start := strings.Index(part, "<")
|
||||
end := strings.Index(part, ">")
|
||||
if start < 0 || end <= start {
|
||||
continue
|
||||
}
|
||||
target := part[start+1 : end]
|
||||
if strings.HasPrefix(target, "/") {
|
||||
return "https://ghcr.io" + target
|
||||
}
|
||||
return target
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ManifestExists falls back to the Docker CLI for a single reference, which
|
||||
// also works for private repositories where the anonymous listing is refused.
|
||||
func ManifestExists(ctx context.Context, reference string) (bool, error) {
|
||||
out, err := exec.CommandContext(ctx, "docker", "manifest", "inspect", reference).CombinedOutput()
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
message := strings.ToLower(string(out))
|
||||
if strings.Contains(message, "manifest unknown") || strings.Contains(message, "no such manifest") {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("check %s: %w: %s", reference, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPublishedTagsIntegration(t *testing.T) {
|
||||
if testing.Short() || os.Getenv("HOMARR_INTEGRATION_TEST") != "1" {
|
||||
t.Skip("skipping GHCR integration test; set HOMARR_INTEGRATION_TEST=1 to run")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
tags, err := PublishedTags(ctx, true)
|
||||
if err != nil {
|
||||
t.Skipf("GHCR unreachable: %v", err)
|
||||
}
|
||||
t.Logf("fetched %d tags in %s", len(tags), time.Since(start))
|
||||
if len(tags) == 0 {
|
||||
t.Fatal("expected at least one published tag")
|
||||
}
|
||||
found := 0
|
||||
for tag := range tags {
|
||||
if found < 5 {
|
||||
t.Log(" ", tag)
|
||||
found++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,9 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
|
||||
)
|
||||
|
||||
func BuildCurrentImage(tag, directory string) error {
|
||||
@@ -19,11 +17,11 @@ func BuildCurrentImage(tag, directory string) error {
|
||||
}
|
||||
|
||||
func buildCurrentImage(ctx context.Context, tag, directory string, stdout, stderr io.Writer) error {
|
||||
root, revision, err := checkoutDetails(ctx, directory)
|
||||
options, err := CurrentBuildOptions(ctx, tag, directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return docker.BuildContext(ctx, docker.BuildOptions{Context: root, Tag: tag, Source: root, Revision: revision}, stdout, stderr)
|
||||
return docker.BuildContext(ctx, options, stdout, stderr)
|
||||
}
|
||||
|
||||
func BuildPRImage(number int, tag string) error {
|
||||
@@ -31,44 +29,18 @@ func BuildPRImage(number int, tag string) error {
|
||||
}
|
||||
|
||||
func buildPRImage(ctx context.Context, number int, tag string, stdout, stderr io.Writer) error {
|
||||
if number <= 0 {
|
||||
return fmt.Errorf("PR number must be positive")
|
||||
}
|
||||
temporaryRoot, err := os.MkdirTemp("", "homarr-pr-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary checkout: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(temporaryRoot)
|
||||
|
||||
checkout := filepath.Join(temporaryRoot, "homarr")
|
||||
clone := exec.CommandContext(ctx, "gh", prCloneArgs(checkout)...)
|
||||
clone.Stdout = stdout
|
||||
clone.Stderr = stderr
|
||||
if err := clone.Run(); err != nil {
|
||||
return fmt.Errorf("clone %s: %w", gh.Repo, err)
|
||||
}
|
||||
command := exec.CommandContext(ctx, "gh", "pr", "checkout", strconv.Itoa(number), "--detach")
|
||||
command.Dir = checkout
|
||||
command.Stdout = stdout
|
||||
command.Stderr = stderr
|
||||
if err := command.Run(); err != nil {
|
||||
return fmt.Errorf("checkout PR #%d: %w", number, err)
|
||||
}
|
||||
_, revision, err := checkoutDetails(ctx, checkout)
|
||||
options, cleanup, err := PRBuildOptions(ctx, number, tag, func(line string) {
|
||||
_, _ = fmt.Fprintln(stdout, line)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return docker.BuildContext(ctx, docker.BuildOptions{
|
||||
Context: checkout,
|
||||
Tag: tag,
|
||||
Source: fmt.Sprintf("https://github.com/%s/pull/%d", gh.Repo, number),
|
||||
Revision: revision,
|
||||
PRNumber: number,
|
||||
}, stdout, stderr)
|
||||
defer cleanup()
|
||||
return docker.BuildContext(ctx, options, stdout, stderr)
|
||||
}
|
||||
|
||||
func prCloneArgs(checkout string) []string {
|
||||
return []string{"repo", "clone", gh.Repo, checkout, "--", "--depth=1", "--filter=blob:none"}
|
||||
func prFetchArgs(repoURL, refSpec string) []string {
|
||||
return []string{"fetch", "--depth=1", repoURL, refSpec}
|
||||
}
|
||||
|
||||
func RebuildImage(image docker.Image) error {
|
||||
@@ -89,7 +61,16 @@ func RebuildImageContext(ctx context.Context, image docker.Image, stdout, stderr
|
||||
}
|
||||
|
||||
func checkoutDetails(ctx context.Context, directory string) (root string, revision string, err error) {
|
||||
rootOutput, err := exec.CommandContext(ctx, "git", "-C", directory, "rev-parse", "--show-toplevel").CombinedOutput()
|
||||
cmdRoot := exec.CommandContext(ctx, "git", "-C", directory, "rev-parse", "--show-toplevel")
|
||||
cmdRoot.Env = append(
|
||||
os.Environ(),
|
||||
"MISE_TRUSTED_CONFIG_PATHS=*",
|
||||
"MISE_QUIET=1",
|
||||
"MISE_SILENT=1",
|
||||
"MISE_YES=1",
|
||||
"MISE_OVERRIDE_CONFIG_FILENAMES=",
|
||||
)
|
||||
rootOutput, err := cmdRoot.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("find Homarr checkout: %w: %s", err, strings.TrimSpace(string(rootOutput)))
|
||||
}
|
||||
@@ -97,7 +78,16 @@ func checkoutDetails(ctx context.Context, directory string) (root string, revisi
|
||||
if _, err := os.Stat(filepath.Join(root, "Dockerfile")); err != nil {
|
||||
return "", "", fmt.Errorf("%s is not a Homarr checkout", root)
|
||||
}
|
||||
revisionOutput, err := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "HEAD").CombinedOutput()
|
||||
cmdRev := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "HEAD")
|
||||
cmdRev.Env = append(
|
||||
os.Environ(),
|
||||
"MISE_TRUSTED_CONFIG_PATHS=*",
|
||||
"MISE_QUIET=1",
|
||||
"MISE_SILENT=1",
|
||||
"MISE_YES=1",
|
||||
"MISE_OVERRIDE_CONFIG_FILENAMES=",
|
||||
)
|
||||
revisionOutput, err := cmdRev.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("read checkout revision: %w: %s", err, strings.TrimSpace(string(revisionOutput)))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
|
||||
)
|
||||
|
||||
// Cleanup releases a temporary checkout. It is always safe to call.
|
||||
type Cleanup func()
|
||||
|
||||
func noCleanup() {}
|
||||
|
||||
// CurrentBuildOptions describes a build of the checkout that contains
|
||||
// directory, recording the source and revision on the image so it can be
|
||||
// rebuilt later without the user remembering where it came from.
|
||||
func CurrentBuildOptions(ctx context.Context, tag, directory string) (docker.BuildOptions, error) {
|
||||
root, revision, err := checkoutDetails(ctx, directory)
|
||||
if err != nil {
|
||||
return docker.BuildOptions{}, err
|
||||
}
|
||||
return docker.BuildOptions{Context: root, Tag: tag, Source: root, Revision: revision}, nil
|
||||
}
|
||||
|
||||
// PRBuildOptions clones a pull request into a temporary checkout and describes
|
||||
// a build of it. The returned Cleanup must run once the build has finished.
|
||||
//
|
||||
// Progress is reported through log rather than written to stdout, because the
|
||||
// caller is a background job rendering inside a TUI, not a shell.
|
||||
func PRBuildOptions(ctx context.Context, number int, tag string, log func(string)) (docker.BuildOptions, Cleanup, error) {
|
||||
if number <= 0 {
|
||||
return docker.BuildOptions{}, noCleanup, fmt.Errorf("PR number must be positive")
|
||||
}
|
||||
if log == nil {
|
||||
log = func(string) {}
|
||||
}
|
||||
temporaryRoot, err := os.MkdirTemp("", "homarr-pr-*")
|
||||
if err != nil {
|
||||
return docker.BuildOptions{}, noCleanup, fmt.Errorf("create temporary checkout: %w", err)
|
||||
}
|
||||
cleanup := func() { _ = os.RemoveAll(temporaryRoot) }
|
||||
|
||||
checkout := filepath.Join(temporaryRoot, "homarr")
|
||||
if err := os.MkdirAll(checkout, 0o755); err != nil {
|
||||
cleanup()
|
||||
return docker.BuildOptions{}, noCleanup, fmt.Errorf("create checkout directory: %w", err)
|
||||
}
|
||||
|
||||
repoURL := fmt.Sprintf("https://github.com/%s.git", gh.Repo)
|
||||
branchName := fmt.Sprintf("pr-%d", number)
|
||||
refSpec := fmt.Sprintf("refs/pull/%d/head:%s", number, branchName)
|
||||
|
||||
log(fmt.Sprintf("fetching PR #%d (shallow) from %s", number, gh.Repo))
|
||||
initCmd := exec.CommandContext(ctx, "git", "init", checkout)
|
||||
if err := runLogged(initCmd, log); err != nil {
|
||||
cleanup()
|
||||
return docker.BuildOptions{}, noCleanup, fmt.Errorf("init git repository: %w", err)
|
||||
}
|
||||
|
||||
fetchCmd := exec.CommandContext(ctx, "git", "-C", checkout, "fetch", "--depth", "1", repoURL, refSpec)
|
||||
if err := runLogged(fetchCmd, log); err != nil {
|
||||
cleanup()
|
||||
return docker.BuildOptions{}, noCleanup, fmt.Errorf("fetch PR #%d: %w", number, err)
|
||||
}
|
||||
|
||||
checkoutCmd := exec.CommandContext(ctx, "git", "-C", checkout, "checkout", branchName)
|
||||
if err := runLogged(checkoutCmd, log); err != nil {
|
||||
cleanup()
|
||||
return docker.BuildOptions{}, noCleanup, fmt.Errorf("checkout PR #%d: %w", number, err)
|
||||
}
|
||||
|
||||
_, revision, err := checkoutDetails(ctx, checkout)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return docker.BuildOptions{}, noCleanup, err
|
||||
}
|
||||
log("building " + revision[:min(len(revision), 12)])
|
||||
return docker.BuildOptions{
|
||||
Context: checkout,
|
||||
Tag: tag,
|
||||
Source: fmt.Sprintf("https://github.com/%s/pull/%d", gh.Repo, number),
|
||||
Revision: revision,
|
||||
PRNumber: number,
|
||||
}, cleanup, nil
|
||||
}
|
||||
|
||||
// RebuildOptions describes a rebuild of an existing local image from the
|
||||
// provenance recorded on it, whether that is a pull request or a checkout.
|
||||
func RebuildOptions(ctx context.Context, image docker.Image, log func(string)) (docker.BuildOptions, Cleanup, error) {
|
||||
if image.PRNumber > 0 {
|
||||
return PRBuildOptions(ctx, image.PRNumber, image.Tag, log)
|
||||
}
|
||||
if image.Source == "" {
|
||||
return docker.BuildOptions{}, noCleanup, fmt.Errorf("homarr:%s has no build source metadata", image.Tag)
|
||||
}
|
||||
if info, err := os.Stat(image.Source); err != nil || !info.IsDir() {
|
||||
return docker.BuildOptions{}, noCleanup, fmt.Errorf("build source %s is no longer available", image.Source)
|
||||
}
|
||||
options, err := CurrentBuildOptions(ctx, image.Tag, image.Source)
|
||||
return options, noCleanup, err
|
||||
}
|
||||
|
||||
func runLogged(command *exec.Cmd, log func(string)) error {
|
||||
command.Env = append(
|
||||
os.Environ(),
|
||||
"MISE_TRUSTED_CONFIG_PATHS=*",
|
||||
"MISE_QUIET=1",
|
||||
"MISE_SILENT=1",
|
||||
"MISE_YES=1",
|
||||
"MISE_OVERRIDE_CONFIG_FILENAMES=",
|
||||
)
|
||||
lines, err := docker.StreamCommand(command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for line := range lines {
|
||||
if line.Done {
|
||||
return line.Err
|
||||
}
|
||||
log(line.Text)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -19,10 +19,10 @@ func TestPRPlanUsesAMD64(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRCloneIsShallow(t *testing.T) {
|
||||
args := prCloneArgs("/tmp/checkout")
|
||||
if !slices.Contains(args, "--depth=1") || !slices.Contains(args, "--filter=blob:none") {
|
||||
t.Fatalf("clone args = %v", args)
|
||||
func TestPRFetchIsShallow(t *testing.T) {
|
||||
args := prFetchArgs("https://github.com/homarr-labs/homarr.git", "refs/pull/1/head:pr-1")
|
||||
if !slices.Contains(args, "--depth=1") {
|
||||
t.Fatalf("fetch args = %v", args)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||
)
|
||||
|
||||
// Exercises the real BuildKit streaming and parsing path against a small
|
||||
// Dockerfile, so the step tracker is validated on genuine output.
|
||||
func TestBuildImageStreamsRealSteps(t *testing.T) {
|
||||
if testing.Short() || os.Getenv("HOMARR_INTEGRATION_TEST") != "1" {
|
||||
t.Skip("skipping docker build probe test; set HOMARR_INTEGRATION_TEST=1 to run")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
dockerfile := `FROM alpine:3.20 AS base
|
||||
RUN echo "hello from step two" && sleep 1
|
||||
FROM base AS final
|
||||
RUN echo "third step" > /out.txt
|
||||
RUN cat /out.txt
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
manager := NewManager()
|
||||
task := manager.Build("build homarr:cli-probe", "homarr:cli-probe", docker.BuildOptions{
|
||||
Context: dir, Tag: "cli-probe", Source: dir, Revision: "probe",
|
||||
})
|
||||
t.Cleanup(func() { _ = docker.RemoveImage(context.Background(), "homarr:cli-probe") })
|
||||
|
||||
for range 600 {
|
||||
<-manager.Changed()
|
||||
if snapshot, _ := manager.Snapshot(task.ID()); snapshot.State.Done() {
|
||||
t.Logf("state=%s percent=%.2f detail=%q err=%v", snapshot.State, snapshot.Percent, snapshot.Detail, snapshot.Err)
|
||||
for _, step := range snapshot.Steps {
|
||||
t.Logf(" #%-3s done=%-5v %-46s %s", step.ID, step.Done, truncate(step.Label, 46), step.Note)
|
||||
}
|
||||
if snapshot.State != StateSucceeded {
|
||||
t.Fatalf("build did not succeed: %v", snapshot.Err)
|
||||
}
|
||||
if len(snapshot.Steps) < 4 {
|
||||
t.Fatalf("expected several build steps, got %d", len(snapshot.Steps))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("build did not finish")
|
||||
}
|
||||
|
||||
func truncate(value string, width int) string {
|
||||
if len(value) <= width {
|
||||
return value
|
||||
}
|
||||
return value[:width]
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||
)
|
||||
|
||||
// Pull downloads an image, preferring the Docker Engine API because it reports
|
||||
// per-layer byte progress even when the CLI would not. If the socket is
|
||||
// unreachable the plain `docker pull` output is parsed instead, which still
|
||||
// yields per-layer state, just without byte counters.
|
||||
func (m *Manager) Pull(title, target, image, platform string) *Task {
|
||||
return m.Start(KindPull, title, target, func(ctx context.Context, report *Reporter) error {
|
||||
return PullImage(ctx, report, image, platform)
|
||||
})
|
||||
}
|
||||
|
||||
// PullImage is the body of a pull, exported so callers can compose it with
|
||||
// follow-up work — pulling and then starting a container reads better as one
|
||||
// job than as two.
|
||||
func PullImage(ctx context.Context, report *Reporter, image, platform string) error {
|
||||
tracker := docker.NewPullTracker()
|
||||
publish := throttled(report, func() {
|
||||
report.SetSteps(pullSteps(tracker))
|
||||
report.SetPercent(tracker.Percent())
|
||||
report.SetDetail(pullDetail(tracker))
|
||||
})
|
||||
|
||||
messages, err := docker.PullStream(ctx, image, platform)
|
||||
if err != nil {
|
||||
report.Log("engine API unavailable (" + err.Error() + "), falling back to docker pull")
|
||||
return pullWithCLI(ctx, report, tracker, publish, image, platform)
|
||||
}
|
||||
for message := range messages {
|
||||
if message.Error != "" {
|
||||
return fmt.Errorf("pull %s: %s", image, message.Error)
|
||||
}
|
||||
tracker.Apply(message)
|
||||
if message.ID == "" && message.Status != "" {
|
||||
report.Log(message.Status)
|
||||
}
|
||||
publish(false)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
publish(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func pullWithCLI(ctx context.Context, report *Reporter, tracker *docker.PullTracker, publish func(bool), image, platform string) error {
|
||||
command := docker.PullCommandContext(ctx, image, platform)
|
||||
command.Cancel = func() error { return command.Process.Kill() }
|
||||
lines, err := docker.StreamCommand(command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for line := range lines {
|
||||
if line.Done {
|
||||
if line.Err != nil {
|
||||
return line.Err
|
||||
}
|
||||
break
|
||||
}
|
||||
tracker.ApplyLine(line.Text)
|
||||
publish(false)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
publish(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func pullSteps(tracker *docker.PullTracker) []Step {
|
||||
layers := tracker.Layers()
|
||||
steps := make([]Step, 0, len(layers))
|
||||
for _, layer := range layers {
|
||||
note := ""
|
||||
if layer.Total > 0 && !layer.Done() {
|
||||
note = docker.FormatSize(layer.Current) + "/" + docker.FormatSize(layer.Total)
|
||||
} else if layer.Total > 0 {
|
||||
note = docker.FormatSize(layer.Total)
|
||||
}
|
||||
steps = append(steps, Step{
|
||||
ID: layer.ID,
|
||||
Label: layer.ID,
|
||||
Status: layer.Status,
|
||||
Current: layer.Current,
|
||||
Total: layer.Total,
|
||||
Percent: layer.Fraction(),
|
||||
Done: layer.Done(),
|
||||
Note: note,
|
||||
})
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
func pullDetail(tracker *docker.PullTracker) string {
|
||||
complete, total := tracker.Complete()
|
||||
parts := []string{fmt.Sprintf("%d/%d layers", complete, total)}
|
||||
if current, size := tracker.Bytes(); size > 0 {
|
||||
parts = append(parts, docker.FormatSize(current)+"/"+docker.FormatSize(size))
|
||||
}
|
||||
if rate := tracker.Rate(); rate > 0 {
|
||||
parts = append(parts, docker.FormatSize(int64(rate))+"/s")
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
// Build compiles an image, streaming BuildKit's plain progress so the UI can
|
||||
// show which Dockerfile step is running and what it is printing.
|
||||
func (m *Manager) Build(title, target string, options docker.BuildOptions) *Task {
|
||||
return m.Start(KindBuild, title, target, func(ctx context.Context, report *Reporter) error {
|
||||
return BuildImage(ctx, report, options)
|
||||
})
|
||||
}
|
||||
|
||||
// BuildFrom runs a caller-supplied preparation step (cloning a pull request,
|
||||
// for example) before building. Preparation output is reported the same way as
|
||||
// build output so the job reads as one continuous stream.
|
||||
func (m *Manager) BuildFrom(title, target string, prepare func(ctx context.Context, report *Reporter) (docker.BuildOptions, error)) *Task {
|
||||
return m.Start(KindBuild, title, target, func(ctx context.Context, report *Reporter) error {
|
||||
options, err := prepare(ctx, report)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return BuildImage(ctx, report, options)
|
||||
})
|
||||
}
|
||||
|
||||
// BuildImage is the body of a build, exported for the same reason as PullImage.
|
||||
func BuildImage(ctx context.Context, report *Reporter, options docker.BuildOptions) error {
|
||||
command, err := docker.BuildCommandStreaming(ctx, options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
command.Cancel = func() error { return command.Process.Kill() }
|
||||
lines, err := docker.StreamCommand(command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tracker := docker.NewBuildTracker()
|
||||
publish := throttled(report, func() {
|
||||
report.SetSteps(buildSteps(tracker))
|
||||
report.SetPercent(tracker.Percent())
|
||||
report.SetDetail(buildDetail(tracker))
|
||||
})
|
||||
for line := range lines {
|
||||
if line.Done {
|
||||
if line.Err != nil {
|
||||
if detail := tracker.LastMessage(); detail != "" && !strings.Contains(line.Err.Error(), detail) {
|
||||
return fmt.Errorf("%w: %s", line.Err, detail)
|
||||
}
|
||||
return line.Err
|
||||
}
|
||||
break
|
||||
}
|
||||
tracker.ApplyLine(line.Text)
|
||||
report.Log(line.Text)
|
||||
publish(false)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
publish(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildSteps(tracker *docker.BuildTracker) []Step {
|
||||
steps := tracker.Steps()
|
||||
converted := make([]Step, 0, len(steps))
|
||||
for _, step := range steps {
|
||||
label := step.Label
|
||||
if label == "" {
|
||||
label = "#" + step.ID
|
||||
}
|
||||
percent := 0.0
|
||||
if step.Done && !step.Canceled && !step.Failed {
|
||||
percent = 1
|
||||
}
|
||||
note := step.Elapsed
|
||||
if step.Cached {
|
||||
note = "cached"
|
||||
}
|
||||
converted = append(converted, Step{
|
||||
ID: step.ID,
|
||||
Label: label,
|
||||
Status: step.Status,
|
||||
Percent: percent,
|
||||
Done: step.Done && !step.Canceled,
|
||||
Failed: step.Failed,
|
||||
Note: note,
|
||||
})
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
func buildDetail(tracker *docker.BuildTracker) string {
|
||||
complete, total := tracker.Complete()
|
||||
detail := fmt.Sprintf("%d/%d steps", complete, total)
|
||||
if active, running := tracker.Active(); running {
|
||||
detail += " · " + active.Label
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
// Action runs a short operation such as stopping a container. It still goes
|
||||
// through the manager so a slow daemon can never freeze the interface.
|
||||
func (m *Manager) Action(title, target string, body func(ctx context.Context) error) *Task {
|
||||
return m.Start(KindAction, title, target, func(ctx context.Context, report *Reporter) error {
|
||||
return body(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
// throttled limits how often a progress publisher runs. Docker emits progress
|
||||
// far faster than a terminal can redraw, and every publish copies slices, so
|
||||
// coalescing here keeps the cost proportional to the frame rate instead of to
|
||||
// the daemon's chattiness. Passing force publishes regardless, which the caller
|
||||
// does once at the end so the final state is never dropped.
|
||||
func throttled(report *Reporter, publish func()) func(force bool) {
|
||||
const interval = 80 * time.Millisecond
|
||||
last := time.Time{}
|
||||
return func(force bool) {
|
||||
now := time.Now()
|
||||
if !force && now.Sub(last) < interval {
|
||||
return
|
||||
}
|
||||
last = now
|
||||
publish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// Package task runs the CLI's slow work — image pulls, local builds, container
|
||||
// actions — on background goroutines and exposes it as immutable snapshots.
|
||||
//
|
||||
// Nothing in this package blocks the caller. A Manager owns every running job,
|
||||
// coalesces their updates into a single wake-up channel, and hands the UI a
|
||||
// consistent view whenever it asks for one. That separation is what keeps the
|
||||
// terminal responsive while a fifteen minute Docker build streams output.
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Kind classifies a job so the UI can pick an icon and a detail renderer.
|
||||
type Kind int
|
||||
|
||||
const (
|
||||
KindPull Kind = iota
|
||||
KindBuild
|
||||
KindAction
|
||||
)
|
||||
|
||||
var kindNames = map[Kind]string{
|
||||
KindPull: "pull",
|
||||
KindBuild: "build",
|
||||
KindAction: "action",
|
||||
}
|
||||
|
||||
func (k Kind) String() string {
|
||||
if name, found := kindNames[k]; found {
|
||||
return name
|
||||
}
|
||||
return "action"
|
||||
}
|
||||
|
||||
// State is the lifecycle stage of a job.
|
||||
type State int
|
||||
|
||||
const (
|
||||
StateRunning State = iota
|
||||
StateSucceeded
|
||||
StateFailed
|
||||
StateCanceled
|
||||
)
|
||||
|
||||
func (s State) Done() bool { return s != StateRunning }
|
||||
|
||||
var stateNames = map[State]string{
|
||||
StateRunning: "running",
|
||||
StateSucceeded: "succeeded",
|
||||
StateFailed: "failed",
|
||||
StateCanceled: "canceled",
|
||||
}
|
||||
|
||||
func (s State) String() string {
|
||||
if name, found := stateNames[s]; found {
|
||||
return name
|
||||
}
|
||||
return "canceled"
|
||||
}
|
||||
|
||||
// Step is one unit of progress inside a job: a Docker layer during a pull, or a
|
||||
// BuildKit vertex during a build.
|
||||
type Step struct {
|
||||
ID string
|
||||
Label string
|
||||
Status string
|
||||
Current int64
|
||||
Total int64
|
||||
Percent float64
|
||||
Done bool
|
||||
Failed bool
|
||||
Note string
|
||||
}
|
||||
|
||||
// Snapshot is an immutable view of a job. The UI only ever sees snapshots, so
|
||||
// it can render without holding a lock or racing the worker goroutine.
|
||||
type Snapshot struct {
|
||||
ID int
|
||||
Kind Kind
|
||||
Title string
|
||||
Target string
|
||||
Detail string
|
||||
State State
|
||||
Err error
|
||||
Percent float64
|
||||
Steps []Step
|
||||
Lines []string
|
||||
Started time.Time
|
||||
Ended time.Time
|
||||
Cancellable bool
|
||||
}
|
||||
|
||||
// Elapsed reports how long the job ran, or has been running.
|
||||
func (s Snapshot) Elapsed() time.Duration {
|
||||
if s.Ended.IsZero() {
|
||||
return time.Since(s.Started)
|
||||
}
|
||||
return s.Ended.Sub(s.Started)
|
||||
}
|
||||
|
||||
// Reporter is the handle a job body uses to publish progress. Every method is
|
||||
// safe to call from any goroutine and never blocks on the UI.
|
||||
type Reporter struct {
|
||||
task *Task
|
||||
}
|
||||
|
||||
// Log appends a line of output. Only the most recent lines are retained.
|
||||
func (r *Reporter) Log(line string) {
|
||||
r.task.mutate(func(t *Task) { t.lines = appendLine(t.lines, line) })
|
||||
}
|
||||
|
||||
// SetSteps replaces the job's progress breakdown.
|
||||
func (r *Reporter) SetSteps(steps []Step) {
|
||||
r.task.mutate(func(t *Task) { t.steps = steps })
|
||||
}
|
||||
|
||||
// SetPercent records overall progress in the range [0,1].
|
||||
func (r *Reporter) SetPercent(percent float64) {
|
||||
r.task.mutate(func(t *Task) { t.percent = clamp(percent) })
|
||||
}
|
||||
|
||||
// SetDetail replaces the single-line summary shown next to the job.
|
||||
func (r *Reporter) SetDetail(detail string) {
|
||||
r.task.mutate(func(t *Task) { t.detail = detail })
|
||||
}
|
||||
|
||||
// Lines returns the retained output, newest last.
|
||||
func (r *Reporter) Lines() []string {
|
||||
r.task.mu.Lock()
|
||||
defer r.task.mu.Unlock()
|
||||
return append([]string(nil), r.task.lines...)
|
||||
}
|
||||
|
||||
// Body is the work a job performs. Returning an error fails the job; returning
|
||||
// ctx.Err() after cancellation marks it canceled instead.
|
||||
type Body func(ctx context.Context, report *Reporter) error
|
||||
|
||||
// Task is a single background job.
|
||||
type Task struct {
|
||||
mu sync.Mutex
|
||||
manager *Manager
|
||||
|
||||
id int
|
||||
kind Kind
|
||||
title string
|
||||
target string
|
||||
detail string
|
||||
state State
|
||||
err error
|
||||
percent float64
|
||||
steps []Step
|
||||
lines []string
|
||||
started time.Time
|
||||
ended time.Time
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
const retainedLines = 500
|
||||
|
||||
func appendLine(lines []string, line string) []string {
|
||||
lines = append(lines, line)
|
||||
if len(lines) > retainedLines {
|
||||
lines = lines[len(lines)-retainedLines:]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func clamp(value float64) float64 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
if value > 1 {
|
||||
return 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (t *Task) mutate(apply func(*Task)) {
|
||||
t.mu.Lock()
|
||||
apply(t)
|
||||
t.mu.Unlock()
|
||||
t.manager.touch()
|
||||
}
|
||||
|
||||
func (t *Task) snapshot() Snapshot {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return Snapshot{
|
||||
ID: t.id,
|
||||
Kind: t.kind,
|
||||
Title: t.title,
|
||||
Target: t.target,
|
||||
Detail: t.detail,
|
||||
State: t.state,
|
||||
Err: t.err,
|
||||
Percent: t.percent,
|
||||
Steps: append([]Step(nil), t.steps...),
|
||||
Lines: append([]string(nil), t.lines...),
|
||||
Started: t.started,
|
||||
Ended: t.ended,
|
||||
Cancellable: t.cancel != nil && t.state == StateRunning,
|
||||
}
|
||||
}
|
||||
|
||||
// ID returns the job's identifier, stable for the lifetime of the process.
|
||||
func (t *Task) ID() int { return t.id }
|
||||
|
||||
// Manager owns every background job and notifies a single listener when any of
|
||||
// them changes.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
tasks []*Task
|
||||
nextID int
|
||||
notify chan struct{}
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
return &Manager{notify: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
// Changed is a coalescing wake-up channel. It receives at most one pending
|
||||
// notification, so a job that emits thousands of progress updates per second
|
||||
// still costs the UI a single redraw per frame.
|
||||
func (m *Manager) Changed() <-chan struct{} { return m.notify }
|
||||
|
||||
func (m *Manager) touch() {
|
||||
select {
|
||||
case m.notify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches a job and returns immediately.
|
||||
func (m *Manager) Start(kind Kind, title, target string, body Body) *Task {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m.mu.Lock()
|
||||
m.nextID++
|
||||
task := &Task{
|
||||
manager: m,
|
||||
id: m.nextID,
|
||||
kind: kind,
|
||||
title: title,
|
||||
target: target,
|
||||
state: StateRunning,
|
||||
started: time.Now(),
|
||||
cancel: cancel,
|
||||
}
|
||||
m.tasks = append(m.tasks, task)
|
||||
m.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
err := body(ctx, &Reporter{task: task})
|
||||
task.mu.Lock()
|
||||
task.ended = time.Now()
|
||||
task.cancel = nil
|
||||
switch {
|
||||
case ctx.Err() != nil:
|
||||
task.state = StateCanceled
|
||||
case err != nil:
|
||||
task.state, task.err = StateFailed, err
|
||||
default:
|
||||
task.state, task.percent = StateSucceeded, 1
|
||||
}
|
||||
task.mu.Unlock()
|
||||
cancel()
|
||||
m.touch()
|
||||
}()
|
||||
|
||||
m.touch()
|
||||
return task
|
||||
}
|
||||
|
||||
// Snapshots returns every job, oldest first.
|
||||
func (m *Manager) Snapshots() []Snapshot {
|
||||
m.mu.Lock()
|
||||
tasks := append([]*Task(nil), m.tasks...)
|
||||
m.mu.Unlock()
|
||||
snapshots := make([]Snapshot, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
snapshots = append(snapshots, task.snapshot())
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
// Snapshot returns a single job by identifier.
|
||||
func (m *Manager) Snapshot(id int) (Snapshot, bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, task := range m.tasks {
|
||||
if task.id == id {
|
||||
return task.snapshot(), true
|
||||
}
|
||||
}
|
||||
return Snapshot{}, false
|
||||
}
|
||||
|
||||
// Cancel asks a running job to stop.
|
||||
func (m *Manager) Cancel(id int) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, task := range m.tasks {
|
||||
if task.id != id {
|
||||
continue
|
||||
}
|
||||
task.mu.Lock()
|
||||
cancel := task.cancel
|
||||
task.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CancelAll stops every running job, used when the program exits.
|
||||
func (m *Manager) CancelAll() {
|
||||
for _, snapshot := range m.Snapshots() {
|
||||
if snapshot.State == StateRunning {
|
||||
m.Cancel(snapshot.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Running counts jobs that have not finished.
|
||||
func (m *Manager) Running() int {
|
||||
count := 0
|
||||
for _, snapshot := range m.Snapshots() {
|
||||
if snapshot.State == StateRunning {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// RunningFor reports whether a job is already working on the given target, so
|
||||
// the UI can refuse to start a duplicate build or pull.
|
||||
func (m *Manager) RunningFor(target string) (Snapshot, bool) {
|
||||
for _, snapshot := range m.Snapshots() {
|
||||
if snapshot.State == StateRunning && snapshot.Target == target {
|
||||
return snapshot, true
|
||||
}
|
||||
}
|
||||
return Snapshot{}, false
|
||||
}
|
||||
|
||||
// Prune drops finished jobs, keeping the history short.
|
||||
func (m *Manager) Prune() {
|
||||
m.mu.Lock()
|
||||
kept := m.tasks[:0]
|
||||
for _, task := range m.tasks {
|
||||
if task.snapshot().State == StateRunning {
|
||||
kept = append(kept, task)
|
||||
}
|
||||
}
|
||||
m.tasks = kept
|
||||
m.mu.Unlock()
|
||||
m.touch()
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/platform"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/registry"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/run"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/task"
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui"
|
||||
)
|
||||
|
||||
// target is everything the actions need to know about the selected row,
|
||||
// resolved once so the same code can act on a pull request, a local image, or a
|
||||
// bare container without caring which screen the user is on.
|
||||
type target struct {
|
||||
label string
|
||||
container string
|
||||
localImage string
|
||||
volume string
|
||||
pr int
|
||||
running bool
|
||||
port string
|
||||
provenance docker.Image
|
||||
buildable bool
|
||||
}
|
||||
|
||||
func (m Model) currentTarget() (target, bool) {
|
||||
if m.screen == screenInstances {
|
||||
row, found := m.selectedInstance()
|
||||
if !found {
|
||||
return target{}, false
|
||||
}
|
||||
return m.instanceTarget(row), true
|
||||
}
|
||||
row, found := m.selectedDev()
|
||||
if !found {
|
||||
return target{}, false
|
||||
}
|
||||
return m.devTarget(row), true
|
||||
}
|
||||
|
||||
func (m Model) devTarget(row devRow) target {
|
||||
result := target{
|
||||
label: row.label(),
|
||||
container: row.containerName(),
|
||||
volume: row.volumeName(),
|
||||
running: row.running,
|
||||
port: row.port,
|
||||
buildable: true,
|
||||
}
|
||||
if row.kind == rowLocal {
|
||||
result.localImage = row.local.Reference()
|
||||
result.provenance = row.local
|
||||
result.pr = row.local.PRNumber
|
||||
return result
|
||||
}
|
||||
result.pr = row.pr.Number
|
||||
result.localImage = row.localReference()
|
||||
// A pull request always has a rebuild recipe even without a local image:
|
||||
// clone the head into a temporary checkout and build it as homarr:pr-N.
|
||||
result.provenance = docker.Image{Tag: fmt.Sprintf("pr-%d", row.pr.Number), PRNumber: row.pr.Number}
|
||||
if row.local.Tag != "" {
|
||||
result.provenance = row.local
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m Model) instanceTarget(row instanceRow) target {
|
||||
result := target{
|
||||
label: row.container.Name,
|
||||
container: row.container.Name,
|
||||
volume: row.container.Name + "_data",
|
||||
running: row.container.Running(),
|
||||
port: row.container.HostPort(),
|
||||
}
|
||||
if strings.HasPrefix(row.container.Image, "homarr:") {
|
||||
result.localImage = row.container.Image
|
||||
tag := strings.TrimPrefix(row.container.Image, "homarr:")
|
||||
for _, image := range m.images {
|
||||
if image.Tag == tag {
|
||||
result.provenance, result.buildable = image, true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if number := row.prNumber(); number > 0 {
|
||||
result.pr = number
|
||||
if !result.buildable {
|
||||
result.provenance = docker.Image{Tag: fmt.Sprintf("pr-%d", number), PRNumber: number}
|
||||
result.buildable = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// primaryAction is Enter: stop what is running, start what is not. A matching
|
||||
// local build and a published registry image open a small source selector;
|
||||
// otherwise the only available source is started directly.
|
||||
func (m Model) primaryAction() (Model, tea.Cmd) {
|
||||
if m.screen == screenInstances {
|
||||
row, found := m.selectedInstance()
|
||||
if !found {
|
||||
return m, nil
|
||||
}
|
||||
if row.container.Running() {
|
||||
return m.stopContainer(row.container.Name)
|
||||
}
|
||||
return m.restartContainer(row.container.Name)
|
||||
}
|
||||
|
||||
row, found := m.selectedDev()
|
||||
if !found {
|
||||
return m, nil
|
||||
}
|
||||
if row.running {
|
||||
return m.stopContainer(row.containerName())
|
||||
}
|
||||
if row.kind == rowLocal {
|
||||
return m.startLocal(row)
|
||||
}
|
||||
if row.hasCurrentLocalImage() {
|
||||
if m.remoteImageAvailable(row) {
|
||||
return m.selectImage(row)
|
||||
}
|
||||
return m.startLocalPR(row)
|
||||
}
|
||||
switch row.image {
|
||||
case ui.ImageUnknown, ui.ImageChecking:
|
||||
m.status, m.statusLevel = "still checking whether CI published an image for "+row.label(), levelWarn
|
||||
return m, nil
|
||||
case ui.ImageMissing:
|
||||
m.status, m.statusLevel = fmt.Sprintf("CI has not published an image for PR #%d yet — press R to build it locally", row.pr.Number), levelWarn
|
||||
return m, nil
|
||||
}
|
||||
return m.deployRemotePR(row)
|
||||
}
|
||||
|
||||
func (m Model) remoteImageAvailable(row devRow) bool {
|
||||
return row.kind == rowRemote && m.tagsKnown && registry.HasPRImage(m.tags, row.pr.Number)
|
||||
}
|
||||
|
||||
func (m Model) selectImage(row devRow) (Model, tea.Cmd) {
|
||||
m.imageSelection = imageSelection{rowKey: row.key(), choice: imageChoiceLocal}
|
||||
m.mode = modeImageSelect
|
||||
m.status, m.statusLevel = "choose an image source for "+row.label(), levelInfo
|
||||
m.relayout()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) startLocal(row devRow) (Model, tea.Cmd) {
|
||||
plan, err := run.BuildPlan(run.Options{Context: context.Background(), Tag: row.local.Tag, Demo: m.demo})
|
||||
if err != nil {
|
||||
m.status, m.statusLevel = err.Error(), levelError
|
||||
return m, nil
|
||||
}
|
||||
return m.startPlan("start "+row.local.Reference(), row.containerName(), plan)
|
||||
}
|
||||
|
||||
func (m Model) startLocalPR(row devRow) (Model, tea.Cmd) {
|
||||
plan, err := localPRPlan(row, m.demo)
|
||||
if err != nil {
|
||||
m.status, m.statusLevel = err.Error(), levelError
|
||||
return m, nil
|
||||
}
|
||||
return m.startPlan(fmt.Sprintf("start PR #%d from local build", row.pr.Number), row.containerName(), plan)
|
||||
}
|
||||
|
||||
// localPRPlan runs a locally built image under the pull request's container and
|
||||
// volume identity, so switching between a local build and the registry build of
|
||||
// the same pull request keeps the same data and port.
|
||||
func localPRPlan(row devRow, demo bool) (*run.Plan, error) {
|
||||
plan, err := run.BuildPlan(run.Options{Context: context.Background(), Tag: row.local.Tag, Demo: demo})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan.Name = row.containerName()
|
||||
plan.Volume = row.volumeName()
|
||||
plan.Label = fmt.Sprintf("PR #%d · local %s", row.pr.Number, truncateRevision(row.local.Revision))
|
||||
plan.PRNumber = row.pr.Number
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func truncateRevision(revision string) string {
|
||||
if len(revision) > 12 {
|
||||
return revision[:12]
|
||||
}
|
||||
return revision
|
||||
}
|
||||
|
||||
func (m Model) startPlan(title, container string, plan *run.Plan) (Model, tea.Cmd) {
|
||||
m.tasks.Action(title, container, func(ctx context.Context) error {
|
||||
plan.Pull = false
|
||||
return run.StartDetached(plan)
|
||||
})
|
||||
m.status, m.statusLevel = fmt.Sprintf("%s → http://localhost:%d", title, plan.HostPort), levelInfo
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// deployRemotePR pulls the registry image and starts it as one job, so the
|
||||
// sidebar shows a single continuous progress stream rather than two.
|
||||
func (m Model) deployRemotePR(row devRow) (Model, tea.Cmd) {
|
||||
if existing, running := m.tasks.RunningFor(row.containerName()); running {
|
||||
m.status, m.statusLevel = existing.Title+" is already running", levelWarn
|
||||
return m, nil
|
||||
}
|
||||
plan, err := run.BuildPlan(run.Options{Context: context.Background(), PR: row.pr.Number, Demo: m.demo})
|
||||
if err != nil {
|
||||
m.status, m.statusLevel = err.Error(), levelError
|
||||
return m, nil
|
||||
}
|
||||
// Reuse the port a running instance already holds so a redeploy does not
|
||||
// move the URL out from under an open browser tab.
|
||||
if row.running && row.port != "" {
|
||||
if port, err := strconv.Atoi(row.port); err == nil {
|
||||
plan.HostPort = port
|
||||
}
|
||||
}
|
||||
title := fmt.Sprintf("pull and start PR #%d", row.pr.Number)
|
||||
created := m.tasks.Start(task.KindPull, title, row.containerName(), func(ctx context.Context, report *task.Reporter) error {
|
||||
if err := task.PullImage(ctx, report, plan.Image, plan.Platform); err != nil {
|
||||
return err
|
||||
}
|
||||
report.SetDetail("starting container on port " + strconv.Itoa(plan.HostPort))
|
||||
plan.Pull = false
|
||||
return run.StartDetached(plan)
|
||||
})
|
||||
return m.focusOn(created.ID(), title)
|
||||
}
|
||||
|
||||
// buildLocally rebuilds the selection in the background. A pull request without
|
||||
// a local image is cloned into a temporary checkout first.
|
||||
func (m Model) buildLocally(subject target) (Model, tea.Cmd) {
|
||||
if !subject.buildable {
|
||||
m.status, m.statusLevel = subject.label+" has no build source to rebuild from", levelWarn
|
||||
return m, nil
|
||||
}
|
||||
reference := "homarr:" + subject.provenance.Tag
|
||||
if existing, running := m.tasks.RunningFor(reference); running {
|
||||
m.status, m.statusLevel = existing.Title+" is already running", levelWarn
|
||||
return m, nil
|
||||
}
|
||||
image := subject.provenance
|
||||
title := "build " + reference
|
||||
created := m.tasks.BuildFrom(title, reference, func(ctx context.Context, report *task.Reporter) (docker.BuildOptions, error) {
|
||||
options, cleanup, err := run.RebuildOptions(ctx, image, report.Log)
|
||||
if err != nil {
|
||||
return docker.BuildOptions{}, err
|
||||
}
|
||||
// The checkout has to outlive option construction but not the build,
|
||||
// and BuildFrom runs both inside one job, so releasing it when the job
|
||||
// goroutine unwinds is both correct and the simplest thing available.
|
||||
context.AfterFunc(ctx, cleanup)
|
||||
return options, nil
|
||||
})
|
||||
return m.focusOn(created.ID(), title)
|
||||
}
|
||||
|
||||
func (m Model) pullRemote(row devRow) (Model, tea.Cmd) {
|
||||
if row.kind != rowRemote {
|
||||
m.status, m.statusLevel = "pulling only applies to pull-request images", levelWarn
|
||||
return m, nil
|
||||
}
|
||||
return m.deployRemotePR(row)
|
||||
}
|
||||
|
||||
// focusOn points the tray and sidebar at a freshly started job so its progress
|
||||
// is visible without any further keystrokes.
|
||||
func (m Model) focusOn(id int, title string) (Model, tea.Cmd) {
|
||||
m.focusTask = id
|
||||
m.sidebar.visible = true
|
||||
m.sidebar.source = sourceTask
|
||||
m.sidebar.follow = true
|
||||
m.status, m.statusLevel = title+" started in the background", levelInfo
|
||||
m.relayout()
|
||||
cmd := m.refreshSidebar()
|
||||
clocks := m.animate()
|
||||
return m, tea.Batch(cmd, clocks)
|
||||
}
|
||||
|
||||
func (m Model) stopContainer(name string) (Model, tea.Cmd) {
|
||||
m.tasks.Action("stop "+name, name, func(ctx context.Context) error { return docker.StopContext(ctx, name) })
|
||||
m.status, m.statusLevel = "stopping "+name+"…", levelInfo
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) restartContainer(name string) (Model, tea.Cmd) {
|
||||
m.tasks.Action("restart "+name, name, func(ctx context.Context) error { return docker.RestartContext(ctx, name) })
|
||||
m.status, m.statusLevel = "restarting "+name+"…", levelInfo
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) removeContainer(name string) (Model, tea.Cmd) {
|
||||
m.tasks.Action("remove "+name, name, func(ctx context.Context) error { return docker.RemoveContext(ctx, name) })
|
||||
m.status, m.statusLevel = "removing "+name+"…", levelInfo
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) deleteImage(reference string) (Model, tea.Cmd) {
|
||||
m.tasks.Action("delete "+reference, reference, func(ctx context.Context) error {
|
||||
return docker.RemoveImage(ctx, reference)
|
||||
})
|
||||
m.status, m.statusLevel = "deleting "+reference+"…", levelInfo
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// deleteData removes an instance's volume. Docker refuses to delete a volume
|
||||
// that is still attached, so the container is removed first — the data is going
|
||||
// away either way, and failing halfway would leave the user to work out why.
|
||||
func (m Model) deleteData(subject target) (Model, tea.Cmd) {
|
||||
container, volume := subject.container, subject.volume
|
||||
m.tasks.Action("delete data "+volume, volume, func(ctx context.Context) error {
|
||||
_ = docker.RemoveContext(ctx, container)
|
||||
return docker.RemoveVolume(ctx, volume)
|
||||
})
|
||||
m.status, m.statusLevel = "deleting "+volume+"…", levelInfo
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) pruneStopped() (Model, tea.Cmd) {
|
||||
names := make([]string, 0)
|
||||
for _, container := range m.containers {
|
||||
if !container.Running() {
|
||||
names = append(names, container.Name)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
m.status, m.statusLevel = "no stopped Homarr containers to prune", levelInfo
|
||||
return m, nil
|
||||
}
|
||||
m.tasks.Action(fmt.Sprintf("prune %d stopped containers", len(names)), "prune", func(ctx context.Context) error {
|
||||
for _, name := range names {
|
||||
if err := docker.RemoveContext(ctx, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
m.status, m.statusLevel = fmt.Sprintf("pruning %d stopped containers…", len(names)), levelInfo
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) openPullRequest() (Model, tea.Cmd) {
|
||||
row, found := m.selectedDev()
|
||||
if !found || row.kind != rowRemote {
|
||||
m.status, m.statusLevel = "select a pull request to open it on GitHub", levelWarn
|
||||
return m, nil
|
||||
}
|
||||
return m.openPullRequestNumber(row.pr.Number)
|
||||
}
|
||||
|
||||
func (m Model) openPullRequestNumber(number int) (Model, tea.Cmd) {
|
||||
if number <= 0 {
|
||||
return m, nil
|
||||
}
|
||||
url := fmt.Sprintf("https://github.com/%s/pull/%d", gitHubRepo, number)
|
||||
if err := platform.OpenURL(url); err != nil {
|
||||
m.status, m.statusLevel = "open failed: "+err.Error(), levelError
|
||||
return m, nil
|
||||
}
|
||||
m.status, m.statusLevel = "opened "+url, levelOK
|
||||
return m, nil
|
||||
}
|
||||
|
||||
const gitHubRepo = "homarr-labs/homarr"
|
||||
|
||||
func (m Model) openApp() (Model, tea.Cmd) {
|
||||
subject, found := m.currentTarget()
|
||||
if !found || !subject.running || subject.port == "" {
|
||||
m.status, m.statusLevel = "selection is not running", levelWarn
|
||||
return m, nil
|
||||
}
|
||||
url := "http://localhost:" + subject.port
|
||||
if err := platform.OpenURL(url); err != nil {
|
||||
m.status, m.statusLevel = "open failed: "+err.Error(), levelError
|
||||
return m, nil
|
||||
}
|
||||
m.status, m.statusLevel = "opened "+url, levelOK
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) copyURL() (Model, tea.Cmd) {
|
||||
subject, found := m.currentTarget()
|
||||
if !found || subject.port == "" {
|
||||
m.status, m.statusLevel = "selection has no published port", levelWarn
|
||||
return m, nil
|
||||
}
|
||||
url := "http://localhost:" + subject.port
|
||||
if err := platform.CopyText(url); err != nil {
|
||||
m.status, m.statusLevel = "copy failed: "+err.Error(), levelError
|
||||
return m, nil
|
||||
}
|
||||
m.status, m.statusLevel = "copied "+url, levelOK
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// remoteImageFor is used by the detail view to name the registry image a row
|
||||
// would pull.
|
||||
func remoteImageFor(row devRow) string {
|
||||
if row.kind != rowRemote {
|
||||
return ""
|
||||
}
|
||||
return registry.ImageReference(row.pr.Number)
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"charm.land/bubbles/v2/spinner"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
type appModel struct {
|
||||
dev prsModel
|
||||
dashboard dashModel
|
||||
showDashboard bool
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func newAppModel(showDashboard bool) appModel {
|
||||
return appModel{dev: newPRsModel(), dashboard: newDashModel(), showDashboard: showDashboard}
|
||||
}
|
||||
|
||||
func (m appModel) Init() tea.Cmd {
|
||||
if m.showDashboard {
|
||||
return m.dashboard.Init()
|
||||
}
|
||||
return m.dev.Init()
|
||||
}
|
||||
|
||||
func (m appModel) canSwitch() bool {
|
||||
if m.showDashboard {
|
||||
return !m.dashboard.filtering
|
||||
}
|
||||
return !m.dev.filtering && !m.dev.pulling
|
||||
}
|
||||
|
||||
func (m appModel) switchScreen() (tea.Model, tea.Cmd) {
|
||||
m.showDashboard = !m.showDashboard
|
||||
commands := make([]tea.Cmd, 0, 2)
|
||||
if m.width > 0 && m.height > 0 {
|
||||
resize := tea.WindowSizeMsg{Width: m.width, Height: m.height}
|
||||
if m.showDashboard {
|
||||
updated, command := m.dashboard.Update(resize)
|
||||
m.dashboard = updated.(dashModel)
|
||||
commands = append(commands, command)
|
||||
} else {
|
||||
updated, command := m.dev.Update(resize)
|
||||
m.dev = updated.(prsModel)
|
||||
commands = append(commands, command)
|
||||
}
|
||||
}
|
||||
if m.showDashboard {
|
||||
commands = append(commands, m.dashboard.Init())
|
||||
} else {
|
||||
commands = append(commands, m.dev.Init())
|
||||
}
|
||||
return m, tea.Batch(commands...)
|
||||
}
|
||||
|
||||
func (m appModel) updateDev(msg tea.Msg) (appModel, tea.Cmd) {
|
||||
updated, command := m.dev.Update(msg)
|
||||
m.dev = updated.(prsModel)
|
||||
return m, command
|
||||
}
|
||||
|
||||
func (m appModel) updateDashboard(msg tea.Msg) (appModel, tea.Cmd) {
|
||||
updated, command := m.dashboard.Update(msg)
|
||||
m.dashboard = updated.(dashModel)
|
||||
return m, command
|
||||
}
|
||||
|
||||
func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if window, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
m.width = window.Width
|
||||
m.height = window.Height
|
||||
m, devCommand := m.updateDev(msg)
|
||||
m, dashboardCommand := m.updateDashboard(msg)
|
||||
return m, tea.Batch(devCommand, dashboardCommand)
|
||||
}
|
||||
if key, ok := msg.(tea.KeyPressMsg); ok {
|
||||
if key.String() == "d" && m.canSwitch() {
|
||||
return m.switchScreen()
|
||||
}
|
||||
if m.showDashboard {
|
||||
return m.updateDashboard(msg)
|
||||
}
|
||||
return m.updateDev(msg)
|
||||
}
|
||||
|
||||
switch msg.(type) {
|
||||
case prsLoadedMsg, imageCheckedMsg, prActionMsg, rebuildDoneMsg, runningRefreshedMsg, pullReadyMsg, pullEvent, spinner.TickMsg:
|
||||
return m.updateDev(msg)
|
||||
case tickMsg, actionMsg, rowsMsg:
|
||||
if m.showDashboard {
|
||||
return m.updateDashboard(msg)
|
||||
}
|
||||
return m, nil
|
||||
case prLogTickMsg:
|
||||
if !m.showDashboard {
|
||||
return m.updateDev(msg)
|
||||
}
|
||||
return m, nil
|
||||
case logsMsg:
|
||||
m, devCommand := m.updateDev(msg)
|
||||
m, dashboardCommand := m.updateDashboard(msg)
|
||||
return m, tea.Batch(devCommand, dashboardCommand)
|
||||
}
|
||||
if m.showDashboard {
|
||||
return m.updateDashboard(msg)
|
||||
}
|
||||
return m.updateDev(msg)
|
||||
}
|
||||
|
||||
func (m appModel) View() tea.View {
|
||||
if m.showDashboard {
|
||||
return m.dashboard.View()
|
||||
}
|
||||
return m.dev.View()
|
||||
}
|
||||
|
||||
func runApp(showDashboard bool) error {
|
||||
program := tea.NewProgram(newAppModel(showDashboard))
|
||||
_, err := program.Run()
|
||||
return err
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
|
||||
)
|
||||
|
||||
func TestAppTogglePreservesDevelopmentState(t *testing.T) {
|
||||
m := newAppModel(false)
|
||||
m.dev.rows = []prRow{{kind: "remote", pr: gh.PR{Number: 6502}}}
|
||||
|
||||
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"}))
|
||||
dashboard := updated.(appModel)
|
||||
if !dashboard.showDashboard {
|
||||
t.Fatal("d did not open the instance dashboard")
|
||||
}
|
||||
updated, _ = dashboard.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"}))
|
||||
development := updated.(appModel)
|
||||
if development.showDashboard || len(development.dev.rows) != 1 || development.dev.rows[0].pr.Number != 6502 {
|
||||
t.Fatalf("development state was not preserved: %#v", development.dev.rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppToggleDoesNotInterceptFilterInput(t *testing.T) {
|
||||
m := newAppModel(false)
|
||||
m.dev.filtering = true
|
||||
m.dev.filter.Focus()
|
||||
|
||||
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"}))
|
||||
got := updated.(appModel)
|
||||
if got.showDashboard || got.dev.filter.Value() != "d" {
|
||||
t.Fatalf("filter input was intercepted: dashboard=%v filter=%q", got.showDashboard, got.dev.filter.Value())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppCanOpenInstancesDuringBuild(t *testing.T) {
|
||||
m := newAppModel(false)
|
||||
m.dev.rebuilding = true
|
||||
|
||||
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"}))
|
||||
if !updated.(appModel).showDashboard {
|
||||
t.Fatal("background build blocked dashboard navigation")
|
||||
}
|
||||
}
|
||||
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user