Compare commits

..
119 changed files with 4421 additions and 11855 deletions

No files matched your search

@@ -1,60 +0,0 @@
---
name: homarr-local-harness
description: Build and inspect a writable Docker demo harness from the current Homarr checkout when validating a local branch in a browser.
---
# Homarr Local Harness
Use this skill when a Homarr change needs a real production image and browser validation. Work from the repository root and keep the harness name Docker-safe, such as `feature-board`.
## Set up the harness
Run the TypeScript operations CLI; this workflow does not use the interactive developer TUI:
```sh
pnpm cli harness setup feature-board
```
`setup` builds the current checkout as `homarr:feature-board`, replaces the named harness container, and starts it with a persistent data volume. It enables demo mode, mock integrations, and writable demo mutations by default:
- `DEMO_MODE=true`
- `DEMO_READ_ONLY=false`
- `UNSAFE_ENABLE_MOCK_INTEGRATION=true`
Pass repeatable environment overrides when the feature needs them:
```sh
pnpm cli harness setup feature-board \
--env WORKSHOP_WEB_URL=https://example.test \
--env FEATURE_FLAG=true
```
Use `pnpm cli harness build <name>` when only the image is needed, and `pnpm cli harness run <name>` to start an already-built image. `--port <number>` pins a host port; otherwise Docker assigns one.
Always use the URL printed by the command. If the output is no longer available, query it directly:
```sh
pnpm cli harness port feature-board
```
Do not assume port `7575`; the container listens on `7575`, but the host port is dynamic by default.
## Browser validation
Open the printed URL with the environment's `browser-harness` tool. In this workspace, the `agent-browser` CLI is the supported browser automation fallback. Load its current workflow before invoking it:
```sh
agent-browser skills get core
```
Navigate to the exact harness URL, wait for the app to become usable, exercise the changed flow, and capture screenshots or other browser evidence. Keep browser state in a named session when the workflow needs login persistence. Report the exact URL, viewport/session used, and screenshot paths; a Docker process or HTTP health response alone is not browser acceptance.
## Cleanup
Stop the named container when finished:
```sh
pnpm cli harness stop feature-board
```
Stopping keeps the data volume so the same harness can be restarted. A later `setup` rebuilds the image and replaces only the container. Preserve unrelated containers, images, volumes, and working-tree changes.
@@ -1,4 +0,0 @@
interface:
display_name: "Homarr Local Harness"
short_description: "Build and browse a writable local Homarr branch harness"
default_prompt: "Use $homarr-local-harness to set up a writable Homarr harness for the current branch and validate it in the browser."
+8 -18
View File
@@ -374,31 +374,21 @@ jobs:
AMD64_DIGEST: ${{ needs.preview-amd64.outputs.digest }}
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
run: |
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[@]}" \
docker buildx imagetools create -t "${PREVIEW_IMAGE}:${PREVIEW_TAG}" \
"${PREVIEW_IMAGE}@${AMD64_DIGEST}" \
"${PREVIEW_IMAGE}@${ARM64_DIGEST}"
- name: Verify preview manifest platforms
if: "!env.ACT"
run: |
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"
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
done
docker buildx imagetools inspect "$image"
cleanup:
name: Clean up preview image
+21 -87
View File
@@ -27,103 +27,37 @@ jobs:
- run: pnpm test:workshop
- run: pnpm test:workshop-image
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: 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/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]
needs: validate
runs-on: ubuntu-24.04
timeout-minutes: 5
timeout-minutes: 30
permissions:
contents: read
packages: write
env:
WORKSHOP_IMAGE: ghcr.io/homarr-labs/workshop
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 }}
- 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"
- 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
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 }}
-2
View File
@@ -25,8 +25,6 @@ packages/db/migrations/*/migrate.cjs
# production
build
tools/homarr-dev/homarr
tools/homarr-dev/homarr-dev
# misc
.DS_Store
@@ -1,175 +1,43 @@
# Developer Docker CLI
The host-side developer CLI builds and runs local Homarr images and pull-request previews with non-blocking background operations. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication (`gh auth login`) is only needed for pull-request features.
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features.
## Requirements
- Docker or OrbStack
- Go 1.25+ (when building the CLI from source)
- GitHub CLI authenticated with `gh auth login` (for pull-request and CI features)
- A [Nerd Font](https://www.nerdfonts.com/) enabled in your terminal (recommended for icons, status indicators, and TUI glyphs)
## Interactive Development Browser
## Browse development images
```bash
pnpm dev:cli --
# or with global binary:
homarr
```
The main browser combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` (or `1`/`2`) to toggle between the image browser and the instance dashboard.
The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen.
A locally built PR image is folded into its remote PR row only when its recorded revision matches the current GitHub head SHA (marked `◆ local`). When the matching GHCR image is also available, pressing `Enter` opens a local/remote selector with local preselected. Outdated local builds remain separate rows.
A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds.
### Multi-Source Sidebar
Press `Tab` to cycle between sidebar tabs:
- **`[logs]`**: Live container output streams (press `f` to follow/pause, `L` to pin to the selected container, `Page Up`/`Page Down` to scroll).
- **`[build]`**: Real-time BuildKit step streaming for active local builds.
- **`[ci]`**: GitHub Actions CI check statuses, run duration, and workflow links for pull requests.
### Background Task Overlay
Pulls and builds run asynchronously in the background. Press `t` to open the tasks overlay, showing:
- Layer-by-layer download progress and transfer speeds (via Docker Engine API).
- Active build stages and logs.
- Task cancellation and status.
### Browser Shortcuts
| Key | Action |
| ---------------------- | --------------------------------------------------------------------------------------------- |
| `/` | Filter rows by PR number, title, author, branch, CI, image, state, or port |
| `↑` / `↓` or `j` / `k` | Navigate rows |
| `Enter` or `Space` | Start the available image; choose local or remote when both exist; stop if running |
| `R` | Build locally in background (or rebuild local image) |
| `p` | Pull remote PR image in background and start container |
| `m` | Manage mode (`b` rebuild, `i` delete image, `d` delete data, `c` remove container, `p` prune) |
| `t` | Toggle background tasks overlay |
| `Tab` | Cycle sidebar tabs (`[logs]`, `[build]`, `[ci]`) |
| `l` / `L` | Toggle sidebar visibility / Pin logs to selected instance |
| `f` | Follow or pause logs |
| `M` | Toggle demo mode for next launch |
| `b` | Toggle bot pull requests |
| `o` / `a` | Open PR on GitHub / Open running instance in browser |
| `c` | Copy instance URL to clipboard |
| `r` | Refresh data |
| `1` / `2` / `d` | Switch screen (development browser / instance dashboard) |
| `?` / `q` | Help overlay / Quit (cancels active background tasks) |
## Instance Dashboard
## Build images
```bash
homarr dash
# or
pnpm dev:cli -- dash
pnpm dev:cli -- build <name>
pnpm dev:cli -- build --pr <number> [name]
```
The instance dashboard displays all running and stopped Homarr containers, published ports, health states, and resource volumes.
The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
| Key | Action |
| ------------------ | ---------------------------------------- |
| `Enter` or `Space` | Start or stop selected instance |
| `s` / `S` | Stop / Restart instance |
| `x` | Remove container (preserves data volume) |
| `m` | Manage mode for selected instance |
| `R` | Rebuild from recorded build provenance |
## CLI Commands
The commands below use the installed `homarr` binary. Without global installation, prefix each command with `pnpm dev:cli --` (for example, `pnpm dev:cli -- list`).
### Launch Instances
## Rebuild images
```bash
homarr run dev
homarr run --detach dev
homarr run --pr 6441
homarr run --pr 6441 --demo
homarr --pr 6441 --env WORKSHOP_WEB_URL=https://app-v2.preview.homarr.dev/
pnpm dev:cli -- rebuild <name>
```
- `--pr <number>`: Pull and run a PR image from GHCR (shorthand: `homarr --pr <number>`).
- `--demo` (`-m`): Start with mock integrations and sample data.
- `--detach`: Start container in background.
- `--env` (`-e`): Pass additional environment variables (repeatable: `--env KEY=VALUE`).
Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout.
### Build & Rebuild
## Run images
```bash
# Build current checkout as homarr:<name>
homarr build feature
# Build a pull request locally in a temporary checkout
homarr build --pr 6441
# Rebuild an existing image from recorded provenance
homarr rebuild feature
homarr --pr <number> --env HOMARR_WEBSITE_URL=https://app-v2.preview.homarr.dev/
homarr run <name>
homarr run --pr <number>
```
Images built with the CLI record their source checkout, revision SHA, and optional PR number. `homarr rebuild` reads this provenance to reconstruct the build without manual configuration.
### GitHub CI Checks
```bash
# View CI checks for a specific PR
homarr ci 6441
# View CI checks for the current branch
homarr ci
# Watch CI checks until completion
homarr ci 6441 --watch
```
### Interactive Shell
```bash
# Open interactive shell in running container
homarr shell homarr-dev
# Run specific command in container
homarr shell homarr-dev pnpm db:migration:sqlite:run
```
### Image & Volume Management
```bash
# List local images with build provenance (source, revision, PR)
homarr images
# Delete local image
homarr images rm feature
# List data volumes with owner PR/tag and in-use status
homarr data
# Delete data volume (safely stops and removes owning container first)
homarr data rm homarr_feature_data
# Remove stopped containers (prompts without --force)
homarr prune --force
```
### Instance Operations & Diagnostics
```bash
# List instances in a script-friendly table
homarr list
# Follow container logs
homarr logs homarr-dev
# Stop, restart, remove, or open container in browser
homarr stop homarr-dev
homarr restart homarr-dev
homarr remove homarr-dev
homarr open homarr-dev
# Verify Docker daemon, Engine API, GHCR access, and gh CLI authentication
homarr doctor
```
The direct `homarr --pr <number>` form is shorthand for `homarr run --pr <number>`. Add `--detach` to run in the
background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
@@ -25,27 +25,3 @@ You should also create a backup of the database, in case you break something.
4. Click "Execute" in the top right of the Window. ![](img/334116411-df1482f9-3aa9-4686-b77f-e0dca09e60b2.png)
5. Select /bin/bash and click Execute ![](img/334116518-e1ddb515-99c7-4159-81bf-76e889d1e808.png)
6. Run the Homarr command and press enter (eg. homarr reset-owner-password) ![](img/334116642-08c4f204-5cd0-4e4e-a785-0142e1b279ca.png)
## Build a local branch harness
The host-side operations CLI can build the current checkout and run it as a named, browser-ready Docker harness. This is separate from the recovery CLI described above and does not open the interactive developer TUI.
```sh
pnpm cli harness setup feature-board
```
The command builds `homarr:feature-board`, starts a container with a persistent data volume, enables demo mode, and keeps demo mutations writable (`DEMO_READ_ONLY=false`). Docker assigns a free host port by default; use the URL printed by the command or query it later:
```sh
pnpm cli harness port feature-board
```
Pass environment overrides by repeating `--env`:
```sh
pnpm cli harness setup feature-board \
--env WORKSHOP_WEB_URL=https://example.test \
--env FEATURE_FLAG=true
```
Other commands are `harness build <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`.
+9 -8
View File
@@ -204,13 +204,10 @@ 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 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.
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.
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
@@ -230,7 +227,11 @@ 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
+34
View File
@@ -0,0 +1,34 @@
---
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} />
+43
View File
@@ -0,0 +1,43 @@
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
View File
@@ -2106,6 +2106,7 @@
"enum": [
"clock",
"weather",
"today",
"airQuality",
"countdown",
"timer",
@@ -23,8 +23,6 @@ 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;
@@ -65,74 +63,71 @@ export const ClientBoard = () => {
.join(" ");
const content = (
<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")}>
<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}-${mainSection.id}`}
section={mainSection}
columnCount={mainColumnCount}
key={`${currentLayoutId}-${leftSection.id}`}
section={leftSection}
columnCount={leftColumnCount}
requestedRowCount={0}
railPlacement="left"
/>
</section>
</aside>
)}
{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>
<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}
>
<BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection}
columnCount={rightColumnCount}
requestedRowCount={0}
railPlacement="right"
/>
</aside>
)}
</div>
</BoardGridEditorBoundary>
</ScaledBoardCanvas>
</BoardGridPortalHost>
</BoardSectionCollapseProvider>
</Box>
</BoardAdvancedFocusProvider>
);
if (representativeWidth === null) return content;
@@ -2,19 +2,14 @@
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Group, Menu } from "@mantine/core";
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { IconPlus } 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 { 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 { useBoardAddActions } from "~/components/board/use-board-add-actions";
import { UniversalCreateModal } from "~/components/create/universal-create-modal";
import { HeaderButton } from "~/components/layout/header/button";
export default function BoardEditActions() {
@@ -22,13 +17,9 @@ export default function BoardEditActions() {
}
const AddMenu = () => {
const { data: session } = useSession();
const board = useRequiredBoard();
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addContainer } = useContainerActions();
const { createItem } = useItemActions();
const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
const { openModal } = useModalAction(UniversalCreateModal);
const t = useI18n();
const pathname = usePathname();
const router = useRouter();
@@ -38,69 +29,29 @@ const AddMenu = () => {
useEffect(() => {
if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
handledInitialAdd.current = true;
openItemSelectModal({ boardId: board.id });
addWidget();
const nextSearchParams = new URLSearchParams(searchParams.toString());
nextSearchParams.delete("add");
const query = nextSearchParams.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [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 });
};
}, [addWidget, board.id, pathname, router, searchParams]);
return (
<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>
<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>
);
};
@@ -1,123 +0,0 @@
/**
* 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,19 +11,6 @@
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;
}
@@ -52,9 +39,3 @@
opacity: 1;
pointer-events: auto;
}
@media (prefers-reduced-motion: reduce) {
.settingsButton {
transition: none;
}
}
@@ -14,7 +14,6 @@ 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 {
@@ -106,11 +105,9 @@ const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemM
size={24}
radius="sm"
pos="absolute"
top={4}
right={4}
top={12}
right={8}
style={{ zIndex: 30 }}
className={itemContentClasses.settingsButton}
data-menu-open={isMenuOpen || undefined}
data-board-widget-settings
aria-label={tItem("menu.label.settingsFor", { name: label })}
>
@@ -1,7 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { IntegrationKind } from "@homarr/definitions";
import {
getWidgetConnectionStatus,
resolveMatchingIntegrationsAsync,
@@ -10,246 +8,56 @@ import {
} from "./item-select-data";
describe("getWidgetConnectionStatus", () => {
const availableKinds = new Set<IntegrationKind>(["sonarr", "plex"]);
const availableKinds = new Set(["sonarr" as const]);
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 widgets with a configured compatible integration as ready", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["sonarr", "radarr"],
availableKinds,
connectionOptional: false,
}),
).toBe("ready");
});
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("reports required integrations that are not configured as needing setup", () => {
expect(
getWidgetConnectionStatus({
supportedIntegrations: ["radarr"],
availableKinds,
connectionOptional: false,
}),
).toBe("needsSetup");
});
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");
});
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("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", () => {
describe("widget selection lock", () => {
it("rejects repeated selection synchronously until the active selection finishes", () => {
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("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 }]);
const result = await resolveMatchingIntegrationsAsync({
hasIntegrationSupport: false,
supportedIntegrations: ["sonarr"],
currentData: undefined,
ensureDataAsync,
});
expect(result).toEqual([]);
expect(ensureDataAsync).not.toHaveBeenCalled();
});
it("uses currentData synchronously when available without calling ensureDataAsync", async () => {
const currentData = [
{ 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");
});
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 },
]);
await expect(
resolveMatchingIntegrationsAsync({
@@ -258,6 +66,37 @@ describe("resolveMatchingIntegrationsAsync", () => {
currentData: undefined,
ensureDataAsync,
}),
).rejects.toThrow("Network timeout");
).resolves.toEqual([{ id: "matching", kind: "sonarr" }]);
expect(ensureDataAsync).toHaveBeenCalledOnce();
});
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(ensureDataAsync).not.toHaveBeenCalled();
});
it("excludes matching integrations the user cannot use", async () => {
const currentData = [
{ id: "allowed", kind: "sonarr" as const, permissions: { hasUseAccess: true } },
{ id: "blocked", kind: "sonarr" as const, permissions: { hasUseAccess: false } },
];
await expect(
resolveMatchingIntegrationsAsync({
hasIntegrationSupport: true,
supportedIntegrations: ["sonarr"],
currentData,
ensureDataAsync: vi.fn(),
}),
).resolves.toEqual([currentData[0]]);
});
});
@@ -0,0 +1,20 @@
.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;
}
}
@@ -1,211 +0,0 @@
// @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 { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Avatar,
Badge,
@@ -6,18 +6,16 @@ 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, IconSearch } from "@tabler/icons-react";
import { IconApi, IconBuildingStore } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -25,8 +23,8 @@ import { useSession } from "@homarr/auth/client";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { createId } from "@homarr/common";
import {
getBoardLaneColumnCount,
getIconUrl,
getBoardLaneColumnCount,
getIntegrationName,
getRootSectionLane,
widgetIntegrationSupport,
@@ -38,14 +36,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 { SelectableCard } from "@homarr/ui";
import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { useSetupAnalytics } from "~/components/create/setup-analytics";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useItemActions } from "./item-actions";
import type { WidgetConnectionStatus } from "./item-select-data";
import {
@@ -66,16 +64,6 @@ 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,
@@ -120,14 +108,15 @@ 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();
@@ -139,7 +128,7 @@ const ItemSelectModalContent = ({
[integrationData],
);
const items = useMemo<WidgetItemData[]>(
const items = useMemo(
() =>
widgetKinds
.filter((kind) => kind !== "customApi")
@@ -199,7 +188,7 @@ const ItemSelectModalContent = ({
createdItem.layouts.find(({ layoutId }) => layoutId === currentLayoutId)?.sectionId ??
createdItem.layouts[0]?.sectionId;
const actualDestination = placementOptions.find(({ value }) => value === actualSectionId)?.label;
const wasRelocated = effectiveSectionId !== null && actualSectionId !== effectiveSectionId && actualDestination;
const wasRelocated = targetSectionId !== null && actualSectionId !== targetSectionId && actualDestination;
const notificationId = `board-item-created:${itemId}`;
showSuccessNotification({
id: notificationId,
@@ -258,7 +247,7 @@ const ItemSelectModalContent = ({
kind: "customApi",
integrationIds: [],
options: configuredOptions,
targetSectionId: effectiveSectionId ?? undefined,
targetSectionId: targetSectionId ?? undefined,
advancedOptions,
});
notifyCreated(updatedBoard, itemId, customWidgetDefinition.name);
@@ -321,7 +310,7 @@ const ItemSelectModalContent = ({
kind,
options,
integrationIds: newIntegrationIds,
targetSectionId: effectiveSectionId ?? undefined,
targetSectionId: targetSectionId ?? undefined,
advancedOptions,
});
if (!notifyCreated(updatedBoard, itemId, t(`widget.${kind}.name`))) return;
@@ -388,233 +377,125 @@ const ItemSelectModalContent = ({
};
return (
<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>
{/* 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>
}
<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 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>
{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>
{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={t("customWidget.page.list.title")}
labelPosition="center"
my="sm"
style={{ gridColumn: "1 / -1" }}
/>
{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 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>
);
};
const ItemSelectModalFrame = ({
actions,
innerProps,
@@ -660,3 +541,109 @@ 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,19 +1,16 @@
"use client";
import { Suspense, use, useCallback, useMemo, useState } from "react";
import { Suspense, use } 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, useModalAction } from "@homarr/modals";
import { createModal, modalSizeForm } 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";
@@ -83,46 +80,13 @@ 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,
}}
/>
);
@@ -158,5 +122,6 @@ export const LazyWidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>
return t("item.edit.title");
},
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: false,
});
@@ -17,7 +17,6 @@ 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";
@@ -49,16 +48,6 @@ 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);
@@ -189,8 +178,6 @@ 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}
@@ -200,7 +187,6 @@ 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,27 +32,6 @@
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;
@@ -198,115 +177,138 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
.board-grid-resize-handle {
position: absolute;
z-index: 25;
display: grid;
z-index: 20;
display: block;
padding: 0;
color: var(--mantine-color-dimmed);
appearance: none;
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;
background: transparent;
border: 0;
touch-action: none;
transition:
opacity 120ms ease,
scale 160ms cubic-bezier(0.22, 1, 0.36, 1),
border-color 120ms ease;
}
.board-grid-resize-handle::before {
position: absolute;
inset: -0.375rem;
content: "";
}
.board-grid-resize-handle > svg {
body[data-board-grid-interacting] .board-grid-resize-handle {
pointer-events: none;
}
.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);
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 {
opacity: 1;
scale: 1;
}
.board-grid-resize-handle:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: -8px;
}
.board-grid-resize-handle[data-grid-resize-handle="n"],
.board-grid-resize-handle[data-grid-resize-handle="s"] {
left: 50%;
width: 1.75rem;
height: 1rem;
border-radius: var(--mantine-radius-xl);
translate: -50% 0;
right: 44px;
left: 44px;
height: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
cursor: ns-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="n"] {
top: -0.25rem;
top: 0;
}
.board-grid-resize-handle[data-grid-resize-handle="s"] {
bottom: -0.25rem;
bottom: 0;
}
.board-grid-resize-handle[data-grid-resize-handle="e"],
.board-grid-resize-handle[data-grid-resize-handle="w"] {
top: 50%;
width: 1rem;
height: 1.75rem;
border-radius: var(--mantine-radius-xl);
translate: 0 -50%;
top: 44px;
bottom: 44px;
width: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
cursor: ew-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="e"] {
right: -0.25rem;
right: 0;
}
.board-grid-resize-handle[data-grid-resize-handle="w"] {
left: -0.25rem;
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;
}
.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"] {
width: 1.875rem;
height: 1.875rem;
border-radius: var(--board-item-radius, var(--mantine-radius-md));
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)));
}
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
top: -0.625rem;
right: -0.625rem;
top: 0;
right: 0;
cursor: nesw-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="se"] {
right: -0.625rem;
bottom: -0.625rem;
right: 0;
bottom: 0;
cursor: nwse-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
bottom: -0.625rem;
left: -0.625rem;
bottom: 0;
left: 0;
cursor: nesw-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
top: -0.625rem;
left: -0.625rem;
top: 0;
left: 0;
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 {
.board-grid-resize-handle::after {
transition: none;
}
}
@@ -32,7 +32,6 @@ 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";
@@ -62,7 +61,6 @@ 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";
@@ -848,9 +846,6 @@ 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);
@@ -926,12 +921,7 @@ const DndGridEntryComponent = ({
<div
ref={setShellRef}
className="board-grid-entry"
style={
{
...getLogicalItemStyle(placement),
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
} as CSSProperties
}
style={getLogicalItemStyle(placement) as CSSProperties}
data-grid-item-id={placement.id}
data-grid-item-type={placement.type}
data-grid-x={placement.x}
@@ -940,7 +930,6 @@ 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
@@ -1113,27 +1102,19 @@ const GridResizeHandles = ({
finishResize(event.pointerId, { x: event.clientX, y: event.clientY }, canceled);
};
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>
);
});
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)}
/>
));
};
const getProjectedDragShape = (
@@ -1258,7 +1239,6 @@ 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,8 +10,6 @@ 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: [
@@ -46,13 +44,6 @@ 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",
}));
@@ -1,270 +0,0 @@
"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;
};
@@ -1,255 +0,0 @@
"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>
);
};
@@ -1,251 +0,0 @@
// @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("common.analyticsError"),
message: t("universalCreate.analyticsError"),
});
},
});
@@ -0,0 +1,77 @@
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"]);
});
});
@@ -0,0 +1,120 @@
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);
};
@@ -0,0 +1,21 @@
.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;
}
@@ -0,0 +1,5 @@
declare const classes: {
readonly action: string;
readonly actionIcon: string;
};
export default classes;
@@ -0,0 +1,162 @@
"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",
});
@@ -0,0 +1,35 @@
"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;
};
@@ -0,0 +1,49 @@
"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,10 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { Alert, Button, Group, ScrollArea, Stack, Text, Title, UnstyledButton } from "@mantine/core";
import { useState } from "react";
import { Group, ScrollArea, 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";
@@ -28,28 +27,9 @@ interface IntegrationSelectModalProps {
export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>(({ actions, innerProps }) => {
const t = useI18n();
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 [step, setStep] = useState<"select" | "form">(innerProps.initialKind ? "form" : "select");
const [selectedKind, setSelectedKind] = useState<IntegrationKind | null>(innerProps.initialKind ?? 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);
@@ -60,7 +40,7 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
setStep("select");
setSelectedKind(null);
};
const handleFormBack = isDirectForm ? actions.closeModal : handleBack;
const handleFormBack = innerProps.initialKind ? actions.closeModal : handleBack;
const handleSuccess = (result?: CreatedIntegrationResult) => {
actions.closeModal();
@@ -72,8 +52,12 @@ export const IntegrationSelectModal = createModal<IntegrationSelectModalProps>((
};
if (step === "form" && selectedKind) {
const HeaderIcon = isDirectForm ? IconX : IconArrowLeft;
const headerLabel = isDirectForm ? t("common.action.close") : t("common.action.previous");
let HeaderIcon = IconArrowLeft;
let headerLabel = t("common.action.previous");
if (innerProps.initialKind) {
HeaderIcon = IconX;
headerLabel = t("common.action.close");
}
return (
<ScrollArea.Autosize mah="80vh">
@@ -95,25 +79,11 @@ 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,6 +3,8 @@ 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";
@@ -36,6 +38,7 @@ 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">
@@ -43,6 +46,8 @@ export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
</TourTarget>
</Group>
</Group>
<UniversalCreateQueryGate />
<UniversalCreateSpotlightResult />
<LazySpotlight />
</AppShellHeader>
);
+2 -6
View File
@@ -78,15 +78,11 @@ describe("lazy widget application graph", () => {
await page.goto(`${baseUrl}/auth/login`);
await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password);
const signedIn = page.waitForURL((url) => url.origin === baseUrl && url.pathname === "/", {
waitUntil: "commit",
timeout: 60_000,
});
const signedIn = page.waitForURL(baseUrl, { waitUntil: "commit", timeout: 60_000 });
await page.locator("css=button[type='submit']").click();
await signedIn;
const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first();
await expect(visibleBoard).toBeVisible({ timeout: 30_000 });
await expect(page.locator("[data-homarr-dev-benchmark-board]")).toBeVisible({ timeout: 30_000 });
const clockWidget = page.locator(
`[data-id="${itemId}"] .clock-widget-container, [data-grid-item-id="${itemId}"] .clock-widget-container`,
);
+2 -3
View File
@@ -30,9 +30,8 @@
"db:studio": "pnpm -F db studio",
"dev": "pnpm -F nextjs dev",
"dev:benchmark": "node --experimental-strip-types scripts/benchmarks/dev-board.mts",
"dev:cli": "go -C tools/homarr-dev run -buildvcs=false .",
"dev:cli:build": "go -C tools/homarr-dev build -buildvcs=false .",
"dev:cli:install": "go -C tools/homarr-dev install -buildvcs=false .",
"dev:cli": "go -C tools/homarr-dev run .",
"dev:cli:install": "go -C tools/homarr-dev install .",
"dev:docs": "pnpm -F @homarr/docs dev",
"docker:docs": "docker compose -f apps/workshop/docker-compose.yml up --build docs",
"docker:workshop": "docker compose -f apps/workshop/docker-compose.yml up --build workshop",
-357
View File
@@ -1,357 +0,0 @@
import { randomBytes } from "node:crypto";
import { constants } from "node:fs";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import { command, number, positional, string } from "@drizzle-team/brocli";
const execFileAsync = promisify(execFile);
const environmentSeparator = "\u0000";
const validEnvironmentName = /^[A-Za-z_][A-Za-z0-9_]*$/;
const validHarnessName = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/;
const demoEnvironment: ReadonlyArray<[string, string]> = [
["DEMO_MODE", "true"],
["DEMO_READ_ONLY", "false"],
["UNSAFE_ENABLE_MOCK_INTEGRATION", "true"],
];
type HarnessRuntimeOptions = {
name: string;
env?: string;
port?: number;
};
const runtimeOptions = () => ({
name: positional("name").required().desc("Name used for the image, container, and data volume"),
env: string("env").alias("e").desc("Environment override KEY=VALUE; repeat the flag for more values"),
port: number("port").int().min(0).max(65535).desc("Host port, or 0 for an automatically assigned port"),
});
function validateHarnessName(name: string): string {
if (!validHarnessName.test(name)) {
throw new Error("Harness name must be 1-63 characters and contain only letters, numbers, '.', '_' or '-'");
}
return name;
}
function imageName(name: string): string {
return `homarr:${validateHarnessName(name)}`;
}
function containerName(name: string): string {
return `homarr-harness-${validateHarnessName(name)}`;
}
function volumeName(name: string): string {
return `homarr-harness-${validateHarnessName(name)}-data`;
}
function hasErrorCode(error: unknown, code: string): boolean {
return error instanceof Error && "code" in error && error.code === code;
}
function commandError(commandName: string, error: unknown): Error {
if (hasErrorCode(error, "ENOENT")) {
return new Error(`${commandName} is required but was not found on PATH`);
}
if (error instanceof Error) {
return new Error(`${commandName} failed: ${error.message}`);
}
return new Error(`${commandName} failed`);
}
async function execCommand(commandName: string, args: string[], cwd?: string): Promise<string> {
try {
const result = await execFileAsync(commandName, args, {
cwd,
maxBuffer: 10 * 1024 * 1024,
});
return result.stdout.trim();
} catch (error) {
throw commandError(commandName, error);
}
}
async function runCommand(commandName: string, args: string[], cwd?: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(commandName, args, {
cwd,
stdio: "inherit",
});
child.once("error", (error) => reject(commandError(commandName, error)));
child.once("close", (code, signal) => {
if (code === 0) {
resolve();
return;
}
const reason = signal ? `signal ${signal}` : `exit code ${code ?? "unknown"}`;
reject(new Error(`${commandName} failed with ${reason}`));
});
});
}
async function repositoryRoot(): Promise<string> {
const root = await execCommand("git", ["rev-parse", "--show-toplevel"], process.cwd());
try {
await access(join(root, "Dockerfile"), constants.R_OK);
} catch {
throw new Error("Run the harness command from a Homarr checkout containing a Dockerfile");
}
return root;
}
function parseEnvironmentOverrides(raw?: string): Map<string, string> {
const values = raw?.split(environmentSeparator) ?? [];
const environment = new Map(demoEnvironment);
for (const value of values) {
const separator = value.indexOf("=");
if (separator <= 0) {
throw new Error(`Invalid environment override ${JSON.stringify(value)}; use KEY=VALUE`);
}
const name = value.slice(0, separator);
if (!validEnvironmentName.test(name)) {
throw new Error(`Invalid environment variable name ${JSON.stringify(name)}`);
}
environment.set(name, value.slice(separator + 1));
}
return environment;
}
function isEncryptionKey(value: string | undefined): value is string {
return value !== undefined && /^[a-f0-9]{64}$/i.test(value);
}
async function readEncryptionKey(path: string): Promise<string | undefined> {
try {
const value = (await readFile(path, "utf8")).trim();
if (isEncryptionKey(value)) {
return value;
}
throw new Error(`Invalid encryption key in ${path}`);
} catch (error) {
if (hasErrorCode(error, "ENOENT")) {
return undefined;
}
throw error;
}
}
async function harnessEncryptionKey(name: string): Promise<string> {
if (isEncryptionKey(process.env.SECRET_ENCRYPTION_KEY)) {
return process.env.SECRET_ENCRYPTION_KEY;
}
const configRoot = process.env.HOMARR_HARNESS_CONFIG_DIR ?? join(homedir(), ".config", "homarr");
const keyPath = join(configRoot, "local-harness", `${validateHarnessName(name)}.key`);
const existingKey = await readEncryptionKey(keyPath);
if (existingKey) {
return existingKey;
}
const newKey = randomBytes(32).toString("hex");
await mkdir(join(configRoot, "local-harness"), { recursive: true, mode: 0o700 });
try {
await writeFile(keyPath, `${newKey}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
return newKey;
} catch (error) {
if (!hasErrorCode(error, "EEXIST")) {
throw error;
}
const racedKey = await readEncryptionKey(keyPath);
if (racedKey) {
return racedKey;
}
throw new Error(`Could not read the harness encryption key at ${keyPath}`, { cause: error });
}
}
async function harnessEnvironment(name: string, raw?: string): Promise<string[]> {
const environment = parseEnvironmentOverrides(raw);
if (!environment.has("SECRET_ENCRYPTION_KEY")) {
environment.set("SECRET_ENCRYPTION_KEY", await harnessEncryptionKey(name));
}
return Array.from(environment, ([key, value]) => `${key}=${value}`);
}
async function buildHarnessImage(name: string): Promise<void> {
const root = await repositoryRoot();
const image = imageName(name);
console.log(`Building ${image} from ${root}`);
await runCommand("docker", ["build", "--tag", image, "."], root);
console.log(`Built ${image}`);
}
async function removeExistingContainer(name: string): Promise<void> {
try {
await execCommand("docker", ["rm", "--force", containerName(name)]);
} catch {
// A missing container is the normal first-run path. Docker reports it as
// an error, so the cleanup is intentionally best-effort.
}
}
async function publishedPort(name: string): Promise<number> {
const output = await execCommand("docker", ["port", containerName(name), "7575/tcp"]);
for (const line of output.split("\n")) {
const match = line.trim().match(/:(\d+)$/);
if (match) {
return Number(match[1]);
}
}
throw new Error(`Container ${containerName(name)} has no published port for 7575/tcp`);
}
async function startHarness(options: HarnessRuntimeOptions): Promise<void> {
const name = validateHarnessName(options.name);
const image = imageName(name);
const container = containerName(name);
const requestedPort = options.port ?? 0;
const environment = await harnessEnvironment(name, options.env);
await removeExistingContainer(name);
console.log(`Starting ${container} from ${image}`);
await runCommand("docker", [
"run",
"--detach",
"--name",
container,
"--publish",
`${requestedPort}:7575`,
"--volume",
`${volumeName(name)}:/appdata`,
...environment.flatMap((value) => ["--env", value]),
image,
]);
const port = await publishedPort(name);
console.log(`Harness ${name} is running`);
console.log(` Container : ${container}`);
console.log(` Image : ${image}`);
console.log(` Port : ${port}`);
console.log(` URL : http://127.0.0.1:${port}`);
}
export const normalizeHarnessEnvironmentArgs = (argv: string[]): string[] => {
const normalized = argv.slice(0, 2);
const args = argv.slice(2);
const environmentValues: string[] = [];
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === undefined) {
continue;
}
if (arg === "--env" || arg === "-e") {
const value = args[index + 1];
if (value === undefined) {
normalized.push(arg);
continue;
}
environmentValues.push(value);
index += 1;
continue;
}
if (arg.startsWith("--env=")) {
environmentValues.push(arg.slice("--env=".length));
continue;
}
normalized.push(arg);
}
if (environmentValues.length > 0) {
normalized.push(`--env=${environmentValues.join(environmentSeparator)}`);
}
return normalized;
};
const harnessBuild = command({
name: "build",
desc: "Build the current checkout as a named local Homarr image",
options: {
name: positional("name").required().desc("Local image tag"),
},
handler: async ({ name }) => buildHarnessImage(name),
});
const harnessRun = command({
name: "run",
aliases: ["start", "up"],
desc: "Run a named local image in writable demo mode",
options: runtimeOptions(),
handler: async (options) => startHarness(options),
});
const harnessSetup = command({
name: "setup",
aliases: ["create"],
desc: "Build the current checkout and run its writable demo harness",
options: runtimeOptions(),
handler: async (options) => {
await buildHarnessImage(options.name);
await startHarness(options);
},
});
const harnessPort = command({
name: "port",
desc: "Show the published host port and URL for a harness",
options: {
name: positional("name").required().desc("Harness name"),
},
handler: async ({ name }) => {
const port = await publishedPort(name);
console.log(`Port: ${port}`);
console.log(`URL: http://127.0.0.1:${port}`);
},
});
const harnessStop = command({
name: "stop",
aliases: ["down"],
desc: "Stop a local harness container and keep its data volume",
options: {
name: positional("name").required().desc("Harness name"),
},
handler: async ({ name }) => {
const container = containerName(name);
await execCommand("docker", ["stop", container]);
console.log(`Stopped ${container}`);
},
});
export const harnessRoot = command({
name: "harness",
aliases: ["local-harness"],
desc: "Build and run a local branch in a browser-ready demo harness",
subcommands: [harnessBuild, harnessRun, harnessSetup, harnessPort, harnessStop],
});
+10 -38
View File
@@ -1,42 +1,14 @@
import { run } from "@drizzle-team/brocli";
import type { Command } from "@drizzle-team/brocli";
import { harnessRoot, normalizeHarnessEnvironmentArgs } from "./commands/harness";
import { fixUsernames } from "./commands/fix-usernames";
import { integrationsRoot } from "./commands/integrations";
import { recreateAdmin } from "./commands/recreate-admin";
import { resetPassword } from "./commands/reset-password";
import { usersRoot } from "./commands/users";
const harnessCommandNames = new Set(["harness", "local-harness"]);
const commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin];
function isHarnessInvocation(argv: string[]): boolean {
return argv.slice(2).some((argument) => harnessCommandNames.has(argument));
}
async function main(): Promise<void> {
const harnessInvocation = isHarnessInvocation(process.argv);
let commands: Command[];
if (harnessInvocation) {
commands = [harnessRoot];
} else {
const [{ fixUsernames }, { integrationsRoot }, { recreateAdmin }, { resetPassword }, { usersRoot }] =
await Promise.all([
import("./commands/fix-usernames"),
import("./commands/integrations"),
import("./commands/recreate-admin"),
import("./commands/reset-password"),
import("./commands/users"),
]);
commands = [usersRoot, integrationsRoot, resetPassword, fixUsernames, recreateAdmin, harnessRoot];
}
let argSource = process.argv;
if (harnessInvocation) {
argSource = normalizeHarnessEnvironmentArgs(process.argv);
}
await run(commands, {
argSource,
name: "homarr-cli",
version: "1.0.0",
});
}
void main();
void run(commands, {
name: "homarr-cli",
version: "1.0.0",
});
@@ -336,6 +336,7 @@ 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,6 +3,7 @@ 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",
+2
View File
@@ -1,6 +1,7 @@
export const widgetKinds = [
"clock",
"weather",
"today",
"airQuality",
"countdown",
"timer",
@@ -62,6 +63,7 @@ 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 },
@@ -1,438 +0,0 @@
// @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,25 +1,12 @@
import { useMemo, useState } from "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 { Button, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import { IconCheck, IconPlus, IconApps } 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 { selectGridCols, SelectableCard } from "@homarr/ui";
import { CatalogItem, SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
@@ -49,15 +36,7 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
const selectedApps = useMemo(() => apps.filter((app) => selectedAppIds.has(app.id)), [apps, selectedAppIds]);
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;
}
const handleSelect = (app: SelectableApp) => {
if (multiSelect) {
setSelectedAppIds((current) => {
const next = new Set(current);
@@ -65,6 +44,22 @@ 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();
}
};
@@ -81,145 +76,100 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
});
};
const handleMultiSubmit = () => {
innerProps.onSelectMany?.(selectedApps);
actions.closeModal();
};
return (
<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]);
}
}}
/>
{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
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>
)}
</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) => (
<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 })
}
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.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>
{filteredApps.length === 0 && !isPending && (
<Center p="xl">
<Text c="dimmed">{t("app.action.select.noResults")}</Text>
</Center>
)}
</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>
</>
);
}).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>
}
/>
);
};
+2 -8
View File
@@ -102,13 +102,6 @@ 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}
@@ -130,7 +123,7 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
{...otherModalProps}
title={translateIfNecessary(t, modal.props.defaultTitle)}
opened={opened}
onClose={handleClose}
onClose={handleCloseModal}
>
{modal.reference.content}
</Modal>
@@ -151,6 +144,7 @@ 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 ?? {} });
},
};
-11
View File
@@ -94,8 +94,6 @@ 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);
@@ -108,9 +106,6 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
innerProps={innerProps}
actions={{
closeModal: () => context.closeModal(modal.id),
setCloseInterceptor: (interceptor: (() => boolean) | null) => {
closeInterceptor = interceptor;
},
}}
/>
);
@@ -119,11 +114,5 @@ const getModal = <TModal extends ModalDefinition>(modal: ModalState<TModal>) =>
return {
modalProps: rest,
content: <FullModal />,
onCloseRequest: () => {
if (closeInterceptor) {
return closeInterceptor();
}
return true;
},
};
};
+4 -6
View File
@@ -5,12 +5,10 @@ import type { stringOrTranslation } from "@homarr/translation";
import type { ModalPresentation } from "./modal-presentation";
export interface ModalActions {
closeModal: () => void;
setCloseInterceptor?: (interceptor: (() => boolean) | null) => void;
}
export type ModalComponent<TInnerProps> = (props: { actions: ModalActions; innerProps: TInnerProps }) => ReactNode;
export type ModalComponent<TInnerProps> = (props: {
actions: { closeModal: () => void };
innerProps: TInnerProps;
}) => ReactNode;
export type CreateModalOptions = Pick<
ModalOptions<unknown>,
+101 -28
View File
@@ -236,6 +236,61 @@
}
}
},
"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",
@@ -821,12 +876,7 @@
"title": "Select an app to add to this board",
"toggle": "Click to select",
"selected": "Selected",
"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"
"multiple": "Add {count}"
},
"create": {
"title": "Create new app",
@@ -1671,11 +1721,7 @@
"grid": {
"widgetCount": "{count} widgets",
"noWidgets": "No widgets",
"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."
"detected": "Detected via Docker"
},
"category": {
"dnsHole": "DNS",
@@ -2089,7 +2135,6 @@
"success": "Success",
"beta": "Beta",
"error": "Error",
"analyticsError": "Setup analytics could not be recorded.",
"action": {
"add": "Add",
"apply": "Apply",
@@ -2765,8 +2810,6 @@
"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."
@@ -2783,20 +2826,6 @@
}
}
},
"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}",
@@ -3436,6 +3465,50 @@
"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",
-7
View File
@@ -13,13 +13,6 @@ 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";
@@ -1,7 +0,0 @@
export { SelectableCard } from "./selectable-card";
export type {
SelectableCardProps,
SelectableCardStylesNames,
SelectableCardCssVariables,
SelectableCardFactory,
} from "./selectable-card";
@@ -1,79 +0,0 @@
.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;
}
@@ -1,7 +0,0 @@
declare const classes: {
readonly root: string;
readonly header: string;
readonly body: string;
readonly footer: string;
};
export default classes;
@@ -1,370 +0,0 @@
// @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);
});
});
});
@@ -1,177 +0,0 @@
"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;
@@ -1,279 +0,0 @@
// @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,68 +1,27 @@
"use client";
import { useMemo, useState } from "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 { Badge, Card, Center, Group, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconPuzzle } from "@tabler/icons-react";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "../components/integration-avatar";
import { SelectableCard } from "../components/selectable-card";
import { selectGridCols } from "../select-grid-layout";
import { SelectGridLayout, selectGridCardHeight } from "../select-grid-layout";
import { buildSortedIntegrations, categoryTranslationKeys, filterIntegrations } from "./integration-grid-shared";
import type { IntegrationGridItem } from "./integration-grid-shared";
import classes from "./integration-select-grid.module.css";
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();
@@ -73,182 +32,75 @@ export const IntegrationSelectGrid = ({
const filtered = useMemo(() => filterIntegrations(integrations, search), [integrations, search]);
return (
<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);
<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>
}
}}
/>
{/* 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>
>
<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>
)}
</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>
);
};
+1 -3
View File
@@ -13,9 +13,7 @@
},
"exports": {
".": "./index.ts",
"./inputs": "./src/_inputs/index.ts",
"./inputs/form": "./src/_inputs/form.ts",
"./inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
"./_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,12 +17,6 @@ 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);
@@ -55,9 +49,13 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
}
inputWrapperOrder={["label", "input", "description", "error"]}
description={
<Text component="span" size="xs">
<Text size="xs">
{t.rich("widget.common.app.description", {
here: renderHereLink,
here: () => (
<Anchor size="xs" component={Link} target="_blank" href="/manage/apps/new">
{t("common.here")}
</Anchor>
),
})}
</Text>
}
+1 -2
View File
@@ -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 }, { enabled: Boolean(options.appId) });
const appQuery = clientApi.app.byId.useQuery({ id: options.appId });
const app = getUsableWidgetQueryData(appQuery);
const href = getSafeAppHref(app?.href);
useRegisterSpotlightContextResults(
@@ -50,7 +50,6 @@ 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 />;
+2
View File
@@ -15,6 +15,7 @@ import {
IconBuildingBank,
IconBusStop,
IconCalendar,
IconCalendarWeek,
IconChartAreaLine,
IconChartBar,
IconClock,
@@ -59,6 +60,7 @@ import type { TablerIcon } from "@homarr/ui";
export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
clock: IconClock,
weather: IconCloud,
today: IconCalendarWeek,
airQuality: IconWind,
countdown: IconHourglass,
timer: IconClockPlay,
+2
View File
@@ -63,6 +63,7 @@ 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";
@@ -97,6 +98,7 @@ export { AssistantWidgetRendererProvider } from "./assistant/context";
export const widgetImports = {
clock,
weather,
today,
airQuality,
countdown,
timer,
+2
View File
@@ -24,6 +24,7 @@ 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>,
@@ -88,6 +89,7 @@ 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,7 +58,6 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
appId?: string;
integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
onIntegrationSaved?: () => void;
onOpenNewIntegration?: (onCreated?: (id: string) => void) => void;
}
export const getSelectedWidgetIntegrations = (
@@ -218,17 +217,6 @@ 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 && (
@@ -237,7 +225,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
data={innerProps.integrationData}
canSelectMultiple={maxIntegrations > 1}
withAsterisk={integrationsRequired}
onOpenNewIntegration={canCreateIntegration ? handleOpenNewIntegration : undefined}
{...form.getInputProps("integrationIds")}
/>
)}
@@ -426,5 +413,6 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
return t("item.edit.title");
},
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: false,
});
+223
View File
@@ -0,0 +1,223 @@
"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}`;
};
@@ -0,0 +1,47 @@
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,
});
});
});
+172
View File
@@ -0,0 +1,172 @@
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,
};
};
+24
View File
@@ -0,0 +1,24 @@
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,7 +32,6 @@ interface WidgetIntegrationSelectProps {
canSelectMultiple?: boolean;
data: IntegrationSelectOption[];
withAsterisk?: boolean;
onOpenNewIntegration?: () => void;
}
export const WidgetIntegrationSelect = ({
data,
@@ -41,7 +40,6 @@ export const WidgetIntegrationSelect = ({
label,
canSelectMultiple = true,
withAsterisk = false,
onOpenNewIntegration,
...props
}: WidgetIntegrationSelectProps) => {
const t = useI18n();
@@ -111,19 +109,11 @@ export const WidgetIntegrationSelect = ({
<PillsInput
inputWrapperOrder={["label", "input", "description", "error"]}
description={
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>
)
<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()}
+25 -42
View File
@@ -56,69 +56,52 @@ homarr dash Start on the instance dashboard
homarr build <name> Build this checkout as homarr:<name>
homarr build --pr <number> Build a PR locally from a temporary checkout
homarr rebuild <name> Rebuild from recorded provenance
homarr images List local images with build provenance
homarr data List instance data volumes
homarr prune Remove stopped instances
homarr list Script-friendly instance list
homarr logs <container> Follow logs
homarr shell <container> Open an interactive shell in a running instance
homarr ci [pr] [--watch] View or watch GitHub CI checks for a pull request
homarr open <container> Open in browser
homarr stop <container> Stop
homarr restart <container> Restart
homarr remove <container> Force-remove
homarr doctor Check Docker, Engine API, and optional integrations
homarr doctor Check Docker and optional CLI integrations
```
## Development Browser
```text
/ Filter by PR number, title, author, branch, CI, image, state, or port
↑/↓ or j/k Select row
Enter or Space Start the available image; choose local or remote when both exist; stop when running
R Build locally in background (or rebuild local image)
p Pull remote PR image in background and start container
m Manage mode (chords: b rebuild, i delete image, d delete data, c remove container, p prune)
t Background tasks overlay (status, progress, cancellation)
Tab Cycle sidebar tab (logs / build / ci)
l Toggle sidebar visibility
L Pin logs to currently selected container
f Follow or pause logs
Page Up/Down Scroll logs
M Toggle demo mode for next launch
b Toggle bot PRs
↑/↓ or j/k Select
Enter or Space Start the matching local build, otherwise pull remote; stop when running
R Build a remote PR locally in the background or rebuild a local image
p Pull a remote PR image and redeploy, preserving port and volume
m Toggle demo mode for the next launch
o Open PR on GitHub
a Open running instance in browser
c Copy instance URL to clipboard
r Refresh data
1 / 2 / d Switch screen (development / instances)
? Help overlay
q Quit (cancels background tasks)
a Open running app
l Toggle inline logs for the selected running PR
f Follow or pause inline logs
Page Up/Down Scroll inline logs
b Show or hide bot PRs
r Refresh
d Switch to the instance dashboard
q Quit; during a pull, cancel it
```
Pull progress with live layer bytes, throughput, BuildKit step streaming, local build provenance, CI rollup badges, container health status, and live output streams are non-blocking. When a background pull completes, the sidebar automatically transitions to streaming the running instance's live logs.
Pull progress, layer completion, local build provenance, selected PR details, CI state, image availability, inline logs, and the final port remain inside the TUI.
Local PR images are folded into their remote PR row only when the recorded revision exactly matches the current GitHub head SHA. They are marked `◆ local` and preferred by Enter; outdated builds remain separate local rows. Navigation and the `d` dashboard toggle remain available while a local build runs.
## Instance Dashboard
```text
/ Filter by name, image, state, health, port, or URL
↑/↓ or j/k Select row
Enter or Space Start / stop selected instance
s Stop instance
S Restart instance
x Remove container (keeps data)
m Manage mode for selected instance
t Background tasks overlay
Tab Cycle sidebar tab (logs / build / ci)
l Toggle sidebar visibility
L Pin logs
f Follow / pause logs
↑/↓ or j/k Select
l Toggle inline logs
f Follow or pause logs
Page Up/Down Scroll logs
o Open running instance in browser
c Copy URL to clipboard
R Rebuild from recorded provenance
s Stop
R Restart
x Remove with confirmation
o Open in browser
c Copy URL
r Refresh
1 / 2 / d Switch screen
? Help overlay
d Switch to the development browser
q Quit
```
-74
View File
@@ -1,74 +0,0 @@
package cli
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/run"
)
func (a *App) buildCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "build [name]",
Short: "Build a local Homarr image from this checkout or a pull request",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
tag := ""
if len(args) > 0 {
tag = args[0]
}
if a.buildPR < 0 {
return fmt.Errorf("PR number must be positive")
}
if a.buildPR > 0 {
if tag == "" {
tag = fmt.Sprintf("pr-%d", a.buildPR)
}
return run.BuildPRImage(a.buildPR, tag)
}
if tag == "" {
return fmt.Errorf("image name is required when --pr is not set")
}
return run.BuildCurrentImage(tag, ".")
},
}
cmd.Flags().IntVarP(&a.buildPR, "pr", "p", 0, "Build a pull request in a temporary checkout")
return cmd
}
func (a *App) rebuildCommand() *cobra.Command {
return &cobra.Command{
Use: "rebuild <name>",
Short: "Rebuild a local image from its recorded checkout or pull request",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
image, err := findLocalImage(cmd, args[0])
if err != nil {
return err
}
return run.RebuildImage(image)
},
}
}
// findLocalImage resolves a tag or full reference to a local image, so both
// `homarr rebuild dev` and `homarr rebuild homarr:dev` work.
func findLocalImage(cmd *cobra.Command, name string) (docker.Image, error) {
images, err := docker.ListLocalImages(cmd.Context())
if err != nil {
return docker.Image{}, err
}
tag := name
if strings.Contains(name, ":") {
_, tag = docker.SplitImageReference(name)
}
for _, image := range images {
if image.Tag == tag {
return image, nil
}
}
return docker.Image{}, fmt.Errorf("local image homarr:%s was not found", tag)
}
-197
View File
@@ -1,197 +0,0 @@
// Package cli wires the command line surface. The interactive application
// lives in internal/tui; everything here is the scriptable path that does not
// need a terminal.
package cli
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/run"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/tui"
)
// App holds command state. The three function fields are seams: they let the
// command wiring be tested without opening a terminal, and they are the only
// mutable state in the package — the previous version reached for package-level
// variables, which meant tests had to mutate globals to observe dispatch.
type App struct {
demo bool
pr int
env []string
detach bool
buildPR int
RunBrowser func() error
RunInstances func() error
Launch func(cmd *cobra.Command, args []string) error
}
func NewApp() *App {
app := &App{RunBrowser: tui.RunDev, RunInstances: tui.RunDashboard}
app.Launch = app.launch
return app
}
// Execute runs the CLI.
func Execute() error {
args := os.Args[1:]
if len(args) > 0 && args[0] == "--" {
args = args[1:]
}
root := NewApp().Root()
root.SetArgs(args)
return root.Execute()
}
func (a *App) Root() *cobra.Command {
root := &cobra.Command{
Use: "homarr",
Short: "Launch and manage Homarr Docker instances",
SilenceUsage: true,
Long: `Launch and manage Homarr Docker containers.
Start a local image, a pull-request build from ghcr.io, browse open pull
requests, or manage running instances — all from one interactive terminal
application where builds and pulls run in the background.
Examples:
homarr # interactive browser
homarr --pr 6390 # launch a pull-request image directly
homarr --pr 6390 -e FOO=bar # with environment overrides
homarr run dev # launch local homarr:dev
homarr run --detach dev # start in the background
homarr dash # open on the instances screen
homarr build feature # build this checkout as homarr:feature
homarr build --pr 6390 # build a pull request locally
homarr images # local images with provenance
homarr data # instance data volumes
homarr prune # remove stopped instances
homarr doctor # check Docker and optional integrations`,
Args: cobra.NoArgs,
RunE: a.runRoot,
}
a.addRunFlags(root)
root.AddCommand(
a.runCommand(),
a.devCommand(),
a.dashCommand(),
a.buildCommand(),
a.rebuildCommand(),
a.listCommand(),
a.imagesCommand(),
a.dataCommand(),
a.pruneCommand(),
a.logsCommand(),
a.stopCommand(),
a.restartCommand(),
a.removeCommand(),
a.openCommand(),
a.shellCommand(),
a.ciCommand(),
a.doctorCommand(),
)
return root
}
func (a *App) addRunFlags(cmd *cobra.Command) {
cmd.Flags().BoolVarP(&a.demo, "demo", "m", false, "Demo mode (mock integrations)")
cmd.Flags().IntVarP(&a.pr, "pr", "p", 0, "Launch a pull-request image from ghcr.io")
cmd.Flags().StringArrayVarP(&a.env, "env", "e", nil, "Extra env vars (repeatable, KEY=VALUE)")
cmd.Flags().BoolVar(&a.detach, "detach", false, "Start in the background")
}
// runRoot opens the browser unless a launch flag was given, in which case the
// bare `homarr` invocation is shorthand for `homarr run`.
func (a *App) runRoot(cmd *cobra.Command, args []string) error {
for _, name := range []string{"pr", "env", "demo", "detach"} {
if cmd.Flags().Changed(name) {
return a.Launch(cmd, args)
}
}
return a.RunBrowser()
}
func (a *App) runCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "run [tag]",
Short: "Launch a local image or a pull-request image",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { return a.Launch(cmd, args) },
}
a.addRunFlags(cmd)
return cmd
}
func (a *App) devCommand() *cobra.Command {
return &cobra.Command{
Use: "dev",
Short: "Browse pull requests and local development images",
Args: cobra.NoArgs,
RunE: func(*cobra.Command, []string) error { return a.RunBrowser() },
}
}
func (a *App) dashCommand() *cobra.Command {
return &cobra.Command{
Use: "dash",
Aliases: []string{"instances"},
Short: "Open the interactive application on the instances screen",
Args: cobra.NoArgs,
RunE: func(*cobra.Command, []string) error { return a.RunInstances() },
}
}
func (a *App) launch(cmd *cobra.Command, args []string) error {
tag := ""
if len(args) > 0 {
tag = args[0]
}
if a.pr < 0 {
return fmt.Errorf("PR number must be positive")
}
if a.pr > 0 && tag != "" {
return fmt.Errorf("tag and --pr cannot be used together")
}
if a.pr == 0 && tag == "" {
if err := cmd.Usage(); err != nil {
return err
}
return fmt.Errorf("provide a tag or --pr")
}
plan, err := run.BuildPlan(run.Options{
Context: cmd.Context(),
PR: a.pr,
Tag: tag,
Demo: a.demo,
Env: a.env,
FetchTitle: a.pr > 0,
})
if err != nil {
return err
}
fmt.Printf("Starting Homarr (%s)\n", plan.Label)
fmt.Printf(" Image : %s\n", plan.Image)
fmt.Printf(" Container : %s\n", plan.Name)
fmt.Printf(" Volume : %s\n", plan.Volume)
fmt.Printf(" Port : %d:7575\n", plan.HostPort)
fmt.Printf(" URL : http://localhost:%d\n", plan.HostPort)
if plan.PRNumber > 0 {
fmt.Printf(" PR : https://github.com/homarr-labs/homarr/pull/%d\n", plan.PRNumber)
}
fmt.Println()
if a.detach {
if err := run.StartDetached(plan); err != nil {
return err
}
fmt.Printf("Started in background: http://localhost:%d\n", plan.HostPort)
return nil
}
return run.StartForeground(plan)
}
-107
View File
@@ -1,107 +0,0 @@
package cli
import (
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestRootCommandOpensBrowser(t *testing.T) {
want := errors.New("browser started")
app := NewApp()
app.RunBrowser = func() error { return want }
if err := app.runRoot(&cobra.Command{}, nil); !errors.Is(err, want) {
t.Fatalf("root command error = %v, want %v", err, want)
}
}
func TestRootCommandLaunchesWhenRunFlagIsProvided(t *testing.T) {
want := errors.New("PR launched")
app := NewApp()
app.Launch = func(*cobra.Command, []string) error { return want }
cmd := &cobra.Command{}
cmd.Flags().Bool("pr", false, "")
if err := cmd.Flags().Set("pr", "true"); err != nil {
t.Fatal(err)
}
if err := app.runRoot(cmd, nil); !errors.Is(err, want) {
t.Fatalf("root command error = %v, want %v", err, want)
}
}
func TestLaunchRejectsTagWithPR(t *testing.T) {
app := NewApp()
// Registering the flags binds them to app.pr and resets it, so the command
// has to exist before the test sets the value it wants to assert on.
cmd := app.runCommand()
app.pr = 42
err := app.launch(cmd, []string{"feature"})
if err == nil || !strings.Contains(err.Error(), "cannot be used together") {
t.Fatalf("error = %v", err)
}
}
func TestLaunchRejectsNegativePR(t *testing.T) {
app := NewApp()
cmd := app.runCommand()
app.pr = -1
err := app.launch(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "must be positive") {
t.Fatalf("error = %v", err)
}
}
func TestLaunchRejectsMissingImage(t *testing.T) {
app := NewApp()
err := app.launch(app.runCommand(), nil)
if err == nil || !strings.Contains(err.Error(), "provide a tag or --pr") {
t.Fatalf("error = %v", err)
}
}
// The root command doubles as `run`, so both must accept the same launch flags.
func TestRootAndRunShareLaunchFlags(t *testing.T) {
app := NewApp()
root := app.Root()
run, _, err := root.Find([]string{"run"})
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"pr", "demo", "env", "detach"} {
if root.Flags().Lookup(name) == nil {
t.Fatalf("root command is missing the %q flag", name)
}
if run.Flags().Lookup(name) == nil {
t.Fatalf("run command is missing the %q flag", name)
}
}
}
func TestCICommandRegisteredAndValidated(t *testing.T) {
app := NewApp()
root := app.Root()
ciCmd, _, err := root.Find([]string{"ci"})
if err != nil {
t.Fatalf("failed to find 'ci' command: %v", err)
}
if ciCmd.Name() != "ci" {
t.Fatalf("expected command name 'ci', got %q", ciCmd.Name())
}
if ciCmd.Flags().Lookup("watch") == nil {
t.Fatalf("expected --watch flag on 'ci' command")
}
// Test invalid PR argument
err = ciCmd.RunE(ciCmd, []string{"invalid-num"})
if err == nil || !strings.Contains(err.Error(), "invalid PR number") {
t.Fatalf("expected invalid PR number error, got: %v", err)
}
}
-90
View File
@@ -1,90 +0,0 @@
package cli
import (
"context"
"fmt"
"os/exec"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/registry"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui"
)
func (a *App) doctorCommand() *cobra.Command {
return &cobra.Command{
Use: "doctor",
Short: "Check Docker, the Engine API, GHCR and optional integrations",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithTimeout(cmd.Context(), 20*time.Second)
defer cancel()
failed := false
if output, err := exec.CommandContext(ctx, "docker", "info", "--format", "{{.ServerVersion}}").CombinedOutput(); err != nil {
failed = true
fail("Docker daemon", strings.TrimSpace(string(output)))
} else {
pass("Docker daemon", strings.TrimSpace(string(output)))
}
// The Engine API is what gives pulls byte-level progress. Losing it
// is not fatal — the CLI falls back to parsing `docker pull` — so
// this reports a downgrade rather than a failure.
host := docker.EngineHost()
if docker.EngineAvailable(ctx) {
pass("Engine API", host)
} else {
optional("Engine API unreachable at "+host, "pull progress falls back to coarse layer states")
}
if tags, err := registry.PublishedTags(ctx, true); err != nil {
optional("GHCR tag listing unavailable", err.Error())
} else {
pass("GHCR test images", fmt.Sprintf("%d published tags", len(tags)))
}
if _, err := exec.LookPath("gh"); err != nil {
optional("GitHub CLI not found", "required for pull-request features")
} else if output, err := exec.CommandContext(ctx, "gh", "auth", "status").CombinedOutput(); err != nil {
optional("GitHub CLI is not authenticated", strings.TrimSpace(string(output)))
} else {
pass("GitHub CLI authentication", "")
}
if _, err := exec.LookPath("homarr"); err != nil {
optional("homarr is not on PATH", "use `pnpm dev:cli -- ...` or run `pnpm dev:cli:install`")
} else {
pass("homarr is on PATH", "")
}
if failed {
return fmt.Errorf("one or more checks failed")
}
return nil
},
}
}
func pass(label, detail string) {
if detail == "" {
fmt.Printf("%s %s\n", ui.OK.Render(ui.IconPass), label)
return
}
fmt.Printf("%s %s: %s\n", ui.OK.Render(ui.IconPass), label, detail)
}
func fail(label, detail string) {
fmt.Printf("%s %s: %s\n", ui.Alert.Render(ui.IconFail), label, detail)
}
func optional(label, detail string) {
if detail == "" {
fmt.Printf("%s %s (optional)\n", ui.Dim.Render(ui.IconNone), label)
return
}
fmt.Printf("%s %s (optional; %s)\n", ui.Dim.Render(ui.IconNone), label, detail)
}
-329
View File
@@ -1,329 +0,0 @@
package cli
import (
"fmt"
"os"
"strconv"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/platform"
)
func (a *App) listCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Aliases: []string{"ls", "ps"},
Short: "List Homarr instances without opening the application",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
containers, err := docker.List()
if err != nil {
return err
}
if len(containers) == 0 {
fmt.Println("No Homarr instances found.")
return nil
}
writer := newTable("NAME", "STATE", "PORT", "IMAGE", "STATUS")
for _, container := range containers {
row(writer, container.Name, container.State, container.HostPort(), container.Image, container.Status)
}
return writer.Flush()
},
}
}
func (a *App) imagesCommand() *cobra.Command {
images := &cobra.Command{
Use: "images",
Aliases: []string{"image"},
Short: "List local Homarr images and their build provenance",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
found, err := docker.ListLocalImages(cmd.Context())
if err != nil {
return err
}
if len(found) == 0 {
fmt.Println("No local homarr images found. Build one with `homarr build <name>`.")
return nil
}
writer := newTable("IMAGE", "SIZE", "CREATED", "PR", "REVISION", "SOURCE")
for _, image := range found {
pr := ""
if image.PRNumber > 0 {
pr = fmt.Sprintf("#%d", image.PRNumber)
}
revision := image.Revision
if len(revision) > 12 {
revision = revision[:12]
}
source := image.Source
if source == "" {
source = "unknown"
}
row(writer, image.Reference(), image.Size, image.Created, pr, revision, source)
}
return writer.Flush()
},
}
images.AddCommand(&cobra.Command{
Use: "rm <name>...",
Aliases: []string{"remove", "delete"},
Short: "Delete local Homarr images",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
for _, name := range args {
reference := name
if !strings.Contains(reference, ":") {
reference = "homarr:" + reference
}
if err := docker.RemoveImage(cmd.Context(), reference); err != nil {
return err
}
fmt.Println("removed " + reference)
}
return nil
},
})
return images
}
func (a *App) dataCommand() *cobra.Command {
data := &cobra.Command{
Use: "data",
Aliases: []string{"volumes", "volume"},
Short: "List Homarr instance data volumes",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
volumes, err := docker.ListVolumes(cmd.Context(), true)
if err != nil {
return err
}
if len(volumes) == 0 {
fmt.Println("No Homarr data volumes found.")
return nil
}
writer := newTable("VOLUME", "SIZE", "IN USE", "BELONGS TO")
for _, volume := range volumes {
owner := volume.Tag()
if number := volume.PRNumber(); number > 0 {
owner = fmt.Sprintf("PR #%d", number)
}
inUse := "no"
if volume.InUse {
inUse = "yes"
}
row(writer, volume.Name, volume.Size, inUse, owner)
}
return writer.Flush()
},
}
data.AddCommand(&cobra.Command{
Use: "rm <volume>...",
Aliases: []string{"remove", "delete"},
Short: "Delete instance data, removing the owning container first",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
for _, name := range args {
// Docker refuses to delete an attached volume, and the data is
// going away regardless, so the container goes first.
_ = docker.Remove(strings.TrimSuffix(name, "_data"))
if err := docker.RemoveVolume(cmd.Context(), name); err != nil {
return err
}
fmt.Println("removed " + name)
}
return nil
},
})
return data
}
func (a *App) pruneCommand() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "prune",
Short: "Remove stopped Homarr containers, keeping their data",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
containers, err := docker.List()
if err != nil {
return err
}
stopped := make([]docker.Container, 0, len(containers))
for _, container := range containers {
if !container.Running() {
stopped = append(stopped, container)
}
}
if len(stopped) == 0 {
fmt.Println("No stopped Homarr containers.")
return nil
}
if !force {
fmt.Printf("Would remove %d stopped containers:\n", len(stopped))
for _, container := range stopped {
fmt.Println(" " + container.Name)
}
fmt.Println("\nRe-run with --force to remove them.")
return nil
}
for _, container := range stopped {
if err := docker.Remove(container.Name); err != nil {
return err
}
fmt.Println("removed " + container.Name)
}
return nil
},
}
cmd.Flags().BoolVarP(&force, "force", "f", false, "Actually remove the containers")
return cmd
}
func (a *App) logsCommand() *cobra.Command {
return &cobra.Command{
Use: "logs <container>",
Short: "Follow logs of a running instance",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { return docker.FollowLogs(args[0]) },
}
}
func (a *App) stopCommand() *cobra.Command {
return &cobra.Command{
Use: "stop <container>",
Short: "Stop a running instance",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { return docker.Stop(args[0]) },
}
}
func (a *App) restartCommand() *cobra.Command {
return &cobra.Command{
Use: "restart <container>",
Short: "Restart an instance",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { return docker.Restart(args[0]) },
}
}
func (a *App) removeCommand() *cobra.Command {
return &cobra.Command{
Use: "remove <container>",
Short: "Force-remove an instance, keeping its data",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { return docker.Remove(args[0]) },
}
}
func (a *App) openCommand() *cobra.Command {
return &cobra.Command{
Use: "open <container>",
Short: "Open a running instance in the browser",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
containers, err := docker.List()
if err != nil {
return err
}
for _, container := range containers {
if container.Name != args[0] {
continue
}
if !container.Running() {
return fmt.Errorf("container %q is not running", args[0])
}
port := container.HostPort()
if port == "" {
return fmt.Errorf("container %q does not publish the Homarr port", args[0])
}
return platform.OpenURL("http://localhost:" + port)
}
return fmt.Errorf("container %q was not found", args[0])
},
}
}
func (a *App) shellCommand() *cobra.Command {
return &cobra.Command{
Use: "shell <container> [cmd...]",
Aliases: []string{"sh", "exec"},
Short: "Open an interactive shell or run a command in a running Homarr container",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
shellArgs := []string{"sh"}
if len(args) > 1 {
shellArgs = args[1:]
}
return docker.ExecInteractive(cmd.Context(), args[0], shellArgs...)
},
}
}
func (a *App) ciCommand() *cobra.Command {
var watch bool
cmd := &cobra.Command{
Use: "ci [pr]",
Aliases: []string{"checks", "check"},
Short: "View or watch GitHub CI checks for a pull request",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var prNumber int
if len(args) > 0 {
num, err := strconv.Atoi(strings.TrimPrefix(args[0], "#"))
if err != nil || num <= 0 {
return fmt.Errorf("invalid PR number %q", args[0])
}
prNumber = num
} else {
currentPR, err := gh.CurrentBranchPR(cmd.Context())
if err != nil || currentPR == nil {
return fmt.Errorf("could not detect PR for current branch: specify a PR number with `homarr ci <number>`")
}
prNumber = currentPR.Number
}
if watch {
return gh.WatchPRChecks(cmd.Context(), prNumber)
}
checks, err := gh.GetPRChecks(cmd.Context(), prNumber, true)
if err != nil {
return err
}
if len(checks) == 0 {
fmt.Printf("No CI checks found for PR #%d.\n", prNumber)
return nil
}
writer := newTable("CHECK", "STATUS", "DURATION", "WORKFLOW", "LINK")
for _, check := range checks {
dur := check.Duration()
if dur == "" {
dur = "—"
}
row(writer, check.Name, check.State, dur, check.Workflow, check.Link)
}
return writer.Flush()
},
}
cmd.Flags().BoolVarP(&watch, "watch", "w", false, "Watch CI checks until completion")
return cmd
}
func newTable(headers ...string) *tabwriter.Writer {
writer := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
row(writer, headers...)
return writer
}
func row(writer *tabwriter.Writer, cells ...string) {
fmt.Fprintln(writer, strings.Join(cells, "\t"))
}
-187
View File
@@ -1,187 +0,0 @@
package docker
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"strings"
"sync"
"time"
)
// engineAPIVersion is the oldest Engine API version that supports every
// endpoint this CLI relies on. The daemon negotiates downwards on its own.
const engineAPIVersion = "v1.41"
// PullMessage is one record of the JSON progress stream returned by the Docker
// Engine when an image is pulled. Unlike the plain-text output of `docker
// pull`, this stream carries per-layer byte counters even when stdout is not a
// terminal, which is what makes a real progress animation possible.
type PullMessage struct {
ID string `json:"id"`
Status string `json:"status"`
Detail struct {
Current int64 `json:"current"`
Total int64 `json:"total"`
} `json:"progressDetail"`
Error string `json:"error"`
}
type engineEndpoint struct {
client *http.Client
base string
err error
}
var engineOnce struct {
sync.Once
endpoint engineEndpoint
}
// EngineHost reports the Docker endpoint this machine talks to, honouring
// DOCKER_HOST and the active `docker context` (OrbStack, Colima, and Docker
// Desktop all install their own socket).
func EngineHost() string {
if host := strings.TrimSpace(os.Getenv("DOCKER_HOST")); host != "" {
return host
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}").Output()
if err == nil {
if host := strings.TrimSpace(string(out)); host != "" {
return host
}
}
return "unix:///var/run/docker.sock"
}
func engine() (*http.Client, string, error) {
engineOnce.Do(func() { engineOnce.endpoint = dialEngine(EngineHost()) })
e := engineOnce.endpoint
return e.client, e.base, e.err
}
func dialEngine(host string) engineEndpoint {
switch {
case strings.HasPrefix(host, "unix://"):
path := strings.TrimPrefix(host, "unix://")
transport := &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", path)
},
}
return engineEndpoint{client: &http.Client{Transport: transport}, base: "http://docker/" + engineAPIVersion}
case strings.HasPrefix(host, "tcp://"), strings.HasPrefix(host, "http://"):
authority := host
for _, scheme := range []string{"tcp://", "http://"} {
authority = strings.TrimPrefix(authority, scheme)
}
return engineEndpoint{client: &http.Client{}, base: "http://" + authority + "/" + engineAPIVersion}
default:
return engineEndpoint{err: fmt.Errorf("unsupported Docker endpoint %q", host)}
}
}
// SplitImageReference separates a reference into its repository and tag,
// tolerating registries that carry an explicit port.
func SplitImageReference(reference string) (repository, tag string) {
slash := strings.LastIndex(reference, "/")
colon := strings.LastIndex(reference, ":")
if colon > slash {
return reference[:colon], reference[colon+1:]
}
return reference, "latest"
}
// PullStream starts an image pull against the Engine API and returns its
// progress records. The channel closes when the pull finishes; a message
// carrying Error describes a failure. Cancelling ctx aborts the pull.
func PullStream(ctx context.Context, image, platform string) (<-chan PullMessage, error) {
client, base, err := engine()
if err != nil {
return nil, err
}
repository, tag := SplitImageReference(image)
query := url.Values{"fromImage": {repository}, "tag": {tag}}
if platform != "" {
query.Set("platform", platform)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/images/create?"+query.Encode(), nil)
if err != nil {
return nil, err
}
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("contact Docker engine: %w", err)
}
if response.StatusCode < 200 || response.StatusCode > 299 {
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
_ = response.Body.Close()
return nil, fmt.Errorf("pull %s: %s", image, engineErrorMessage(response.Status, body))
}
messages := make(chan PullMessage, 64)
go func() {
defer close(messages)
defer response.Body.Close()
decoder := json.NewDecoder(response.Body)
for {
var message PullMessage
if err := decoder.Decode(&message); err != nil {
if err != io.EOF && ctx.Err() == nil {
select {
case messages <- PullMessage{Error: err.Error()}:
case <-ctx.Done():
}
}
return
}
select {
case messages <- message:
case <-ctx.Done():
return
}
}
}()
return messages, nil
}
func engineErrorMessage(status string, body []byte) string {
var payload struct {
Message string `json:"message"`
}
if err := json.Unmarshal(body, &payload); err == nil && payload.Message != "" {
return payload.Message
}
if trimmed := strings.TrimSpace(string(body)); trimmed != "" {
return trimmed
}
return status
}
// EngineAvailable reports whether the Engine API answers, so callers can fall
// back to the `docker` binary instead of failing outright.
func EngineAvailable(ctx context.Context) bool {
client, base, err := engine()
if err != nil {
return false
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/_ping", nil)
if err != nil {
return false
}
response, err := client.Do(request)
if err != nil {
return false
}
defer response.Body.Close()
_, _ = io.Copy(io.Discard, response.Body)
return response.StatusCode >= 200 && response.StatusCode < 300
}
+17 -31
View File
@@ -378,29 +378,27 @@ func IsPortConflict(err error) bool {
strings.Contains(message, "failed programming external connectivity")
}
func Stop(name string) error { return StopContext(context.Background(), name) }
func Restart(name string) error { return RestartContext(context.Background(), name) }
func Remove(name string) error { return RemoveContext(context.Background(), name) }
// The context-aware variants let a background job be cancelled while the daemon
// is still thinking, which matters when Docker is wedged and the user wants the
// interface back.
func StopContext(ctx context.Context, name string) error {
return runContainerCommand(ctx, "stop", name)
func Stop(name string) error {
cmd := exec.Command("docker", "stop", name)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func RestartContext(ctx context.Context, name string) error {
return runContainerCommand(ctx, "restart", name)
func Restart(name string) error {
cmd := exec.Command("docker", "restart", name)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func RemoveContext(ctx context.Context, name string) error {
return runContainerCommand(ctx, "rm", "-f", name)
}
func runContainerCommand(ctx context.Context, args ...string) error {
out, err := exec.CommandContext(ctx, "docker", args...).CombinedOutput()
func Remove(name string) error {
cmd := exec.Command("docker", "rm", "-f", name)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
@@ -427,15 +425,3 @@ func FollowLogs(name string) error {
cmd.Stderr = os.Stderr
return cmd.Run()
}
func ExecInteractive(ctx context.Context, name string, command ...string) error {
if len(command) == 0 {
command = []string{"sh"}
}
args := append([]string{"exec", "-it", name}, command...)
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
+14 -35
View File
@@ -27,8 +27,6 @@ type Image struct {
Source string
Revision string
PRNumber int
Size string
Created string
}
func (image Image) Reference() string {
@@ -36,11 +34,9 @@ func (image Image) Reference() string {
}
type imageListRow struct {
ID string
Repository string
Tag string
Size string
CreatedSince string
ID string
Repository string
Tag string
}
func parseImageList(output []byte) ([]Image, error) {
@@ -54,7 +50,7 @@ func parseImageList(output []byte) ([]Image, error) {
return nil, fmt.Errorf("parse local Docker image: %w", err)
}
if row.Repository == "homarr" && row.Tag != "<none>" {
images = append(images, Image{ID: row.ID, Tag: row.Tag, Size: row.Size, Created: row.CreatedSince})
images = append(images, Image{ID: row.ID, Tag: row.Tag})
}
}
return images, nil
@@ -90,39 +86,22 @@ func inspectImageLabels(ctx context.Context, images []Image) ([]map[string]strin
args = append(args, image.Reference())
}
out, err := exec.CommandContext(ctx, "docker", args...).Output()
if err == nil {
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) == len(images) {
labelsByImage := make([]map[string]string, len(images))
valid := true
for index, line := range lines {
labelsByImage[index] = make(map[string]string)
if line == "null" || line == "" {
continue
}
if err := json.Unmarshal([]byte(line), &labelsByImage[index]); err != nil {
valid = false
break
}
}
if valid {
return labelsByImage, nil
}
}
if err != nil {
return nil, fmt.Errorf("inspect local Homarr images: %w", err)
}
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
if len(lines) != len(images) {
return nil, fmt.Errorf("inspect returned %d images, expected %d", len(lines), len(images))
}
labelsByImage := make([]map[string]string, len(images))
for index, image := range images {
for index, line := range lines {
labelsByImage[index] = make(map[string]string)
singleOut, singleErr := exec.CommandContext(ctx, "docker", "image", "inspect", "--format", "{{json .Config.Labels}}", image.Reference()).Output()
if singleErr != nil {
if line == "null" {
continue
}
trimmed := strings.TrimSpace(string(singleOut))
if trimmed == "" || trimmed == "null" {
continue
if err := json.Unmarshal([]byte(line), &labelsByImage[index]); err != nil {
return nil, fmt.Errorf("parse labels for %s: %w", images[index].Reference(), err)
}
_ = json.Unmarshal([]byte(trimmed), &labelsByImage[index])
}
return labelsByImage, nil
}
@@ -1,410 +0,0 @@
package docker
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/charmbracelet/x/ansi"
)
// Layer is the download and extraction state of a single image layer.
type Layer struct {
ID string
Status string
Current int64
Total int64
}
// Fraction reports how far this layer is towards being usable, treating
// download and extraction as two halves of the work. Docker restarts the byte
// counters between the two phases, so they are weighted rather than summed.
func (l Layer) Fraction() float64 {
switch normalizeLayerStatus(l.Status) {
case "already exists", "pull complete":
return 1
case "downloading":
return 0.5 * ratio(l.Current, l.Total)
case "verifying checksum", "download complete":
return 0.5
case "extracting":
return 0.5 + 0.5*ratio(l.Current, l.Total)
default:
return 0
}
}
func (l Layer) Done() bool {
status := normalizeLayerStatus(l.Status)
return status == "pull complete" || status == "already exists"
}
func ratio(current, total int64) float64 {
if total <= 0 {
return 0
}
if current >= total {
return 1
}
return float64(current) / float64(total)
}
func normalizeLayerStatus(status string) string {
return strings.ToLower(strings.TrimSpace(status))
}
// PullTracker folds a pull progress stream into stable, ordered per-layer
// state so the UI can redraw it every frame without flicker.
type PullTracker struct {
order []string
layers map[string]*Layer
status string
started time.Time
sampleAt time.Time
sampled int64
rate float64
}
func NewPullTracker() *PullTracker {
now := time.Now()
return &PullTracker{layers: make(map[string]*Layer), started: now, sampleAt: now}
}
// Apply folds one Engine API record into the tracker.
func (t *PullTracker) Apply(message PullMessage) {
if message.ID == "" {
if message.Status != "" {
t.status = message.Status
}
return
}
// Digest and manifest records reuse the ID field but are not layers.
if strings.HasPrefix(message.ID, "sha256:") || len(message.ID) > 24 {
t.status = message.Status
return
}
layer, exists := t.layers[message.ID]
if !exists {
layer = &Layer{ID: message.ID}
t.layers[message.ID] = layer
t.order = append(t.order, message.ID)
}
if message.Status != "" {
layer.Status = message.Status
}
if message.Detail.Total > 0 {
layer.Total = message.Detail.Total
}
if message.Detail.Current > 0 || message.Detail.Total > 0 {
layer.Current = message.Detail.Current
}
if layer.Done() {
layer.Current = layer.Total
}
t.sample()
}
var plainPullPattern = regexp.MustCompile(`^([0-9a-zA-Z_-]{4,64}):\s+(.+)$`)
// ApplyLine folds a line of plain `docker pull` output into the tracker. It is
// the fallback used when the Engine API is unreachable; the resulting progress
// is coarser because the CLI omits byte counters when stdout is not a TTY.
func (t *PullTracker) ApplyLine(line string) {
line = strings.TrimSpace(ansi.Strip(line))
if line == "" {
return
}
match := plainPullPattern.FindStringSubmatch(line)
if match == nil {
t.status = line
return
}
status, current, total := parsePlainProgress(match[2])
t.Apply(PullMessage{
ID: match[1],
Status: status,
Detail: struct {
Current int64 `json:"current"`
Total int64 `json:"total"`
}{Current: current, Total: total},
})
}
var plainSizePattern = regexp.MustCompile(`([0-9.]+\s*[A-Za-z]*B)\s*/\s*([0-9.]+\s*[A-Za-z]*B)`)
func parsePlainProgress(rest string) (status string, current, total int64) {
status = rest
if bracket := strings.Index(rest, "["); bracket >= 0 {
status = strings.TrimSpace(rest[:bracket])
}
if match := plainSizePattern.FindStringSubmatch(rest); match != nil {
current = ParseSize(match[1])
total = ParseSize(match[2])
}
return status, current, total
}
var sizeUnits = map[string]int64{
"b": 1,
"kb": 1000, "mb": 1000 * 1000, "gb": 1000 * 1000 * 1000, "tb": 1000 * 1000 * 1000 * 1000,
"kib": 1024, "mib": 1024 * 1024, "gib": 1024 * 1024 * 1024, "tib": 1024 * 1024 * 1024 * 1024,
}
// ParseSize converts a human readable Docker size such as "12.3MB" into bytes.
func ParseSize(value string) int64 {
value = strings.ToLower(strings.TrimSpace(value))
index := 0
for index < len(value) && (value[index] >= '0' && value[index] <= '9' || value[index] == '.') {
index++
}
number, err := strconv.ParseFloat(strings.TrimSpace(value[:index]), 64)
if err != nil {
return 0
}
unit := strings.TrimSpace(value[index:])
if unit == "" {
return int64(number)
}
multiplier, known := sizeUnits[unit]
if !known {
return int64(number)
}
return int64(number * float64(multiplier))
}
// FormatSize renders a byte count the way the Docker CLI does.
func FormatSize(bytes int64) string {
const unit = 1000
if bytes < unit {
return fmt.Sprintf("%dB", bytes)
}
value := float64(bytes)
for _, suffix := range []string{"kB", "MB", "GB", "TB"} {
value /= unit
if value < unit {
return fmt.Sprintf("%.1f%s", value, suffix)
}
}
return fmt.Sprintf("%.1fPB", value/unit)
}
func (t *PullTracker) sample() {
now := time.Now()
elapsed := now.Sub(t.sampleAt)
if elapsed < 500*time.Millisecond {
return
}
current, _ := t.Bytes()
if delta := current - t.sampled; delta > 0 {
t.rate = float64(delta) / elapsed.Seconds()
}
t.sampled = current
t.sampleAt = now
}
// Layers returns the layers in the order Docker announced them.
func (t *PullTracker) Layers() []Layer {
layers := make([]Layer, 0, len(t.order))
for _, id := range t.order {
layers = append(layers, *t.layers[id])
}
return layers
}
// Bytes reports downloaded and total bytes across every layer that announced a
// size.
func (t *PullTracker) Bytes() (current, total int64) {
for _, layer := range t.layers {
if layer.Total <= 0 {
continue
}
total += layer.Total
if layer.Done() {
current += layer.Total
continue
}
current += min(layer.Current, layer.Total)
}
return current, total
}
// Complete reports how many layers are fully pulled out of how many are known.
func (t *PullTracker) Complete() (complete, total int) {
for _, layer := range t.layers {
total++
if layer.Done() {
complete++
}
}
return complete, total
}
// Percent is the overall progress of the pull in the range [0,1].
func (t *PullTracker) Percent() float64 {
if len(t.order) == 0 {
return 0
}
sum := 0.0
for _, id := range t.order {
sum += t.layers[id].Fraction()
}
return sum / float64(len(t.order))
}
// Rate is the recent download throughput in bytes per second.
func (t *PullTracker) Rate() float64 { return t.rate }
// Status is the most recent message that was not tied to a single layer.
func (t *PullTracker) Status() string { return t.status }
// Elapsed reports how long the pull has been running.
func (t *PullTracker) Elapsed() time.Duration { return time.Since(t.started) }
// BuildStep is one BuildKit vertex, such as a Dockerfile instruction.
type BuildStep struct {
ID string
Label string
Status string
Output []string
Done bool
Cached bool
Failed bool
Canceled bool
Elapsed string
}
// BuildTracker folds `docker build --progress=plain` output into ordered steps.
type BuildTracker struct {
order []string
steps map[string]*BuildStep
total int
started time.Time
last string
}
func NewBuildTracker() *BuildTracker {
return &BuildTracker{steps: make(map[string]*BuildStep), started: time.Now()}
}
var (
buildLinePattern = regexp.MustCompile(`^#(\d+)\s*(.*)$`)
buildStagePattern = regexp.MustCompile(`^\[[^\]]*?(\d+)/(\d+)\]`)
buildTimePattern = regexp.MustCompile(`^\d+\.\d+\s`)
)
// ApplyLine folds one line of BuildKit plain output into the tracker.
func (t *BuildTracker) ApplyLine(line string) {
line = strings.TrimRight(line, "\r\n")
trimmed := strings.TrimSpace(ansi.Strip(line))
if trimmed == "" {
return
}
match := buildLinePattern.FindStringSubmatch(trimmed)
if match == nil {
t.last = trimmed
return
}
id, rest := match[1], strings.TrimSpace(match[2])
// BuildKit reserves #0 for its own preamble ("building with ... driver").
// It never completes, so counting it would leave every finished build one
// step short of done.
if id == "0" {
t.last = rest
return
}
step, exists := t.steps[id]
if !exists {
step = &BuildStep{ID: id}
t.steps[id] = step
t.order = append(t.order, id)
}
switch {
case strings.HasPrefix(rest, "DONE"):
step.Done = true
step.Status = "done"
step.Elapsed = strings.TrimSpace(strings.TrimPrefix(rest, "DONE"))
case rest == "CACHED":
step.Done, step.Cached, step.Status = true, true, "cached"
case strings.HasPrefix(rest, "ERROR"):
step.Failed, step.Done, step.Status = true, true, "failed"
step.Output = appendStepOutput(step.Output, rest)
t.last = rest
case strings.HasPrefix(rest, "CANCELED"):
step.Done, step.Status, step.Canceled = true, "canceled", true
case buildTimePattern.MatchString(rest):
// `#12 1.234 <output>` — timestamped output from inside the step.
_, output, _ := strings.Cut(rest, " ")
if output = strings.TrimSpace(output); output != "" {
step.Output = appendStepOutput(step.Output, output)
t.last = output
}
case step.Label == "":
step.Label = rest
if stage := buildStagePattern.FindStringSubmatch(rest); stage != nil {
if total, err := strconv.Atoi(stage[2]); err == nil && total > t.total {
t.total = total
}
}
default:
step.Output = appendStepOutput(step.Output, rest)
}
}
func appendStepOutput(output []string, line string) []string {
const retained = 200
output = append(output, line)
if len(output) > retained {
output = output[len(output)-retained:]
}
return output
}
// Steps returns every announced step in declaration order.
func (t *BuildTracker) Steps() []BuildStep {
steps := make([]BuildStep, 0, len(t.order))
for _, id := range t.order {
steps = append(steps, *t.steps[id])
}
return steps
}
// Active returns the step BuildKit is currently working on, if any.
func (t *BuildTracker) Active() (BuildStep, bool) {
for index := len(t.order) - 1; index >= 0; index-- {
if step := t.steps[t.order[index]]; !step.Done && step.Label != "" {
return *step, true
}
}
return BuildStep{}, false
}
// Complete reports finished steps out of the best known total. The total grows
// as BuildKit announces stages, so it is never allowed to shrink below what has
// already been seen. A step BuildKit reported as canceled or failed is finished
// but not complete — counting it would report a killed build as fully built.
func (t *BuildTracker) Complete() (complete, total int) {
for _, step := range t.steps {
if step.Done && !step.Canceled && !step.Failed {
complete++
}
}
return complete, max(t.total, len(t.order))
}
// Percent is the overall build progress in the range [0,1].
func (t *BuildTracker) Percent() float64 {
complete, total := t.Complete()
if total == 0 {
return 0
}
return ratio(int64(complete), int64(total))
}
// LastMessage is the most recent human readable line, used for error context.
func (t *BuildTracker) LastMessage() string { return t.last }
// Elapsed reports how long the build has been running.
func (t *BuildTracker) Elapsed() time.Duration { return time.Since(t.started) }
@@ -1,163 +0,0 @@
package docker
import (
"testing"
)
func TestLayerFraction(t *testing.T) {
cases := []struct {
status string
current int64
total int64
want float64
}{
{"pull complete", 0, 0, 1.0},
{"already exists", 0, 0, 1.0},
{"download complete", 100, 100, 0.5},
{"verifying checksum", 100, 100, 0.5},
{"downloading", 50, 100, 0.25},
{"extracting", 50, 100, 0.75},
{"waiting", 0, 100, 0.0},
}
for _, c := range cases {
layer := Layer{Status: c.status, Current: c.current, Total: c.total}
if got := layer.Fraction(); got != c.want {
t.Errorf("Layer{%q, %d, %d}.Fraction() = %v, want %v", c.status, c.current, c.total, got, c.want)
}
}
}
func TestParseSizeAndFormatSize(t *testing.T) {
if got := ParseSize("12.3MB"); got != 12300000 {
t.Errorf("ParseSize(12.3MB) = %d, want 12300000", got)
}
if got := ParseSize("10MiB"); got != 10*1024*1024 {
t.Errorf("ParseSize(10MiB) = %d, want %d", got, 10*1024*1024)
}
if got := ParseSize("500B"); got != 500 {
t.Errorf("ParseSize(500B) = %d, want 500", got)
}
if got := ParseSize("invalid"); got != 0 {
t.Errorf("ParseSize(invalid) = %d, want 0", got)
}
if got := FormatSize(500); got != "500B" {
t.Errorf("FormatSize(500) = %q, want 500B", got)
}
if got := FormatSize(1500000); got != "1.5MB" {
t.Errorf("FormatSize(1500000) = %q, want 1.5MB", got)
}
if got := FormatSize(2500000000); got != "2.5GB" {
t.Errorf("FormatSize(2500000000) = %q, want 2.5GB", got)
}
}
func TestPullTrackerApplyAndProgress(t *testing.T) {
tracker := NewPullTracker()
tracker.Apply(PullMessage{
ID: "layer1",
Status: "Downloading",
Detail: struct {
Current int64 `json:"current"`
Total int64 `json:"total"`
}{Current: 50, Total: 100},
})
tracker.Apply(PullMessage{
ID: "layer2",
Status: "Extracting",
Detail: struct {
Current int64 `json:"current"`
Total int64 `json:"total"`
}{Current: 50, Total: 100},
})
layers := tracker.Layers()
if len(layers) != 2 {
t.Fatalf("expected 2 layers, got %d", len(layers))
}
if layers[0].ID != "layer1" || layers[1].ID != "layer2" {
t.Errorf("unexpected layers order: %v, %v", layers[0].ID, layers[1].ID)
}
currentBytes, totalBytes := tracker.Bytes()
if currentBytes != 100 || totalBytes != 200 {
t.Errorf("Bytes() = %d, %d; want 100, 200", currentBytes, totalBytes)
}
complete, total := tracker.Complete()
if complete != 0 || total != 2 {
t.Errorf("Complete() = %d, %d; want 0, 2", complete, total)
}
if percent := tracker.Percent(); percent != 0.5 {
t.Errorf("Percent() = %v, want 0.5", percent)
}
tracker.ApplyLine("c0ffee123456: Pull complete")
if len(tracker.Layers()) != 3 {
t.Fatalf("expected 3 layers after ApplyLine, got %d", len(tracker.Layers()))
}
complete, total = tracker.Complete()
if complete != 1 || total != 3 {
t.Errorf("Complete() = %d, %d; want 1, 3", complete, total)
}
}
func TestBuildTrackerStepsAndProgress(t *testing.T) {
tracker := NewBuildTracker()
tracker.ApplyLine("#0 building with \"default\" instance using docker driver")
if len(tracker.Steps()) != 0 {
t.Fatalf("expected step #0 to be skipped, got %d steps", len(tracker.Steps()))
}
tracker.ApplyLine("#1 [internal] load build definition from Dockerfile")
tracker.ApplyLine("#1 transferring dockerfile: 3.42kB done")
tracker.ApplyLine("#1 DONE 0.0s")
tracker.ApplyLine("#2 [1/3] FROM docker.io/library/alpine:3.20")
tracker.ApplyLine("#2 CACHED")
tracker.ApplyLine("#3 [2/3] RUN echo step2")
tracker.ApplyLine("#3 0.123 hello from build")
steps := tracker.Steps()
if len(steps) != 3 {
t.Fatalf("expected 3 steps, got %d", len(steps))
}
if !steps[0].Done {
t.Errorf("step #1 should be marked Done")
}
if !steps[1].Cached || !steps[1].Done {
t.Errorf("step #2 should be marked Cached and Done")
}
if steps[2].Done {
t.Errorf("step #3 should not be Done yet")
}
active, running := tracker.Active()
if !running || active.ID != "3" {
t.Errorf("Active() = %+v, %v; want ID 3, true", active, running)
}
complete, total := tracker.Complete()
if complete != 2 || total < 3 {
t.Errorf("Complete() = %d, %d; want 2, >=3", complete, total)
}
if last := tracker.LastMessage(); last != "hello from build" {
t.Errorf("LastMessage() = %q, want 'hello from build'", last)
}
tracker.ApplyLine("#3 CANCELED")
steps = tracker.Steps()
if !steps[2].Canceled {
t.Errorf("step #3 should be marked Canceled")
}
complete, _ = tracker.Complete()
if complete != 2 {
t.Errorf("Complete() after cancel = %d, want 2", complete)
}
}
-102
View File
@@ -1,102 +0,0 @@
package docker
import (
"bufio"
"context"
"fmt"
"io"
"os/exec"
"strings"
)
// StreamLine is one line of output from a streamed command. Err is set only on
// the final line, which also has Done set.
type StreamLine struct {
Text string
Err error
Done bool
}
// splitLines is a bufio.SplitFunc that treats both newline and carriage return
// as terminators so in-place progress updates arrive as discrete lines.
func splitLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
for index, char := range data {
if char == '\n' || char == '\r' {
return index + 1, data[:index], nil
}
}
if atEOF && len(data) > 0 {
return len(data), data, nil
}
return 0, nil, nil
}
// StreamCommand runs cmd with stdout and stderr merged and delivers its output
// line by line. The channel is closed after a final Done line carrying the exit
// status. Cancelling the command's context terminates the process.
func StreamCommand(cmd *exec.Cmd) (<-chan StreamLine, error) {
reader, writer := io.Pipe()
cmd.Stdout = writer
cmd.Stderr = writer
if err := cmd.Start(); err != nil {
_ = reader.Close()
_ = writer.Close()
return nil, err
}
// Wait runs on its own goroutine and closes the write end, which is what
// gives the scanner an EOF. Waiting first and closing afterwards deadlocks:
// the scanner would block forever on a pipe this process still holds open.
// Wait only returns once every byte has been copied into the pipe, so no
// output can be lost to the close.
waited := make(chan error, 1)
go func() {
err := cmd.Wait()
_ = writer.Close()
waited <- err
}()
lines := make(chan StreamLine, 256)
go func() {
defer close(lines)
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
scanner.Split(splitLines)
last := ""
for scanner.Scan() {
text := strings.TrimRight(scanner.Text(), "\r\n")
if strings.TrimSpace(text) == "" {
continue
}
last = text
lines <- StreamLine{Text: text}
}
_ = reader.Close()
err := <-waited
if err != nil && last != "" {
err = fmt.Errorf("%w: %s", err, strings.TrimSpace(last))
}
lines <- StreamLine{Done: true, Err: err}
}()
return lines, nil
}
// StreamContainerLogs follows a container's logs, replaying the last tail lines
// first. The stream ends when the container stops or ctx is cancelled.
func StreamContainerLogs(ctx context.Context, name string, tail int) (<-chan StreamLine, error) {
cmd := exec.CommandContext(ctx, "docker", "logs", "--follow", "--tail", fmt.Sprint(tail), name)
cmd.Cancel = func() error { return cmd.Process.Kill() }
return StreamCommand(cmd)
}
// BuildCommandStreaming returns a build command configured for BuildKit's plain
// progress output, which is the only format that can be parsed line by line.
func BuildCommandStreaming(ctx context.Context, options BuildOptions) (*exec.Cmd, error) {
cmd, err := BuildCommand(ctx, options)
if err != nil {
return nil, err
}
cmd.Args = append(cmd.Args[:len(cmd.Args)-1], "--progress=plain", cmd.Args[len(cmd.Args)-1])
cmd.Env = append(cmd.Environ(), "BUILDKIT_PROGRESS=plain", "DOCKER_BUILDKIT=1")
return cmd, nil
}
-151
View File
@@ -1,151 +0,0 @@
package docker
import (
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
)
// Volume is a Docker volume that belongs to a Homarr instance.
type Volume struct {
Name string
Driver string
Mountpoint string
Size string
InUse bool
}
// PRNumber returns the pull request a volume stores data for, or zero.
func (v Volume) PRNumber() int {
var number int
if _, err := fmt.Sscanf(v.Name, "homarr_pr_%d_data", &number); err == nil {
return number
}
return 0
}
// Tag returns the local image tag a volume stores data for, or an empty string.
func (v Volume) Tag() string {
if v.PRNumber() > 0 {
return ""
}
name := strings.TrimPrefix(v.Name, "homarr_")
if name == v.Name {
return ""
}
return strings.TrimSuffix(name, "_data")
}
// ListVolumes returns the Homarr data volumes, optionally annotated with disk
// usage when includeSizes is true.
func ListVolumes(ctx context.Context, includeSizes bool) ([]Volume, error) {
out, err := exec.CommandContext(ctx, "docker", "volume", "ls", "--filter", "name=homarr", "--format", "{{json .}}").Output()
if err != nil {
return nil, fmt.Errorf("list Homarr volumes: %w", err)
}
volumes, err := parseVolumeList(out)
if err != nil {
return nil, err
}
var sizes map[string]string
if includeSizes {
sizes = volumeSizes(ctx)
}
inUse := volumesInUse(ctx)
for index := range volumes {
if sizes != nil {
if size, known := sizes[volumes[index].Name]; known {
volumes[index].Size = size
}
}
volumes[index].InUse = inUse[volumes[index].Name]
}
return volumes, nil
}
func parseVolumeList(output []byte) ([]Volume, error) {
volumes := make([]Volume, 0)
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
if line == "" {
continue
}
var row struct {
Name string
Driver string
Mountpoint string
Size string
}
if err := json.Unmarshal([]byte(line), &row); err != nil {
return nil, fmt.Errorf("parse Docker volume: %w", err)
}
// buildx keeps its cache in volumes that match the same name filter.
if !strings.HasPrefix(row.Name, "homarr_") && row.Name != "homarr" {
continue
}
size := row.Size
if size == "N/A" {
size = ""
}
volumes = append(volumes, Volume{Name: row.Name, Driver: row.Driver, Mountpoint: row.Mountpoint, Size: size})
}
return volumes, nil
}
// volumeSizes reads reclaimable space per volume. `docker system df -v` is slow
// and optional, so failures degrade to unknown sizes rather than an error.
func volumeSizes(ctx context.Context) map[string]string {
sizes := make(map[string]string)
out, err := exec.CommandContext(ctx, "docker", "system", "df", "-v", "--format", "{{json .Volumes}}").Output()
if err != nil {
return sizes
}
var rows []struct {
Name string
Size string
}
if err := json.Unmarshal(out, &rows); err != nil {
return sizes
}
for _, row := range rows {
if row.Size != "" && row.Size != "N/A" {
sizes[row.Name] = row.Size
}
}
return sizes
}
func volumesInUse(ctx context.Context) map[string]bool {
inUse := make(map[string]bool)
out, err := exec.CommandContext(ctx, "docker", "ps", "-a", "--filter", "name=homarr", "--format", "{{.Mounts}}").Output()
if err != nil {
return inUse
}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
for _, mount := range strings.Split(line, ",") {
if mount = strings.TrimSpace(mount); mount != "" {
inUse[mount] = true
}
}
}
return inUse
}
// RemoveVolume deletes a volume and the instance data it holds.
func RemoveVolume(ctx context.Context, name string) error {
out, err := exec.CommandContext(ctx, "docker", "volume", "rm", name).CombinedOutput()
if err != nil {
return fmt.Errorf("remove volume %s: %w: %s", name, err, strings.TrimSpace(string(out)))
}
return nil
}
// RemoveImage force-deletes a local image, including any tags pointing at it.
func RemoveImage(ctx context.Context, reference string) error {
out, err := exec.CommandContext(ctx, "docker", "image", "rm", "-f", reference).CombinedOutput()
if err != nil {
return fmt.Errorf("remove image %s: %w: %s", reference, err, strings.TrimSpace(string(out)))
}
return nil
}
-149
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"sync"
@@ -198,151 +197,3 @@ func GetPR(ctx context.Context, number int) (*PR, error) {
UpdatedAt: r.UpdatedAt,
}, nil
}
type Check struct {
Name string `json:"name"`
State string `json:"state"`
Bucket string `json:"bucket"`
Workflow string `json:"workflow"`
Link string `json:"link"`
StartedAt string `json:"startedAt"`
CompletedAt string `json:"completedAt"`
Description string `json:"description"`
Event string `json:"event"`
}
var checkBucketKeys = map[string]string{
"pass": "pass",
"fail": "fail",
"pending": "pending",
"skipping": "skipping",
"cancel": "cancel",
}
func (c Check) BucketKey() string {
if key, found := checkBucketKeys[strings.ToLower(c.Bucket)]; found {
return key
}
return "pending"
}
func (c Check) IsPending() bool {
return c.Bucket == "pending" || c.State == "PENDING" || c.State == "IN_PROGRESS" || c.State == "STARTING"
}
func (c Check) IsSuccess() bool {
return c.Bucket == "pass" || c.State == "SUCCESS"
}
func (c Check) IsFailure() bool {
return c.Bucket == "fail" || c.State == "FAILURE" || c.State == "ERROR"
}
func (c Check) IsSkipped() bool {
return c.Bucket == "skipping" || c.Bucket == "cancel" || c.State == "SKIPPED" || c.State == "CANCELLED"
}
func (c Check) Duration() string {
if c.StartedAt == "" {
return ""
}
start, err := time.Parse(time.RFC3339, c.StartedAt)
if err != nil || start.IsZero() {
return ""
}
end := time.Now()
if c.CompletedAt != "" {
if tEnd, err := time.Parse(time.RFC3339, c.CompletedAt); err == nil && !tEnd.IsZero() {
end = tEnd
}
}
d := end.Sub(start).Round(time.Second)
if d < 0 {
return ""
}
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60)
}
return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60)
}
var prChecksCache = struct {
sync.Mutex
entries map[int]cachedChecks
}{entries: make(map[int]cachedChecks)}
type cachedChecks struct {
checks []Check
fetchedAt time.Time
}
func GetPRChecks(ctx context.Context, number int, refresh bool) ([]Check, error) {
if number <= 0 {
return nil, fmt.Errorf("invalid PR number: %d", number)
}
prChecksCache.Lock()
entry, found := prChecksCache.entries[number]
prChecksCache.Unlock()
if !refresh && found && time.Since(entry.fetchedAt) < 15*time.Second {
return append([]Check(nil), entry.checks...), nil
}
cmd := exec.CommandContext(ctx, "gh", "pr", "checks", fmt.Sprint(number),
"--repo", Repo,
"--json", "name,state,bucket,workflow,link,startedAt,completedAt,description,event",
)
out, err := cmd.CombinedOutput()
if err != nil && len(out) == 0 {
return nil, commandError(err, out)
}
var checks []Check
if jsonErr := json.Unmarshal(out, &checks); jsonErr != nil {
if err != nil {
return nil, commandError(err, out)
}
return nil, fmt.Errorf("parse checks JSON: %w", jsonErr)
}
prChecksCache.Lock()
prChecksCache.entries[number] = cachedChecks{checks: checks, fetchedAt: time.Now()}
prChecksCache.Unlock()
return checks, nil
}
func WatchPRChecks(ctx context.Context, number int) error {
cmd := exec.CommandContext(ctx, "gh", "pr", "checks", fmt.Sprint(number), "--repo", Repo, "--watch")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func CurrentBranchPR(ctx context.Context) (*PR, error) {
out, err := exec.CommandContext(ctx, "gh", "pr", "view",
"--repo", Repo,
"--json", "number,title,author,headRefName,headRefOid,updatedAt,isDraft,statusCheckRollup",
).CombinedOutput()
if err != nil {
return nil, commandError(err, out)
}
var r rawPR
if err := json.Unmarshal(out, &r); err != nil {
return nil, err
}
return &PR{
Number: r.Number,
Title: r.Title,
Author: r.Author.Login,
HeadRef: r.HeadRefName,
HeadSHA: r.HeadRefOID,
CIState: rollupState(r.StatusCheckRollup),
IsDraft: r.IsDraft,
UpdatedAt: r.UpdatedAt,
}, nil
}
-44
View File
@@ -89,47 +89,3 @@ func TestListPRsExcludesBotsIntegration(t *testing.T) {
}
}
}
func TestCheckHelpersAndDuration(t *testing.T) {
c1 := Check{
Name: "Test Job",
State: "SUCCESS",
Bucket: "pass",
StartedAt: "2026-08-21T10:00:00Z",
CompletedAt: "2026-08-21T10:05:30Z",
}
if !c1.IsSuccess() || c1.IsFailure() || c1.IsPending() || c1.IsSkipped() {
t.Fatalf("unexpected state helpers for c1: success=%v, fail=%v, pend=%v, skip=%v", c1.IsSuccess(), c1.IsFailure(), c1.IsPending(), c1.IsSkipped())
}
if d := c1.Duration(); d != "5m30s" {
t.Fatalf("Duration() = %q, want 5m30s", d)
}
c2 := Check{
Name: "Fail Job",
State: "FAILURE",
Bucket: "fail",
}
if !c2.IsFailure() {
t.Fatalf("expected c2 to be failure")
}
if c2.Duration() != "" {
t.Fatalf("expected empty duration for missing timestamps")
}
c3 := Check{
Name: "Pending Job",
Bucket: "pending",
}
if !c3.IsPending() {
t.Fatalf("expected c3 to be pending")
}
c4 := Check{
Name: "Skipped Job",
Bucket: "skipping",
}
if !c4.IsSkipped() {
t.Fatalf("expected c4 to be skipped")
}
}
-241
View File
@@ -1,241 +0,0 @@
// Package logs keeps live `docker logs --follow` streams for running
// containers so the sidebar can show output as it happens instead of polling.
package logs
import (
"context"
"strings"
"sync"
"time"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
)
// retainedLines bounds memory per container. Homarr is chatty at boot, so the
// buffer has to be deep enough to still contain the startup banner once the
// app settles.
const retainedLines = 2000
// maxStreams bounds how many containers are followed at once. Each stream is a
// `docker logs --follow` process plus a buffer, which is cheap for the handful
// of instances a developer normally runs but not for thirty. The least recently
// viewed stream is dropped when the cap is reached; re-attaching replays the
// tail, so nothing is lost beyond the scrollback.
const maxStreams = 6
// State describes what a stream is currently doing.
type State int
const (
StateStarting State = iota
StateStreaming
StateStopped
StateFailed
)
// Stream is the retained output of one container.
type Stream struct {
mu sync.Mutex
name string
lines []string
state State
err error
cancel context.CancelFunc
since time.Time
used time.Time
}
// Registry owns every active log stream and notifies a single listener when new
// output arrives.
type Registry struct {
mu sync.Mutex
streams map[string]*Stream
notify chan struct{}
}
func NewRegistry() *Registry {
return &Registry{streams: make(map[string]*Stream), notify: make(chan struct{}, 1)}
}
// Changed is a coalescing wake-up channel, mirroring task.Manager. A container
// logging a thousand lines a second still costs one redraw per frame.
func (r *Registry) Changed() <-chan struct{} { return r.notify }
func (r *Registry) touch() {
select {
case r.notify <- struct{}{}:
default:
}
}
// Attach starts following a container's logs if it is not already being
// followed. It returns immediately; output arrives on the Changed channel.
func (r *Registry) Attach(name string) {
if name == "" {
return
}
r.mu.Lock()
if _, exists := r.streams[name]; exists {
r.mu.Unlock()
return
}
now := time.Now()
ctx, cancel := context.WithCancel(context.Background())
stream := &Stream{name: name, state: StateStarting, cancel: cancel, since: now, used: now}
r.streams[name] = stream
evicted := r.evictLocked()
r.mu.Unlock()
for _, stale := range evicted {
stale.cancel()
}
go r.follow(ctx, stream)
r.touch()
}
// evictLocked drops the least recently viewed streams above the cap. The caller
// holds the lock and cancels the returned streams outside it.
func (r *Registry) evictLocked() []*Stream {
evicted := make([]*Stream, 0)
for len(r.streams) > maxStreams {
var oldest *Stream
for _, stream := range r.streams {
if oldest == nil || stream.used.Before(oldest.used) {
oldest = stream
}
}
if oldest == nil {
break
}
delete(r.streams, oldest.name)
evicted = append(evicted, oldest)
}
return evicted
}
func (r *Registry) follow(ctx context.Context, stream *Stream) {
lines, err := docker.StreamContainerLogs(ctx, stream.name, 400)
if err != nil {
stream.set(StateFailed, err)
r.touch()
return
}
stream.set(StateStreaming, nil)
r.touch()
for line := range lines {
if line.Done {
if ctx.Err() != nil {
stream.set(StateStopped, nil)
} else if line.Err != nil {
stream.set(StateFailed, line.Err)
} else {
stream.set(StateStopped, nil)
}
r.touch()
return
}
stream.append(line.Text)
r.touch()
}
}
func (s *Stream) append(line string) {
s.mu.Lock()
defer s.mu.Unlock()
s.lines = append(s.lines, line)
if len(s.lines) > retainedLines {
s.lines = s.lines[len(s.lines)-retainedLines:]
}
}
func (s *Stream) set(state State, err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.state, s.err = state, err
}
// Detach stops following a container and drops its buffer.
func (r *Registry) Detach(name string) {
r.mu.Lock()
stream, exists := r.streams[name]
delete(r.streams, name)
r.mu.Unlock()
if exists && stream.cancel != nil {
stream.cancel()
}
r.touch()
}
// Retain stops streams for containers that no longer exist, so a removed
// instance does not keep a dead `docker logs` process around.
func (r *Registry) Retain(names []string) {
live := make(map[string]bool, len(names))
for _, name := range names {
live[name] = true
}
r.mu.Lock()
stale := make([]*Stream, 0)
for name, stream := range r.streams {
if !live[name] {
stale = append(stale, stream)
delete(r.streams, name)
}
}
r.mu.Unlock()
for _, stream := range stale {
if stream.cancel != nil {
stream.cancel()
}
}
if len(stale) > 0 {
r.touch()
}
}
// Close stops every stream.
func (r *Registry) Close() { r.Retain(nil) }
// Snapshot is an immutable view of one stream.
type Snapshot struct {
Name string
Lines []string
State State
Err error
}
// Text joins the retained lines for rendering into a viewport.
func (s Snapshot) Text() string { return strings.Join(s.Lines, "\n") }
// Snapshot returns the retained output for a container. The second result is
// false when the container is not being followed.
func (r *Registry) Snapshot(name string) (Snapshot, bool) {
r.mu.Lock()
stream, exists := r.streams[name]
if exists {
// Viewing a stream is what keeps it alive under the cap.
stream.used = time.Now()
}
r.mu.Unlock()
if !exists {
return Snapshot{}, false
}
stream.mu.Lock()
defer stream.mu.Unlock()
return Snapshot{
Name: stream.name,
Lines: append([]string(nil), stream.lines...),
State: stream.state,
Err: stream.err,
}, true
}
// Attached lists the containers currently being followed.
func (r *Registry) Attached() []string {
r.mu.Lock()
defer r.mu.Unlock()
names := make([]string, 0, len(r.streams))
for name := range r.streams {
names = append(names, name)
}
return names
}
@@ -1,96 +0,0 @@
package logs
import (
"fmt"
"testing"
"time"
)
func TestRegistryAttachAndSnapshot(t *testing.T) {
reg := NewRegistry()
defer reg.Close()
snap, attached := reg.Snapshot("nonexistent")
if attached {
t.Fatalf("expected attached=false for unattached container")
}
reg.Attach("test-container")
snap, attached = reg.Snapshot("test-container")
if !attached {
t.Fatalf("expected attached=true after Attach")
}
if snap.State != StateStarting && snap.State != StateStreaming {
t.Fatalf("expected StateStarting or StateStreaming, got %v", snap.State)
}
reg.Detach("test-container")
snap, attached = reg.Snapshot("test-container")
if attached {
t.Fatalf("expected attached=false after Detach")
}
}
func TestRegistryChangedNotification(t *testing.T) {
reg := NewRegistry()
defer reg.Close()
ch := reg.Changed()
if ch == nil {
t.Fatal("expected non-nil Changed channel")
}
reg.touch()
select {
case <-ch:
case <-time.After(50 * time.Millisecond):
t.Fatal("expected notification on Changed channel")
}
}
func TestStreamLineBufferingAndBounds(t *testing.T) {
s := &Stream{
name: "test",
since: time.Now(),
used: time.Now(),
}
for i := 0; i < retainedLines+50; i++ {
s.append(fmt.Sprintf("line %d", i))
}
s.mu.Lock()
lines := make([]string, len(s.lines))
copy(lines, s.lines)
s.mu.Unlock()
if len(lines) != retainedLines {
t.Fatalf("expected %d retained lines, got %d", retainedLines, len(lines))
}
if lines[0] != "line 50" {
t.Errorf("first retained line = %q, want 'line 50'", lines[0])
}
}
func TestRegistryRetainAndEviction(t *testing.T) {
reg := NewRegistry()
defer reg.Close()
reg.Attach("c1")
reg.Attach("c2")
reg.Attach("c3")
// Retain only c1 and c3
reg.Retain([]string{"c1", "c3"})
if _, attached := reg.Snapshot("c2"); attached {
t.Errorf("c2 should have been dropped by Retain")
}
if _, attached := reg.Snapshot("c1"); !attached {
t.Errorf("c1 should still be attached")
}
if _, attached := reg.Snapshot("c3"); !attached {
t.Errorf("c3 should still be attached")
}
}
+9 -10
View File
@@ -14,16 +14,15 @@ func OpenURL(url string) error {
return exec.Command(name, args...).Start()
}
var platformOpeners = map[string]func(string) (string, []string){
"darwin": func(u string) (string, []string) { return "open", []string{u} },
"linux": func(u string) (string, []string) { return "xdg-open", []string{u} },
"windows": func(u string) (string, []string) { return "rundll32", []string{"url.dll,FileProtocolHandler", u} },
}
func openCommand(goos, url string) (string, []string, error) {
if opener, supported := platformOpeners[goos]; supported {
name, args := opener(url)
return name, args, nil
switch goos {
case "darwin":
return "open", []string{url}, nil
case "linux":
return "xdg-open", []string{url}, nil
case "windows":
return "rundll32", []string{"url.dll,FileProtocolHandler", url}, nil
default:
return "", nil, fmt.Errorf("opening URLs is not supported on %s", goos)
}
return "", nil, fmt.Errorf("opening URLs is not supported on %s", goos)
}
-165
View File
@@ -1,165 +0,0 @@
// Package registry queries GHCR for the pull-request test images Homarr's CI
// publishes.
package registry
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
// TestImageRepository is where CI pushes one image per open pull request.
const TestImageRepository = "homarr-labs/homarr-test"
// ImageReference returns the GHCR reference for a pull request's test image.
func ImageReference(pr int) string {
return fmt.Sprintf("ghcr.io/%s:pr-%d", TestImageRepository, pr)
}
const (
tagCacheTTL = 2 * time.Minute
httpTimeout = 20 * time.Second
)
var tagCache struct {
sync.Mutex
tags map[string]bool
fetchedAt time.Time
}
var client = &http.Client{Timeout: httpTimeout}
// PublishedTags lists every tag in the test image repository in a single
// request. Checking availability one pull request at a time with `docker
// manifest inspect` costs one network round trip per row, which dominates
// startup on a busy repository; the registry's own tag listing answers the same
// question for every row at once.
func PublishedTags(ctx context.Context, refresh bool) (map[string]bool, error) {
tagCache.Lock()
cached, fetchedAt := tagCache.tags, tagCache.fetchedAt
tagCache.Unlock()
if !refresh && cached != nil && time.Since(fetchedAt) < tagCacheTTL {
return cached, nil
}
token, err := anonymousToken(ctx)
if err != nil {
return nil, err
}
tags, err := listTags(ctx, token)
if err != nil {
if cached != nil {
return cached, err
}
return nil, err
}
tagCache.Lock()
tagCache.tags, tagCache.fetchedAt = tags, time.Now()
tagCache.Unlock()
return tags, nil
}
// HasPRImage reports whether CI has published an image for a pull request.
func HasPRImage(tags map[string]bool, pr int) bool {
return tags["pr-"+strconv.Itoa(pr)]
}
func anonymousToken(ctx context.Context) (string, error) {
url := fmt.Sprintf("https://ghcr.io/token?scope=repository:%s:pull&service=ghcr.io", TestImageRepository)
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
response, err := client.Do(request)
if err != nil {
return "", fmt.Errorf("request GHCR token: %w", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("request GHCR token: %s", response.Status)
}
var payload struct {
Token string `json:"token"`
}
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
return "", fmt.Errorf("decode GHCR token: %w", err)
}
return payload.Token, nil
}
func listTags(ctx context.Context, token string) (map[string]bool, error) {
tags := make(map[string]bool)
next := fmt.Sprintf("https://ghcr.io/v2/%s/tags/list?n=1000", TestImageRepository)
for page := 0; next != "" && page < 20; page++ {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil)
if err != nil {
return nil, err
}
request.Header.Set("Authorization", "Bearer "+token)
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("list GHCR tags: %w", err)
}
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
_ = response.Body.Close()
return nil, fmt.Errorf("list GHCR tags: %s: %s", response.Status, strings.TrimSpace(string(body)))
}
var payload struct {
Tags []string `json:"tags"`
}
err = json.NewDecoder(response.Body).Decode(&payload)
linkHeader := response.Header.Get("Link")
_ = response.Body.Close()
if err != nil {
return nil, fmt.Errorf("decode GHCR tags: %w", err)
}
for _, tag := range payload.Tags {
tags[tag] = true
}
next = nextPageURL(linkHeader)
}
return tags, nil
}
// nextPageURL follows the RFC 5988 Link header the registry uses for paging.
func nextPageURL(link string) string {
for _, part := range strings.Split(link, ",") {
if !strings.Contains(part, `rel="next"`) {
continue
}
start := strings.Index(part, "<")
end := strings.Index(part, ">")
if start < 0 || end <= start {
continue
}
target := part[start+1 : end]
if strings.HasPrefix(target, "/") {
return "https://ghcr.io" + target
}
return target
}
return ""
}
// ManifestExists falls back to the Docker CLI for a single reference, which
// also works for private repositories where the anonymous listing is refused.
func ManifestExists(ctx context.Context, reference string) (bool, error) {
out, err := exec.CommandContext(ctx, "docker", "manifest", "inspect", reference).CombinedOutput()
if err == nil {
return true, nil
}
message := strings.ToLower(string(out))
if strings.Contains(message, "manifest unknown") || strings.Contains(message, "no such manifest") {
return false, nil
}
return false, fmt.Errorf("check %s: %w: %s", reference, err, strings.TrimSpace(string(out)))
}
@@ -1,32 +0,0 @@
package registry
import (
"context"
"os"
"testing"
"time"
)
func TestPublishedTagsIntegration(t *testing.T) {
if testing.Short() || os.Getenv("HOMARR_INTEGRATION_TEST") != "1" {
t.Skip("skipping GHCR integration test; set HOMARR_INTEGRATION_TEST=1 to run")
}
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
start := time.Now()
tags, err := PublishedTags(ctx, true)
if err != nil {
t.Skipf("GHCR unreachable: %v", err)
}
t.Logf("fetched %d tags in %s", len(tags), time.Since(start))
if len(tags) == 0 {
t.Fatal("expected at least one published tag")
}
found := 0
for tag := range tags {
if found < 5 {
t.Log(" ", tag)
found++
}
}
}
+39 -29
View File
@@ -7,9 +7,11 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
)
func BuildCurrentImage(tag, directory string) error {
@@ -17,11 +19,11 @@ func BuildCurrentImage(tag, directory string) error {
}
func buildCurrentImage(ctx context.Context, tag, directory string, stdout, stderr io.Writer) error {
options, err := CurrentBuildOptions(ctx, tag, directory)
root, revision, err := checkoutDetails(ctx, directory)
if err != nil {
return err
}
return docker.BuildContext(ctx, options, stdout, stderr)
return docker.BuildContext(ctx, docker.BuildOptions{Context: root, Tag: tag, Source: root, Revision: revision}, stdout, stderr)
}
func BuildPRImage(number int, tag string) error {
@@ -29,18 +31,44 @@ func BuildPRImage(number int, tag string) error {
}
func buildPRImage(ctx context.Context, number int, tag string, stdout, stderr io.Writer) error {
options, cleanup, err := PRBuildOptions(ctx, number, tag, func(line string) {
_, _ = fmt.Fprintln(stdout, line)
})
if number <= 0 {
return fmt.Errorf("PR number must be positive")
}
temporaryRoot, err := os.MkdirTemp("", "homarr-pr-*")
if err != nil {
return fmt.Errorf("create temporary checkout: %w", err)
}
defer os.RemoveAll(temporaryRoot)
checkout := filepath.Join(temporaryRoot, "homarr")
clone := exec.CommandContext(ctx, "gh", prCloneArgs(checkout)...)
clone.Stdout = stdout
clone.Stderr = stderr
if err := clone.Run(); err != nil {
return fmt.Errorf("clone %s: %w", gh.Repo, err)
}
command := exec.CommandContext(ctx, "gh", "pr", "checkout", strconv.Itoa(number), "--detach")
command.Dir = checkout
command.Stdout = stdout
command.Stderr = stderr
if err := command.Run(); err != nil {
return fmt.Errorf("checkout PR #%d: %w", number, err)
}
_, revision, err := checkoutDetails(ctx, checkout)
if err != nil {
return err
}
defer cleanup()
return docker.BuildContext(ctx, options, stdout, stderr)
return docker.BuildContext(ctx, docker.BuildOptions{
Context: checkout,
Tag: tag,
Source: fmt.Sprintf("https://github.com/%s/pull/%d", gh.Repo, number),
Revision: revision,
PRNumber: number,
}, stdout, stderr)
}
func prFetchArgs(repoURL, refSpec string) []string {
return []string{"fetch", "--depth=1", repoURL, refSpec}
func prCloneArgs(checkout string) []string {
return []string{"repo", "clone", gh.Repo, checkout, "--", "--depth=1", "--filter=blob:none"}
}
func RebuildImage(image docker.Image) error {
@@ -61,16 +89,7 @@ func RebuildImageContext(ctx context.Context, image docker.Image, stdout, stderr
}
func checkoutDetails(ctx context.Context, directory string) (root string, revision string, err error) {
cmdRoot := exec.CommandContext(ctx, "git", "-C", directory, "rev-parse", "--show-toplevel")
cmdRoot.Env = append(
os.Environ(),
"MISE_TRUSTED_CONFIG_PATHS=*",
"MISE_QUIET=1",
"MISE_SILENT=1",
"MISE_YES=1",
"MISE_OVERRIDE_CONFIG_FILENAMES=",
)
rootOutput, err := cmdRoot.CombinedOutput()
rootOutput, err := exec.CommandContext(ctx, "git", "-C", directory, "rev-parse", "--show-toplevel").CombinedOutput()
if err != nil {
return "", "", fmt.Errorf("find Homarr checkout: %w: %s", err, strings.TrimSpace(string(rootOutput)))
}
@@ -78,16 +97,7 @@ func checkoutDetails(ctx context.Context, directory string) (root string, revisi
if _, err := os.Stat(filepath.Join(root, "Dockerfile")); err != nil {
return "", "", fmt.Errorf("%s is not a Homarr checkout", root)
}
cmdRev := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "HEAD")
cmdRev.Env = append(
os.Environ(),
"MISE_TRUSTED_CONFIG_PATHS=*",
"MISE_QUIET=1",
"MISE_SILENT=1",
"MISE_YES=1",
"MISE_OVERRIDE_CONFIG_FILENAMES=",
)
revisionOutput, err := cmdRev.CombinedOutput()
revisionOutput, err := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "HEAD").CombinedOutput()
if err != nil {
return "", "", fmt.Errorf("read checkout revision: %w: %s", err, strings.TrimSpace(string(revisionOutput)))
}
-128
View File
@@ -1,128 +0,0 @@
package run
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
)
// Cleanup releases a temporary checkout. It is always safe to call.
type Cleanup func()
func noCleanup() {}
// CurrentBuildOptions describes a build of the checkout that contains
// directory, recording the source and revision on the image so it can be
// rebuilt later without the user remembering where it came from.
func CurrentBuildOptions(ctx context.Context, tag, directory string) (docker.BuildOptions, error) {
root, revision, err := checkoutDetails(ctx, directory)
if err != nil {
return docker.BuildOptions{}, err
}
return docker.BuildOptions{Context: root, Tag: tag, Source: root, Revision: revision}, nil
}
// PRBuildOptions clones a pull request into a temporary checkout and describes
// a build of it. The returned Cleanup must run once the build has finished.
//
// Progress is reported through log rather than written to stdout, because the
// caller is a background job rendering inside a TUI, not a shell.
func PRBuildOptions(ctx context.Context, number int, tag string, log func(string)) (docker.BuildOptions, Cleanup, error) {
if number <= 0 {
return docker.BuildOptions{}, noCleanup, fmt.Errorf("PR number must be positive")
}
if log == nil {
log = func(string) {}
}
temporaryRoot, err := os.MkdirTemp("", "homarr-pr-*")
if err != nil {
return docker.BuildOptions{}, noCleanup, fmt.Errorf("create temporary checkout: %w", err)
}
cleanup := func() { _ = os.RemoveAll(temporaryRoot) }
checkout := filepath.Join(temporaryRoot, "homarr")
if err := os.MkdirAll(checkout, 0o755); err != nil {
cleanup()
return docker.BuildOptions{}, noCleanup, fmt.Errorf("create checkout directory: %w", err)
}
repoURL := fmt.Sprintf("https://github.com/%s.git", gh.Repo)
branchName := fmt.Sprintf("pr-%d", number)
refSpec := fmt.Sprintf("refs/pull/%d/head:%s", number, branchName)
log(fmt.Sprintf("fetching PR #%d (shallow) from %s", number, gh.Repo))
initCmd := exec.CommandContext(ctx, "git", "init", checkout)
if err := runLogged(initCmd, log); err != nil {
cleanup()
return docker.BuildOptions{}, noCleanup, fmt.Errorf("init git repository: %w", err)
}
fetchCmd := exec.CommandContext(ctx, "git", "-C", checkout, "fetch", "--depth", "1", repoURL, refSpec)
if err := runLogged(fetchCmd, log); err != nil {
cleanup()
return docker.BuildOptions{}, noCleanup, fmt.Errorf("fetch PR #%d: %w", number, err)
}
checkoutCmd := exec.CommandContext(ctx, "git", "-C", checkout, "checkout", branchName)
if err := runLogged(checkoutCmd, log); err != nil {
cleanup()
return docker.BuildOptions{}, noCleanup, fmt.Errorf("checkout PR #%d: %w", number, err)
}
_, revision, err := checkoutDetails(ctx, checkout)
if err != nil {
cleanup()
return docker.BuildOptions{}, noCleanup, err
}
log("building " + revision[:min(len(revision), 12)])
return docker.BuildOptions{
Context: checkout,
Tag: tag,
Source: fmt.Sprintf("https://github.com/%s/pull/%d", gh.Repo, number),
Revision: revision,
PRNumber: number,
}, cleanup, nil
}
// RebuildOptions describes a rebuild of an existing local image from the
// provenance recorded on it, whether that is a pull request or a checkout.
func RebuildOptions(ctx context.Context, image docker.Image, log func(string)) (docker.BuildOptions, Cleanup, error) {
if image.PRNumber > 0 {
return PRBuildOptions(ctx, image.PRNumber, image.Tag, log)
}
if image.Source == "" {
return docker.BuildOptions{}, noCleanup, fmt.Errorf("homarr:%s has no build source metadata", image.Tag)
}
if info, err := os.Stat(image.Source); err != nil || !info.IsDir() {
return docker.BuildOptions{}, noCleanup, fmt.Errorf("build source %s is no longer available", image.Source)
}
options, err := CurrentBuildOptions(ctx, image.Tag, image.Source)
return options, noCleanup, err
}
func runLogged(command *exec.Cmd, log func(string)) error {
command.Env = append(
os.Environ(),
"MISE_TRUSTED_CONFIG_PATHS=*",
"MISE_QUIET=1",
"MISE_SILENT=1",
"MISE_YES=1",
"MISE_OVERRIDE_CONFIG_FILENAMES=",
)
lines, err := docker.StreamCommand(command)
if err != nil {
return err
}
for line := range lines {
if line.Done {
return line.Err
}
log(line.Text)
}
return nil
}
+4 -4
View File
@@ -19,10 +19,10 @@ func TestPRPlanUsesAMD64(t *testing.T) {
}
}
func TestPRFetchIsShallow(t *testing.T) {
args := prFetchArgs("https://github.com/homarr-labs/homarr.git", "refs/pull/1/head:pr-1")
if !slices.Contains(args, "--depth=1") {
t.Fatalf("fetch args = %v", args)
func TestPRCloneIsShallow(t *testing.T) {
args := prCloneArgs("/tmp/checkout")
if !slices.Contains(args, "--depth=1") || !slices.Contains(args, "--filter=blob:none") {
t.Fatalf("clone args = %v", args)
}
}
@@ -1,59 +0,0 @@
package task
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
)
// Exercises the real BuildKit streaming and parsing path against a small
// Dockerfile, so the step tracker is validated on genuine output.
func TestBuildImageStreamsRealSteps(t *testing.T) {
if testing.Short() || os.Getenv("HOMARR_INTEGRATION_TEST") != "1" {
t.Skip("skipping docker build probe test; set HOMARR_INTEGRATION_TEST=1 to run")
}
dir := t.TempDir()
dockerfile := `FROM alpine:3.20 AS base
RUN echo "hello from step two" && sleep 1
FROM base AS final
RUN echo "third step" > /out.txt
RUN cat /out.txt
`
if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil {
t.Fatal(err)
}
manager := NewManager()
task := manager.Build("build homarr:cli-probe", "homarr:cli-probe", docker.BuildOptions{
Context: dir, Tag: "cli-probe", Source: dir, Revision: "probe",
})
t.Cleanup(func() { _ = docker.RemoveImage(context.Background(), "homarr:cli-probe") })
for range 600 {
<-manager.Changed()
if snapshot, _ := manager.Snapshot(task.ID()); snapshot.State.Done() {
t.Logf("state=%s percent=%.2f detail=%q err=%v", snapshot.State, snapshot.Percent, snapshot.Detail, snapshot.Err)
for _, step := range snapshot.Steps {
t.Logf(" #%-3s done=%-5v %-46s %s", step.ID, step.Done, truncate(step.Label, 46), step.Note)
}
if snapshot.State != StateSucceeded {
t.Fatalf("build did not succeed: %v", snapshot.Err)
}
if len(snapshot.Steps) < 4 {
t.Fatalf("expected several build steps, got %d", len(snapshot.Steps))
}
return
}
}
t.Fatal("build did not finish")
}
func truncate(value string, width int) string {
if len(value) <= width {
return value
}
return value[:width]
}
-237
View File
@@ -1,237 +0,0 @@
package task
import (
"context"
"fmt"
"strings"
"time"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
)
// Pull downloads an image, preferring the Docker Engine API because it reports
// per-layer byte progress even when the CLI would not. If the socket is
// unreachable the plain `docker pull` output is parsed instead, which still
// yields per-layer state, just without byte counters.
func (m *Manager) Pull(title, target, image, platform string) *Task {
return m.Start(KindPull, title, target, func(ctx context.Context, report *Reporter) error {
return PullImage(ctx, report, image, platform)
})
}
// PullImage is the body of a pull, exported so callers can compose it with
// follow-up work — pulling and then starting a container reads better as one
// job than as two.
func PullImage(ctx context.Context, report *Reporter, image, platform string) error {
tracker := docker.NewPullTracker()
publish := throttled(report, func() {
report.SetSteps(pullSteps(tracker))
report.SetPercent(tracker.Percent())
report.SetDetail(pullDetail(tracker))
})
messages, err := docker.PullStream(ctx, image, platform)
if err != nil {
report.Log("engine API unavailable (" + err.Error() + "), falling back to docker pull")
return pullWithCLI(ctx, report, tracker, publish, image, platform)
}
for message := range messages {
if message.Error != "" {
return fmt.Errorf("pull %s: %s", image, message.Error)
}
tracker.Apply(message)
if message.ID == "" && message.Status != "" {
report.Log(message.Status)
}
publish(false)
}
if ctx.Err() != nil {
return ctx.Err()
}
publish(true)
return nil
}
func pullWithCLI(ctx context.Context, report *Reporter, tracker *docker.PullTracker, publish func(bool), image, platform string) error {
command := docker.PullCommandContext(ctx, image, platform)
command.Cancel = func() error { return command.Process.Kill() }
lines, err := docker.StreamCommand(command)
if err != nil {
return err
}
for line := range lines {
if line.Done {
if line.Err != nil {
return line.Err
}
break
}
tracker.ApplyLine(line.Text)
publish(false)
}
if ctx.Err() != nil {
return ctx.Err()
}
publish(true)
return nil
}
func pullSteps(tracker *docker.PullTracker) []Step {
layers := tracker.Layers()
steps := make([]Step, 0, len(layers))
for _, layer := range layers {
note := ""
if layer.Total > 0 && !layer.Done() {
note = docker.FormatSize(layer.Current) + "/" + docker.FormatSize(layer.Total)
} else if layer.Total > 0 {
note = docker.FormatSize(layer.Total)
}
steps = append(steps, Step{
ID: layer.ID,
Label: layer.ID,
Status: layer.Status,
Current: layer.Current,
Total: layer.Total,
Percent: layer.Fraction(),
Done: layer.Done(),
Note: note,
})
}
return steps
}
func pullDetail(tracker *docker.PullTracker) string {
complete, total := tracker.Complete()
parts := []string{fmt.Sprintf("%d/%d layers", complete, total)}
if current, size := tracker.Bytes(); size > 0 {
parts = append(parts, docker.FormatSize(current)+"/"+docker.FormatSize(size))
}
if rate := tracker.Rate(); rate > 0 {
parts = append(parts, docker.FormatSize(int64(rate))+"/s")
}
return strings.Join(parts, " · ")
}
// Build compiles an image, streaming BuildKit's plain progress so the UI can
// show which Dockerfile step is running and what it is printing.
func (m *Manager) Build(title, target string, options docker.BuildOptions) *Task {
return m.Start(KindBuild, title, target, func(ctx context.Context, report *Reporter) error {
return BuildImage(ctx, report, options)
})
}
// BuildFrom runs a caller-supplied preparation step (cloning a pull request,
// for example) before building. Preparation output is reported the same way as
// build output so the job reads as one continuous stream.
func (m *Manager) BuildFrom(title, target string, prepare func(ctx context.Context, report *Reporter) (docker.BuildOptions, error)) *Task {
return m.Start(KindBuild, title, target, func(ctx context.Context, report *Reporter) error {
options, err := prepare(ctx, report)
if err != nil {
return err
}
return BuildImage(ctx, report, options)
})
}
// BuildImage is the body of a build, exported for the same reason as PullImage.
func BuildImage(ctx context.Context, report *Reporter, options docker.BuildOptions) error {
command, err := docker.BuildCommandStreaming(ctx, options)
if err != nil {
return err
}
command.Cancel = func() error { return command.Process.Kill() }
lines, err := docker.StreamCommand(command)
if err != nil {
return err
}
tracker := docker.NewBuildTracker()
publish := throttled(report, func() {
report.SetSteps(buildSteps(tracker))
report.SetPercent(tracker.Percent())
report.SetDetail(buildDetail(tracker))
})
for line := range lines {
if line.Done {
if line.Err != nil {
if detail := tracker.LastMessage(); detail != "" && !strings.Contains(line.Err.Error(), detail) {
return fmt.Errorf("%w: %s", line.Err, detail)
}
return line.Err
}
break
}
tracker.ApplyLine(line.Text)
report.Log(line.Text)
publish(false)
}
if ctx.Err() != nil {
return ctx.Err()
}
publish(true)
return nil
}
func buildSteps(tracker *docker.BuildTracker) []Step {
steps := tracker.Steps()
converted := make([]Step, 0, len(steps))
for _, step := range steps {
label := step.Label
if label == "" {
label = "#" + step.ID
}
percent := 0.0
if step.Done && !step.Canceled && !step.Failed {
percent = 1
}
note := step.Elapsed
if step.Cached {
note = "cached"
}
converted = append(converted, Step{
ID: step.ID,
Label: label,
Status: step.Status,
Percent: percent,
Done: step.Done && !step.Canceled,
Failed: step.Failed,
Note: note,
})
}
return converted
}
func buildDetail(tracker *docker.BuildTracker) string {
complete, total := tracker.Complete()
detail := fmt.Sprintf("%d/%d steps", complete, total)
if active, running := tracker.Active(); running {
detail += " · " + active.Label
}
return detail
}
// Action runs a short operation such as stopping a container. It still goes
// through the manager so a slow daemon can never freeze the interface.
func (m *Manager) Action(title, target string, body func(ctx context.Context) error) *Task {
return m.Start(KindAction, title, target, func(ctx context.Context, report *Reporter) error {
return body(ctx)
})
}
// throttled limits how often a progress publisher runs. Docker emits progress
// far faster than a terminal can redraw, and every publish copies slices, so
// coalescing here keeps the cost proportional to the frame rate instead of to
// the daemon's chattiness. Passing force publishes regardless, which the caller
// does once at the end so the final state is never dropped.
func throttled(report *Reporter, publish func()) func(force bool) {
const interval = 80 * time.Millisecond
last := time.Time{}
return func(force bool) {
now := time.Now()
if !force && now.Sub(last) < interval {
return
}
last = now
publish()
}
}
-360
View File
@@ -1,360 +0,0 @@
// Package task runs the CLI's slow work — image pulls, local builds, container
// actions — on background goroutines and exposes it as immutable snapshots.
//
// Nothing in this package blocks the caller. A Manager owns every running job,
// coalesces their updates into a single wake-up channel, and hands the UI a
// consistent view whenever it asks for one. That separation is what keeps the
// terminal responsive while a fifteen minute Docker build streams output.
package task
import (
"context"
"sync"
"time"
)
// Kind classifies a job so the UI can pick an icon and a detail renderer.
type Kind int
const (
KindPull Kind = iota
KindBuild
KindAction
)
var kindNames = map[Kind]string{
KindPull: "pull",
KindBuild: "build",
KindAction: "action",
}
func (k Kind) String() string {
if name, found := kindNames[k]; found {
return name
}
return "action"
}
// State is the lifecycle stage of a job.
type State int
const (
StateRunning State = iota
StateSucceeded
StateFailed
StateCanceled
)
func (s State) Done() bool { return s != StateRunning }
var stateNames = map[State]string{
StateRunning: "running",
StateSucceeded: "succeeded",
StateFailed: "failed",
StateCanceled: "canceled",
}
func (s State) String() string {
if name, found := stateNames[s]; found {
return name
}
return "canceled"
}
// Step is one unit of progress inside a job: a Docker layer during a pull, or a
// BuildKit vertex during a build.
type Step struct {
ID string
Label string
Status string
Current int64
Total int64
Percent float64
Done bool
Failed bool
Note string
}
// Snapshot is an immutable view of a job. The UI only ever sees snapshots, so
// it can render without holding a lock or racing the worker goroutine.
type Snapshot struct {
ID int
Kind Kind
Title string
Target string
Detail string
State State
Err error
Percent float64
Steps []Step
Lines []string
Started time.Time
Ended time.Time
Cancellable bool
}
// Elapsed reports how long the job ran, or has been running.
func (s Snapshot) Elapsed() time.Duration {
if s.Ended.IsZero() {
return time.Since(s.Started)
}
return s.Ended.Sub(s.Started)
}
// Reporter is the handle a job body uses to publish progress. Every method is
// safe to call from any goroutine and never blocks on the UI.
type Reporter struct {
task *Task
}
// Log appends a line of output. Only the most recent lines are retained.
func (r *Reporter) Log(line string) {
r.task.mutate(func(t *Task) { t.lines = appendLine(t.lines, line) })
}
// SetSteps replaces the job's progress breakdown.
func (r *Reporter) SetSteps(steps []Step) {
r.task.mutate(func(t *Task) { t.steps = steps })
}
// SetPercent records overall progress in the range [0,1].
func (r *Reporter) SetPercent(percent float64) {
r.task.mutate(func(t *Task) { t.percent = clamp(percent) })
}
// SetDetail replaces the single-line summary shown next to the job.
func (r *Reporter) SetDetail(detail string) {
r.task.mutate(func(t *Task) { t.detail = detail })
}
// Lines returns the retained output, newest last.
func (r *Reporter) Lines() []string {
r.task.mu.Lock()
defer r.task.mu.Unlock()
return append([]string(nil), r.task.lines...)
}
// Body is the work a job performs. Returning an error fails the job; returning
// ctx.Err() after cancellation marks it canceled instead.
type Body func(ctx context.Context, report *Reporter) error
// Task is a single background job.
type Task struct {
mu sync.Mutex
manager *Manager
id int
kind Kind
title string
target string
detail string
state State
err error
percent float64
steps []Step
lines []string
started time.Time
ended time.Time
cancel context.CancelFunc
}
const retainedLines = 500
func appendLine(lines []string, line string) []string {
lines = append(lines, line)
if len(lines) > retainedLines {
lines = lines[len(lines)-retainedLines:]
}
return lines
}
func clamp(value float64) float64 {
if value < 0 {
return 0
}
if value > 1 {
return 1
}
return value
}
func (t *Task) mutate(apply func(*Task)) {
t.mu.Lock()
apply(t)
t.mu.Unlock()
t.manager.touch()
}
func (t *Task) snapshot() Snapshot {
t.mu.Lock()
defer t.mu.Unlock()
return Snapshot{
ID: t.id,
Kind: t.kind,
Title: t.title,
Target: t.target,
Detail: t.detail,
State: t.state,
Err: t.err,
Percent: t.percent,
Steps: append([]Step(nil), t.steps...),
Lines: append([]string(nil), t.lines...),
Started: t.started,
Ended: t.ended,
Cancellable: t.cancel != nil && t.state == StateRunning,
}
}
// ID returns the job's identifier, stable for the lifetime of the process.
func (t *Task) ID() int { return t.id }
// Manager owns every background job and notifies a single listener when any of
// them changes.
type Manager struct {
mu sync.Mutex
tasks []*Task
nextID int
notify chan struct{}
}
func NewManager() *Manager {
return &Manager{notify: make(chan struct{}, 1)}
}
// Changed is a coalescing wake-up channel. It receives at most one pending
// notification, so a job that emits thousands of progress updates per second
// still costs the UI a single redraw per frame.
func (m *Manager) Changed() <-chan struct{} { return m.notify }
func (m *Manager) touch() {
select {
case m.notify <- struct{}{}:
default:
}
}
// Start launches a job and returns immediately.
func (m *Manager) Start(kind Kind, title, target string, body Body) *Task {
ctx, cancel := context.WithCancel(context.Background())
m.mu.Lock()
m.nextID++
task := &Task{
manager: m,
id: m.nextID,
kind: kind,
title: title,
target: target,
state: StateRunning,
started: time.Now(),
cancel: cancel,
}
m.tasks = append(m.tasks, task)
m.mu.Unlock()
go func() {
err := body(ctx, &Reporter{task: task})
task.mu.Lock()
task.ended = time.Now()
task.cancel = nil
switch {
case ctx.Err() != nil:
task.state = StateCanceled
case err != nil:
task.state, task.err = StateFailed, err
default:
task.state, task.percent = StateSucceeded, 1
}
task.mu.Unlock()
cancel()
m.touch()
}()
m.touch()
return task
}
// Snapshots returns every job, oldest first.
func (m *Manager) Snapshots() []Snapshot {
m.mu.Lock()
tasks := append([]*Task(nil), m.tasks...)
m.mu.Unlock()
snapshots := make([]Snapshot, 0, len(tasks))
for _, task := range tasks {
snapshots = append(snapshots, task.snapshot())
}
return snapshots
}
// Snapshot returns a single job by identifier.
func (m *Manager) Snapshot(id int) (Snapshot, bool) {
m.mu.Lock()
defer m.mu.Unlock()
for _, task := range m.tasks {
if task.id == id {
return task.snapshot(), true
}
}
return Snapshot{}, false
}
// Cancel asks a running job to stop.
func (m *Manager) Cancel(id int) {
m.mu.Lock()
defer m.mu.Unlock()
for _, task := range m.tasks {
if task.id != id {
continue
}
task.mu.Lock()
cancel := task.cancel
task.mu.Unlock()
if cancel != nil {
cancel()
}
}
}
// CancelAll stops every running job, used when the program exits.
func (m *Manager) CancelAll() {
for _, snapshot := range m.Snapshots() {
if snapshot.State == StateRunning {
m.Cancel(snapshot.ID)
}
}
}
// Running counts jobs that have not finished.
func (m *Manager) Running() int {
count := 0
for _, snapshot := range m.Snapshots() {
if snapshot.State == StateRunning {
count++
}
}
return count
}
// RunningFor reports whether a job is already working on the given target, so
// the UI can refuse to start a duplicate build or pull.
func (m *Manager) RunningFor(target string) (Snapshot, bool) {
for _, snapshot := range m.Snapshots() {
if snapshot.State == StateRunning && snapshot.Target == target {
return snapshot, true
}
}
return Snapshot{}, false
}
// Prune drops finished jobs, keeping the history short.
func (m *Manager) Prune() {
m.mu.Lock()
kept := m.tasks[:0]
for _, task := range m.tasks {
if task.snapshot().State == StateRunning {
kept = append(kept, task)
}
}
m.tasks = kept
m.mu.Unlock()
m.touch()
}
-410
View File
@@ -1,410 +0,0 @@
package tui
import (
"context"
"fmt"
"strconv"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/platform"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/registry"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/run"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/task"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/ui"
)
// target is everything the actions need to know about the selected row,
// resolved once so the same code can act on a pull request, a local image, or a
// bare container without caring which screen the user is on.
type target struct {
label string
container string
localImage string
volume string
pr int
running bool
port string
provenance docker.Image
buildable bool
}
func (m Model) currentTarget() (target, bool) {
if m.screen == screenInstances {
row, found := m.selectedInstance()
if !found {
return target{}, false
}
return m.instanceTarget(row), true
}
row, found := m.selectedDev()
if !found {
return target{}, false
}
return m.devTarget(row), true
}
func (m Model) devTarget(row devRow) target {
result := target{
label: row.label(),
container: row.containerName(),
volume: row.volumeName(),
running: row.running,
port: row.port,
buildable: true,
}
if row.kind == rowLocal {
result.localImage = row.local.Reference()
result.provenance = row.local
result.pr = row.local.PRNumber
return result
}
result.pr = row.pr.Number
result.localImage = row.localReference()
// A pull request always has a rebuild recipe even without a local image:
// clone the head into a temporary checkout and build it as homarr:pr-N.
result.provenance = docker.Image{Tag: fmt.Sprintf("pr-%d", row.pr.Number), PRNumber: row.pr.Number}
if row.local.Tag != "" {
result.provenance = row.local
}
return result
}
func (m Model) instanceTarget(row instanceRow) target {
result := target{
label: row.container.Name,
container: row.container.Name,
volume: row.container.Name + "_data",
running: row.container.Running(),
port: row.container.HostPort(),
}
if strings.HasPrefix(row.container.Image, "homarr:") {
result.localImage = row.container.Image
tag := strings.TrimPrefix(row.container.Image, "homarr:")
for _, image := range m.images {
if image.Tag == tag {
result.provenance, result.buildable = image, true
break
}
}
}
if number := row.prNumber(); number > 0 {
result.pr = number
if !result.buildable {
result.provenance = docker.Image{Tag: fmt.Sprintf("pr-%d", number), PRNumber: number}
result.buildable = true
}
}
return result
}
// primaryAction is Enter: stop what is running, start what is not. A matching
// local build and a published registry image open a small source selector;
// otherwise the only available source is started directly.
func (m Model) primaryAction() (Model, tea.Cmd) {
if m.screen == screenInstances {
row, found := m.selectedInstance()
if !found {
return m, nil
}
if row.container.Running() {
return m.stopContainer(row.container.Name)
}
return m.restartContainer(row.container.Name)
}
row, found := m.selectedDev()
if !found {
return m, nil
}
if row.running {
return m.stopContainer(row.containerName())
}
if row.kind == rowLocal {
return m.startLocal(row)
}
if row.hasCurrentLocalImage() {
if m.remoteImageAvailable(row) {
return m.selectImage(row)
}
return m.startLocalPR(row)
}
switch row.image {
case ui.ImageUnknown, ui.ImageChecking:
m.status, m.statusLevel = "still checking whether CI published an image for "+row.label(), levelWarn
return m, nil
case ui.ImageMissing:
m.status, m.statusLevel = fmt.Sprintf("CI has not published an image for PR #%d yet — press R to build it locally", row.pr.Number), levelWarn
return m, nil
}
return m.deployRemotePR(row)
}
func (m Model) remoteImageAvailable(row devRow) bool {
return row.kind == rowRemote && m.tagsKnown && registry.HasPRImage(m.tags, row.pr.Number)
}
func (m Model) selectImage(row devRow) (Model, tea.Cmd) {
m.imageSelection = imageSelection{rowKey: row.key(), choice: imageChoiceLocal}
m.mode = modeImageSelect
m.status, m.statusLevel = "choose an image source for "+row.label(), levelInfo
m.relayout()
return m, nil
}
func (m Model) startLocal(row devRow) (Model, tea.Cmd) {
plan, err := run.BuildPlan(run.Options{Context: context.Background(), Tag: row.local.Tag, Demo: m.demo})
if err != nil {
m.status, m.statusLevel = err.Error(), levelError
return m, nil
}
return m.startPlan("start "+row.local.Reference(), row.containerName(), plan)
}
func (m Model) startLocalPR(row devRow) (Model, tea.Cmd) {
plan, err := localPRPlan(row, m.demo)
if err != nil {
m.status, m.statusLevel = err.Error(), levelError
return m, nil
}
return m.startPlan(fmt.Sprintf("start PR #%d from local build", row.pr.Number), row.containerName(), plan)
}
// localPRPlan runs a locally built image under the pull request's container and
// volume identity, so switching between a local build and the registry build of
// the same pull request keeps the same data and port.
func localPRPlan(row devRow, demo bool) (*run.Plan, error) {
plan, err := run.BuildPlan(run.Options{Context: context.Background(), Tag: row.local.Tag, Demo: demo})
if err != nil {
return nil, err
}
plan.Name = row.containerName()
plan.Volume = row.volumeName()
plan.Label = fmt.Sprintf("PR #%d · local %s", row.pr.Number, truncateRevision(row.local.Revision))
plan.PRNumber = row.pr.Number
return plan, nil
}
func truncateRevision(revision string) string {
if len(revision) > 12 {
return revision[:12]
}
return revision
}
func (m Model) startPlan(title, container string, plan *run.Plan) (Model, tea.Cmd) {
m.tasks.Action(title, container, func(ctx context.Context) error {
plan.Pull = false
return run.StartDetached(plan)
})
m.status, m.statusLevel = fmt.Sprintf("%s → http://localhost:%d", title, plan.HostPort), levelInfo
return m, nil
}
// deployRemotePR pulls the registry image and starts it as one job, so the
// sidebar shows a single continuous progress stream rather than two.
func (m Model) deployRemotePR(row devRow) (Model, tea.Cmd) {
if existing, running := m.tasks.RunningFor(row.containerName()); running {
m.status, m.statusLevel = existing.Title+" is already running", levelWarn
return m, nil
}
plan, err := run.BuildPlan(run.Options{Context: context.Background(), PR: row.pr.Number, Demo: m.demo})
if err != nil {
m.status, m.statusLevel = err.Error(), levelError
return m, nil
}
// Reuse the port a running instance already holds so a redeploy does not
// move the URL out from under an open browser tab.
if row.running && row.port != "" {
if port, err := strconv.Atoi(row.port); err == nil {
plan.HostPort = port
}
}
title := fmt.Sprintf("pull and start PR #%d", row.pr.Number)
created := m.tasks.Start(task.KindPull, title, row.containerName(), func(ctx context.Context, report *task.Reporter) error {
if err := task.PullImage(ctx, report, plan.Image, plan.Platform); err != nil {
return err
}
report.SetDetail("starting container on port " + strconv.Itoa(plan.HostPort))
plan.Pull = false
return run.StartDetached(plan)
})
return m.focusOn(created.ID(), title)
}
// buildLocally rebuilds the selection in the background. A pull request without
// a local image is cloned into a temporary checkout first.
func (m Model) buildLocally(subject target) (Model, tea.Cmd) {
if !subject.buildable {
m.status, m.statusLevel = subject.label+" has no build source to rebuild from", levelWarn
return m, nil
}
reference := "homarr:" + subject.provenance.Tag
if existing, running := m.tasks.RunningFor(reference); running {
m.status, m.statusLevel = existing.Title+" is already running", levelWarn
return m, nil
}
image := subject.provenance
title := "build " + reference
created := m.tasks.BuildFrom(title, reference, func(ctx context.Context, report *task.Reporter) (docker.BuildOptions, error) {
options, cleanup, err := run.RebuildOptions(ctx, image, report.Log)
if err != nil {
return docker.BuildOptions{}, err
}
// The checkout has to outlive option construction but not the build,
// and BuildFrom runs both inside one job, so releasing it when the job
// goroutine unwinds is both correct and the simplest thing available.
context.AfterFunc(ctx, cleanup)
return options, nil
})
return m.focusOn(created.ID(), title)
}
func (m Model) pullRemote(row devRow) (Model, tea.Cmd) {
if row.kind != rowRemote {
m.status, m.statusLevel = "pulling only applies to pull-request images", levelWarn
return m, nil
}
return m.deployRemotePR(row)
}
// focusOn points the tray and sidebar at a freshly started job so its progress
// is visible without any further keystrokes.
func (m Model) focusOn(id int, title string) (Model, tea.Cmd) {
m.focusTask = id
m.sidebar.visible = true
m.sidebar.source = sourceTask
m.sidebar.follow = true
m.status, m.statusLevel = title+" started in the background", levelInfo
m.relayout()
cmd := m.refreshSidebar()
clocks := m.animate()
return m, tea.Batch(cmd, clocks)
}
func (m Model) stopContainer(name string) (Model, tea.Cmd) {
m.tasks.Action("stop "+name, name, func(ctx context.Context) error { return docker.StopContext(ctx, name) })
m.status, m.statusLevel = "stopping "+name+"…", levelInfo
return m, nil
}
func (m Model) restartContainer(name string) (Model, tea.Cmd) {
m.tasks.Action("restart "+name, name, func(ctx context.Context) error { return docker.RestartContext(ctx, name) })
m.status, m.statusLevel = "restarting "+name+"…", levelInfo
return m, nil
}
func (m Model) removeContainer(name string) (Model, tea.Cmd) {
m.tasks.Action("remove "+name, name, func(ctx context.Context) error { return docker.RemoveContext(ctx, name) })
m.status, m.statusLevel = "removing "+name+"…", levelInfo
return m, nil
}
func (m Model) deleteImage(reference string) (Model, tea.Cmd) {
m.tasks.Action("delete "+reference, reference, func(ctx context.Context) error {
return docker.RemoveImage(ctx, reference)
})
m.status, m.statusLevel = "deleting "+reference+"…", levelInfo
return m, nil
}
// deleteData removes an instance's volume. Docker refuses to delete a volume
// that is still attached, so the container is removed first — the data is going
// away either way, and failing halfway would leave the user to work out why.
func (m Model) deleteData(subject target) (Model, tea.Cmd) {
container, volume := subject.container, subject.volume
m.tasks.Action("delete data "+volume, volume, func(ctx context.Context) error {
_ = docker.RemoveContext(ctx, container)
return docker.RemoveVolume(ctx, volume)
})
m.status, m.statusLevel = "deleting "+volume+"…", levelInfo
return m, nil
}
func (m Model) pruneStopped() (Model, tea.Cmd) {
names := make([]string, 0)
for _, container := range m.containers {
if !container.Running() {
names = append(names, container.Name)
}
}
if len(names) == 0 {
m.status, m.statusLevel = "no stopped Homarr containers to prune", levelInfo
return m, nil
}
m.tasks.Action(fmt.Sprintf("prune %d stopped containers", len(names)), "prune", func(ctx context.Context) error {
for _, name := range names {
if err := docker.RemoveContext(ctx, name); err != nil {
return err
}
}
return nil
})
m.status, m.statusLevel = fmt.Sprintf("pruning %d stopped containers…", len(names)), levelInfo
return m, nil
}
func (m Model) openPullRequest() (Model, tea.Cmd) {
row, found := m.selectedDev()
if !found || row.kind != rowRemote {
m.status, m.statusLevel = "select a pull request to open it on GitHub", levelWarn
return m, nil
}
return m.openPullRequestNumber(row.pr.Number)
}
func (m Model) openPullRequestNumber(number int) (Model, tea.Cmd) {
if number <= 0 {
return m, nil
}
url := fmt.Sprintf("https://github.com/%s/pull/%d", gitHubRepo, number)
if err := platform.OpenURL(url); err != nil {
m.status, m.statusLevel = "open failed: "+err.Error(), levelError
return m, nil
}
m.status, m.statusLevel = "opened "+url, levelOK
return m, nil
}
const gitHubRepo = "homarr-labs/homarr"
func (m Model) openApp() (Model, tea.Cmd) {
subject, found := m.currentTarget()
if !found || !subject.running || subject.port == "" {
m.status, m.statusLevel = "selection is not running", levelWarn
return m, nil
}
url := "http://localhost:" + subject.port
if err := platform.OpenURL(url); err != nil {
m.status, m.statusLevel = "open failed: "+err.Error(), levelError
return m, nil
}
m.status, m.statusLevel = "opened "+url, levelOK
return m, nil
}
func (m Model) copyURL() (Model, tea.Cmd) {
subject, found := m.currentTarget()
if !found || subject.port == "" {
m.status, m.statusLevel = "selection has no published port", levelWarn
return m, nil
}
url := "http://localhost:" + subject.port
if err := platform.CopyText(url); err != nil {
m.status, m.statusLevel = "copy failed: "+err.Error(), levelError
return m, nil
}
m.status, m.statusLevel = "copied "+url, levelOK
return m, nil
}
// remoteImageFor is used by the detail view to name the registry image a row
// would pull.
func remoteImageFor(row devRow) string {
if row.kind != rowRemote {
return ""
}
return registry.ImageReference(row.pr.Number)
}
+122
View File
@@ -0,0 +1,122 @@
package tui
import (
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
)
type appModel struct {
dev prsModel
dashboard dashModel
showDashboard bool
width int
height int
}
func newAppModel(showDashboard bool) appModel {
return appModel{dev: newPRsModel(), dashboard: newDashModel(), showDashboard: showDashboard}
}
func (m appModel) Init() tea.Cmd {
if m.showDashboard {
return m.dashboard.Init()
}
return m.dev.Init()
}
func (m appModel) canSwitch() bool {
if m.showDashboard {
return !m.dashboard.filtering
}
return !m.dev.filtering && !m.dev.pulling
}
func (m appModel) switchScreen() (tea.Model, tea.Cmd) {
m.showDashboard = !m.showDashboard
commands := make([]tea.Cmd, 0, 2)
if m.width > 0 && m.height > 0 {
resize := tea.WindowSizeMsg{Width: m.width, Height: m.height}
if m.showDashboard {
updated, command := m.dashboard.Update(resize)
m.dashboard = updated.(dashModel)
commands = append(commands, command)
} else {
updated, command := m.dev.Update(resize)
m.dev = updated.(prsModel)
commands = append(commands, command)
}
}
if m.showDashboard {
commands = append(commands, m.dashboard.Init())
} else {
commands = append(commands, m.dev.Init())
}
return m, tea.Batch(commands...)
}
func (m appModel) updateDev(msg tea.Msg) (appModel, tea.Cmd) {
updated, command := m.dev.Update(msg)
m.dev = updated.(prsModel)
return m, command
}
func (m appModel) updateDashboard(msg tea.Msg) (appModel, tea.Cmd) {
updated, command := m.dashboard.Update(msg)
m.dashboard = updated.(dashModel)
return m, command
}
func (m appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if window, ok := msg.(tea.WindowSizeMsg); ok {
m.width = window.Width
m.height = window.Height
m, devCommand := m.updateDev(msg)
m, dashboardCommand := m.updateDashboard(msg)
return m, tea.Batch(devCommand, dashboardCommand)
}
if key, ok := msg.(tea.KeyPressMsg); ok {
if key.String() == "d" && m.canSwitch() {
return m.switchScreen()
}
if m.showDashboard {
return m.updateDashboard(msg)
}
return m.updateDev(msg)
}
switch msg.(type) {
case prsLoadedMsg, imageCheckedMsg, prActionMsg, rebuildDoneMsg, runningRefreshedMsg, pullReadyMsg, pullEvent, spinner.TickMsg:
return m.updateDev(msg)
case tickMsg, actionMsg, rowsMsg:
if m.showDashboard {
return m.updateDashboard(msg)
}
return m, nil
case prLogTickMsg:
if !m.showDashboard {
return m.updateDev(msg)
}
return m, nil
case logsMsg:
m, devCommand := m.updateDev(msg)
m, dashboardCommand := m.updateDashboard(msg)
return m, tea.Batch(devCommand, dashboardCommand)
}
if m.showDashboard {
return m.updateDashboard(msg)
}
return m.updateDev(msg)
}
func (m appModel) View() tea.View {
if m.showDashboard {
return m.dashboard.View()
}
return m.dev.View()
}
func runApp(showDashboard bool) error {
program := tea.NewProgram(newAppModel(showDashboard))
_, err := program.Run()
return err
}
+47
View File
@@ -0,0 +1,47 @@
package tui
import (
"testing"
tea "charm.land/bubbletea/v2"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/gh"
)
func TestAppTogglePreservesDevelopmentState(t *testing.T) {
m := newAppModel(false)
m.dev.rows = []prRow{{kind: "remote", pr: gh.PR{Number: 6502}}}
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"}))
dashboard := updated.(appModel)
if !dashboard.showDashboard {
t.Fatal("d did not open the instance dashboard")
}
updated, _ = dashboard.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"}))
development := updated.(appModel)
if development.showDashboard || len(development.dev.rows) != 1 || development.dev.rows[0].pr.Number != 6502 {
t.Fatalf("development state was not preserved: %#v", development.dev.rows)
}
}
func TestAppToggleDoesNotInterceptFilterInput(t *testing.T) {
m := newAppModel(false)
m.dev.filtering = true
m.dev.filter.Focus()
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"}))
got := updated.(appModel)
if got.showDashboard || got.dev.filter.Value() != "d" {
t.Fatalf("filter input was intercepted: dashboard=%v filter=%q", got.showDashboard, got.dev.filter.Value())
}
}
func TestAppCanOpenInstancesDuringBuild(t *testing.T) {
m := newAppModel(false)
m.dev.rebuilding = true
updated, _ := m.Update(tea.KeyPressMsg(tea.Key{Code: 'd', Text: "d"}))
if !updated.(appModel).showDashboard {
t.Fatal("background build blocked dashboard navigation")
}
}
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more