Compare commits

..
90 changed files with 842 additions and 331 deletions

No files matched your search

+1 -1
View File
@@ -37,4 +37,4 @@ Orient from the current checkout before editing. Treat package manifests, export
- Cron jobs: `packages/cron-jobs/src/jobs/`
- App routes: `apps/nextjs/src/app/[locale]/`
Invoke `documentation-sync` when hidden capabilities or information not evident to an advanced user change, or existing guidance becomes inaccurate; follow the Documentation Sync criteria in `AGENTS.md`. When exposing tRPC through MCP, invoke `mcp-integration`.
When a change affects setup, usage, troubleshooting, or the accuracy of existing docs, invoke `documentation-sync`. When exposing tRPC through MCP, invoke `mcp-integration`.
+12 -8
View File
@@ -1,17 +1,21 @@
---
name: documentation-sync
description: Write concise Homarr docs for advanced users, covering only hidden capabilities and information not evident from the UI. Use when that information changes or existing guidance becomes inaccurate; skip obvious controls and routine fixes.
description: Keep Homarr's Docusaurus documentation useful and accurate. Use when a change affects what users need to know to configure, use, or troubleshoot Homarr, or makes existing guidance inaccurate.
---
# Documentation Sync
Apply the audience and scope rules in `AGENTS.md` first. Before adding a passage, identify what an advanced user could not learn directly from the interface. If there is no such information, omit it.
Update `apps/docs/` when users need new or corrected guidance to configure, use, or troubleshoot Homarr. Keep documentation straight to the point.
For hidden capabilities such as the advanced widget feature, explain discovery, activation, and non-obvious behavior. For visible standard controls such as Delete, omit narration of what the label already conveys. Use the shortest explanation that preserves necessary technical detail; remove redundant prose in the passage being edited.
## Decide whether documentation is needed
When an update is warranted, inspect adjacent pages for structure and types, not as a verbosity target. The mappings below locate needed information; they do not require a page for every feature or code change.
Identify the user action, decision, prerequisite, limitation, or troubleshooting step that changes. Update the relevant docs when that information is missing or inaccurate. If nothing users need to know changes, leave the docs untouched.
## Locate a needed update
Routine visual fixes such as icon sizing, spacing, alignment, responsive layout, and animation usually need no documentation. Describe them in the PR when useful for review. Document a visual change only when it changes a user workflow, setting, or meaningful limitation.
Write the minimum information needed to complete the user's task. Inspect adjacent pages and the docs types before editing.
## Map changes that need documentation
| Code change | Documentation target |
| ----------------------------------- | -------------------------------------------------------------------- |
@@ -44,12 +48,12 @@ For a widget:
- Add or update `apps/docs/docs/widgets/<slug>/index.ts` with the local `WidgetDefinition` pattern.
- Add or update `index.mdx` with the established `WidgetHeader`, `WidgetConfig`, and `AddingWidget` components as applicable.
- Explain only non-obvious option semantics, integration prerequisites, permission constraints, or behavior that the interface does not make clear.
- Document options, required integrations, permissions, empty states, and changed behavior that affect setup or use.
## Completion criteria
1. Every passage adds information an advanced user cannot reasonably infer from the interface. Remove obvious, repetitive, or unnecessary prose.
1. Every addition helps a user configure, use, or troubleshoot Homarr; omit incidental rendering and implementation details.
2. Keep names, defaults, paths, screenshots, links, and prerequisites consistent with code.
3. Update every affected page and remove superseded guidance.
3. Correct affected guidance and remove superseded instructions. A code change alone does not require a docs edit.
4. Run the narrowest useful docs validation. Use `pnpm turbo build --filter=@homarr/docs` when links, MDX, generated definitions, or navigation can fail; otherwise run the docs package formatter on touched files.
5. Treat broken links and anchors as failures; Docusaurus checks them strictly.
@@ -1,4 +1,4 @@
interface:
display_name: "Homarr Documentation Sync"
short_description: "Keep user-facing docs aligned with code"
default_prompt: "Use $documentation-sync to map this Homarr code change to the required documentation updates."
short_description: "Keep user docs concise, useful, and accurate"
default_prompt: "Use $documentation-sync to decide whether this Homarr change needs user guidance and update only what users need to know."
+2 -3
View File
@@ -16,10 +16,9 @@ LOG_LEVEL='info'
# This is how you can use the sqlite driver:
DB_DRIVER='better-sqlite3'
# Set an absolute writable SQLite path so migrations and the app use the same file.
# Their working directories differ; do not use a relative path here.
# Safe local source-development path. SQLite files are ignored by Git.
# Container images use /appdata/db/db.sqlite by default instead.
DB_URL=
DB_URL='./db.sqlite'
# Those are the two ways to use the mysql2 driver:
# 1. Using the URL format:
+1 -1
View File
@@ -10,7 +10,7 @@
- [ ] Pull request targets the intended base branch (`dev` by default; `release/v2` for v2 release work)
- [ ] Commits follow the [conventional commits guideline](https://www.conventionalcommits.org/en/v1.0.0/)
- [ ] No shorthand variable names are used (for example, `x`, `y`, `i`, or abbreviations)
- [ ] User-facing changes include matching documentation updates under `apps/docs`
- [ ] Changes to setup, usage, troubleshooting, or existing guidance include relevant documentation updates under `apps/docs` when needed
- [ ] When using AI, no temporary files are checked in and the code style follows the rest of the project
**When contributing a new integration or widget:**
+8 -19
View File
@@ -16,10 +16,10 @@ permissions:
jobs:
create-and-merge-pr:
runs-on: ubuntu-latest
timeout-minutes: 10
timeout-minutes: 2
steps:
- name: Discord notification
if: ${{ inputs.send-notifications }}
if: ${{ github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
@@ -37,22 +37,11 @@ jobs:
branch: next
- name: Create pull request
id: create-pull-request
run: |
pr_url="$(gh pr list --base beta --head next --state open --json url --jq '.[0].url // empty')"
if [[ -z "$pr_url" ]]; then
pr_url="$(gh pr create \
--title "chore(release): release ${{ steps.semver.outputs.next }}-beta" \
--body "Detected change to be ${{ steps.semver.outputs.bump }}<br/>Bump version from ${{ steps.semver.outputs.current }} to ${{ steps.semver.outputs.next }}-beta" \
--base beta \
--head next \
--label automerge)"
fi
echo "url=$pr_url" >> "$GITHUB_OUTPUT"
echo "pr_number=${pr_url##*/}" >> "$GITHUB_OUTPUT"
run: 'gh pr create --title "chore(release): release ${{ steps.semver.outputs.next }}-beta" --body "Detected change to be ``${{ steps.semver.outputs.bump }}``<br/>Bump version from ``${{ steps.semver.outputs.current }}`` to ``${{ steps.semver.outputs.next }}-beta``" --base beta --head next --label automerge'
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Discord notification
if: ${{ inputs.send-notifications }}
if: ${{ github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
@@ -69,7 +58,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ steps.obtainApprovalToken.outputs.token }}
run: |
gh pr review "${{ steps.create-pull-request.outputs.url }}" --approve --body "Automatically approved by GitHub Action"
gh pr review --approve --body "Automatically approved by GitHub Action"
- name: Obtain token
id: obtainMergeToken
uses: actions/create-github-app-token@v3
@@ -93,21 +82,21 @@ jobs:
MERGE_REQUIRED_APPROVALS: 0 # do not require approvals
- name: Merged Discord notification
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && inputs.send-notifications }}
if: ${{ steps.automerge.outputs.mergeResult == 'merged' && github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "Merged PR ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}-beta"
- name: Major Bump Discord notification
if: ${{ steps.semver.outputs.bump == 'major' && inputs.send-notifications }}
if: ${{ steps.semver.outputs.bump == 'major' && github.event.inputs.send-notifications }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
with:
args: "The release PR must be manually merged because the next version is a major version: ${{ steps.create-pull-request.outputs.url }} for release ${{ steps.semver.outputs.next }}-beta"
- name: Discord Fail Notification
if: ${{ failure() && inputs.send-notifications }}
if: failure() && github.event.inputs.send-notifications
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
uses: Ilshidur/action-discord@0.4.0
+2 -8
View File
@@ -59,13 +59,7 @@ homarr/
The documentation site lives at `apps/docs/` (Docusaurus 3, `@homarr/docs`).
Write for smart, curious, advanced users. Documentation should contain only information they cannot reasonably discover or understand directly from the interface: hidden capabilities, non-obvious behavior, prerequisites, constraints, configuration contracts, and migration requirements.
Keep docs concise. Every paragraph must add knowledge beyond visible labels and standard UI conventions. Explain how to discover and use a hidden advanced widget feature; omit explanations that the Delete button deletes an item in edit mode. Avoid UI walkthroughs, exhaustive control descriptions, and repetition. When editing a passage, remove obvious or redundant prose rather than preserving verbosity as a precedent.
Update docs only when a change introduces or alters that non-obvious information, or makes existing guidance inaccurate. Routine fixes and self-explanatory UI changes need no docs update. A new feature alone does not require a page; apply the same reader-value test.
When documentation is needed, use these locations:
Update documentation when a change affects what users need to know to configure, use, or troubleshoot Homarr, or makes existing guidance inaccurate. Follow `.agents/skills/documentation-sync/SKILL.md` to decide whether an update is needed and keep it concise. For changes that need documentation:
- New integration → `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
- New widget → `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
@@ -105,6 +99,6 @@ When documentation is needed, use these locations:
Portable skills live in `.agents/skills/`. Read the relevant `SKILL.md` before working in that domain; detailed references are loaded only when needed. Claude-compatible discovery is provided through `.claude/skills`.
- `codebase-context` — architecture, package boundaries, and shared utilities
- `documentation-sync` — documentation for changes users need explained
- `documentation-sync` — concise, relevant user guidance
- `mcp-integration` — safe tRPC-to-MCP exposure
- `homarr-custom-widget` — safe Custom JSX v2 authoring
+14 -6
View File
@@ -15,13 +15,19 @@ Many of you used the v2 demo when it was still rough, found bugs and came back t
We are also planning a small batch of **free, limited-edition Homarr swag** for Workshop contributors and a few randomly picked community members. It is not for sale and it is not for profit. No Homarr merch empire, sorry.
Homarr 2.0 builds on the dashboard you already use. Here is what changed.
Homarr 2.0 is an upgrade, not a brand-new app wearing the same logo. Here is what actually changed.
![A populated Homarr 2.0 dashboard with compact widgets, an Assistant, and an application rail](./img/homarr-v2-dashboard.webp)
<!-- truncate -->
Update your Homarr image as usual. Homarr runs database migrations automatically when the updated container starts; no manual database migration or separate v2 upgrade procedure is needed. Workshop uses the production service by default, with no preview URL configuration required.
:::warning Back up before upgrading
Database migrations run automatically when Homarr starts. Create a
[Homarr backup](/docs/management/backup) first. Do not test a prerelease image on your only production volume.
Backups are boring. Restores are worse.
:::
## Custom Widgets v2 and Workshop
@@ -144,8 +150,9 @@ Groups use a new Viewer, Editor and Admin matrix. The API, Assistant and MCP fol
Board startup work runs in parallel, heavy screens load when opened, and identical integration requests share work instead of doing it twice.
## Compatibility notes
## Before upgrading
- Keep a backup and use the final release image, not a prerelease tag.
- Legacy Custom JSX definitions enter a migration state. Homarr keeps encrypted credentials and an archived copy until the v2 definition validates.
- Mobile layouts place rail content in the main flow instead of displaying rails.
- Custom Widgets remain Beta.
@@ -153,7 +160,8 @@ Board startup work runs in parallel, heavy screens load when opened, and identic
## Links
- Try the [live demo](https://demo.homarr.dev/).
- See [installation and update instructions](/docs/getting-started/installation/docker).
- Browse [Workshop](https://homarr.dev/workshop).
- Try the [Homarr 2.0 preview](https://app-v2.preview.homarr.dev).
- Follow the [v2 beta setup guide](/docs/getting-started/upgrade-v2).
- Follow the [v2 release pull request](https://github.com/homarr-labs/homarr/pull/6545) and [public beta thread](https://github.com/homarr-labs/homarr/issues/6600).
- Compare [v1.76.2 with the release/v2 branch](https://github.com/homarr-labs/homarr/compare/v1.76.2...release/v2).
- Read the guides for [Boards](/docs/management/boards), [Custom Widgets](/docs/management/custom-widgets), [Assistant](/docs/management/assistant), [MCP](/docs/management/mcp) and [Docker labels](/docs/integrations/docker-labels).
@@ -90,7 +90,7 @@ homarr run dev
homarr run --detach dev
homarr run --pr 6441
homarr run --pr 6441 --demo
homarr --pr 6441 --env WORKSHOP_WEB_URL=https://workshop.example.com/workshop
homarr --pr 6441 --env WORKSHOP_WEB_URL=https://app-v2.preview.homarr.dev/
```
- `--pr <number>`: Pull and run a PR image from GHCR (shorthand: `homarr --pr <number>`).
@@ -31,13 +31,9 @@ pnpm install --frozen-lockfile
### Run the Homarr application
1. Copy `.env.example` to `.env`.
2. Run `openssl rand -hex 32` twice. Set `AUTH_SECRET` and `SECRET_ENCRYPTION_KEY` to the two different generated
values in `.env`; the example leaves both empty. Keep this file private and preserve `SECRET_ENCRYPTION_KEY`
when reusing a database.
3. Set `DB_URL` to an absolute writable SQLite file path. Migration and application commands run from different
workspace directories, so a relative path can point them at different databases.
4. Keep Redis and the optional local databases running in one terminal.
5. Apply migrations and start Next.js in another terminal.
2. Set `DB_URL` to an absolute writable SQLite file path.
3. Keep Redis and the optional local databases running in one terminal.
4. Apply migrations and start Next.js in another terminal.
```sh
cp .env.example .env
@@ -61,20 +57,6 @@ Homarr becomes the instance administrator.
Use `pnpm docker:dev:up` instead when you only need Redis in the background. Running the migration also seeds the
default data; use `pnpm db:seed` to seed it explicitly later.
`pnpm dev` starts only Next.js. When your work needs scheduled tasks or live subscriptions, run the relevant service
in a separate terminal alongside it:
```sh
pnpm --filter @homarr/tasks dev
```
```sh
pnpm --filter @homarr/websocket dev
```
The WebSocket service listens on port 3001. Production startup embeds both services, so these extra commands are
only needed for development.
Workshop contributors can set up PocketBase and Docusaurus with the dedicated
[Workshop development guide](./workshop).
@@ -98,8 +80,8 @@ Workshop contributors can set up PocketBase and Docusaurus with the dedicated
- `pnpm typecheck` — typecheck all workspaces.
- `pnpm test` — unit and contract suites.
- `pnpm build` — production monorepo build.
- `docker build -t homarr:local .` — build a production Docker image.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:local` — run it.
- `docker build -t homarr .` — build a production Docker image.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>` — run it.
## CI and focused validation
@@ -24,10 +24,6 @@ see [Managing integrations](/docs/management/integrations#configuration).
### Update
Update the image and recreate the container with the same configuration. Homarr runs database migrations automatically
at startup, including when moving from v1 to v2; no separate upgrade procedure is needed. Keep the `/appdata` mount and
`SECRET_ENCRYPTION_KEY` unchanged. Workshop connects to the production service by default.
```sh
docker compose pull
docker compose up -d
@@ -21,8 +21,8 @@ This method is only useful, when you want to develop on Homarr or extend it with
## Prerequisites
- [Node.js](https://nodejs.org/en/download/) 24.18 or newer
- [pnpm](https://pnpm.io/installation) at the version pinned in the repository's `package.json`
- [Node.js](https://nodejs.org/en/download/)
- [pnpm](https://pnpm.io/installation)
- [Redis](https://redis.io/download)
## Steps
@@ -38,9 +38,9 @@ This method is only useful, when you want to develop on Homarr or extend it with
Install all dependencies using <code>pnpm install</code>
</li>
<li>
Copy `.env.example` to `.env` and set `DB_URL` to an absolute writable path, such as
`DB_URL='/home/username/homarr/db.sqlite'`. Migration and application commands run from different workspace
directories, so a relative path can point them at different databases.
Copy the `.env.example` file to `.env`. Its `DB_URL='./db.sqlite'` value is ready for local use and stores the
database in the cloned repository. Replace it with an absolute writable path such as
`DB_URL='/home/username/homarr/db.sqlite'` if you want to store the database elsewhere.
</li>
<li>
Run `openssl rand -hex 32` twice and set `AUTH_SECRET` and `SECRET_ENCRYPTION_KEY` to the two different generated
@@ -0,0 +1,88 @@
---
title: Try the v2 beta
sidebar_position: 2
tags:
- Getting started
- Upgrade
- Migration
- Backup
---
This page covers the temporary Homarr v2 public beta. Try the [live demo](https://app-v2.preview.homarr.dev/)
without installing anything, or run the beta image published in the
[public beta announcement](https://github.com/homarr-labs/homarr/issues/6600):
```sh
docker pull ghcr.io/homarr-labs/homarr-test:v2
```
For a fresh beta installation, follow the [Docker installation guide](/docs/getting-started/installation/docker),
use `ghcr.io/homarr-labs/homarr-test:v2` as the image, and use a separate `/appdata` volume.
Set `WORKSHOP_API_URL=https://v2.preview.homarr.dev` to use the preview Workshop.
Homarr runs database migrations automatically at startup; no manual database migration command is needed.
Once v2 is released, existing installations can use their normal update process. The beta image below is temporary.
:::danger Back up before starting v2
In Homarr v1, go to **Management → Tools → Backup**, export a backup, and keep the downloaded file somewhere safe
before starting v2. See [Backup and restore](/docs/management/backup) for details.
:::
## Try the beta with existing v1 data
1. Change the Homarr image to `ghcr.io/homarr-labs/homarr-test:v2`.
2. Keep the existing `/appdata` mount, database variables, Redis configuration, and `SECRET_ENCRYPTION_KEY` unchanged.
3. Start one Homarr replica and follow its logs. Startup must reach `Migration complete`; Homarr aborts startup when a
database migration fails.
4. Open Homarr and sign in. Confirm **Management → About** reports v2 before starting additional replicas.
5. Keep using the beta image for beta updates. To return to v1, restore the pre-beta backup as described below.
Keep your other existing Compose settings, including environment variables. For Docker Compose, update the image and preview Workshop URL before the usual update:
```yaml
services:
homarr:
image: ghcr.io/homarr-labs/homarr-test:v2
environment:
WORKSHOP_API_URL: https://v2.preview.homarr.dev
```
```sh
docker compose pull
docker compose up -d
docker compose logs --follow homarr
```
Do not add `-v` when stopping the Compose deployment; that can remove named volumes.
## What v2 migrates
The startup migration:
- creates the Custom Widget v2, Assistant, header preference, and branding storage;
- adds Base/Mobile layout roles and left/right board gutters;
- converts legacy Categories and dynamic sections into Containers and rails;
- preserves legacy Custom Widget definitions, encrypted secrets, and board references for explicit migration.
Legacy Custom Widgets are not silently rewritten. In **Management → Custom Widgets**, entries marked
**Migration required** provide a credential-free migration prompt. Review and test the resulting v2 definition before
removing the archived v1 version. See [Custom Widgets: Import and Workshop](/docs/management/custom-widgets#import-and-workshop).
## Verify the upgraded instance
- Sign in with each enabled authentication method and verify administrator access.
- Open every important board on desktop and mobile. Check Containers, former Categories, rails, item placement, and
home-board assignments.
- Test at least one read and one permitted action for each critical integration. Recheck integration and board access
for representative Viewer, Editor, and Admin groups.
- Check Docker or Podman endpoints, search engines, uploaded media, trusted certificates, scheduled tasks, WebSockets,
and external Redis when used.
- Review every legacy Custom Widget and migrate it only after its v2 preview and requests work.
- Restart Homarr once more and confirm the logs contain no migration, database, Redis, authentication, or task errors.
## Roll back
Use the backup exported before the upgrade if you need to restore your v1 installation. Do not reuse a database that
v2 has already migrated with v1.
@@ -24,14 +24,12 @@ The What's Up Docker integration lets Homarr connect to your [WUD](https://githu
<AddingIntegration />
:::info URL configuration
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr uses `/api/containers` under that URL for both connection testing and widget data.
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr calls the `/api/app` and `/api/containers` endpoints under that URL automatically.
:::
### Secrets
WUD 9 requires authentication. Enter your WUD username and password in Homarr; the initial admin credentials configured with `WUD_AUTH_ADMIN_USER` and `WUD_AUTH_ADMIN_PASSWORD` work with Basic authentication. Older WUD versions without authentication can leave both fields empty.
Credentials are sent to the configured URL over HTTP or HTTPS. Use HTTPS outside a trusted internal network.
WUD does not require authentication by default. If your instance is protected with [Basic Authentication](https://github.com/getwud/wud/blob/main/docs/configuration/authentications/basic/README.md), you can provide a username and password.
<IntegrationSecrets
secrets={[
@@ -42,7 +40,7 @@ Credentials are sent to the configured URL over HTTP or HTTPS. Use HTTPS outside
{
credentials: ["username", "password"],
steps: [
"Use your WUD account credentials; older versions can use a configured WUD_AUTH_BASIC_* user",
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
"Enter that username and password in Homarr — Basic authentication is handled automatically",
],
},
@@ -57,5 +57,5 @@ Legacy definitions show **Migration required**. The migration prompt excludes cr
describe the visible behavior separately, import the returned v2 definition, and verify it before removing the archived
version.
Keep `SECRET_ENCRYPTION_KEY` unchanged when updating Homarr so existing credentials remain readable. Multi-replica
installations must use the same Redis service so cached responses invalidate consistently.
Back up the database and `SECRET_ENCRYPTION_KEY` before upgrading or rolling back. Multi-replica installations must use
the same Redis service so cached responses invalidate consistently.
+2 -3
View File
@@ -18,9 +18,8 @@ password. Decimal units are the default. Change them from the user form or **Com
Preferences → Data size units**. Byte rates follow the same preference; bit rates stay decimal and use labels such as
Mb/s. Deleting a user does not delete boards they created.
In demo mode (`DEMO_MODE=true`), user deletion, password changes, and local username changes are disabled for everyone,
including administrators, even when `DEMO_READ_ONLY=false` allows other changes. With demo editing enabled, other
profile preferences remain editable. This applies to the UI and API.
In demo mode (`DEMO_MODE=true`), user deletion and password changes are disabled for everyone, including administrators,
even when `DEMO_READ_ONLY=false` allows other changes. This applies to the UI and API.
![The Users management page](../img/users.png)
+2 -1
View File
@@ -12,7 +12,8 @@ import { appWidget } from ".";
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
The App widget opens an application or website and can show whether Homarr can reach it. Configure the target and status
check from the tile's edit actions.
check from the tile's edit actions. Very small tiles shorten the title and description to leave room for the icon;
enlarge the tile to show more text.
### Adding the widget
@@ -27,7 +27,7 @@ import { WidgetCardShell, WidgetTitleBadge } from "@homarr/widgets/widget-card-s
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { getLogicalTrackSize } from "~/components/board/layout";
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import { useBoardContentScale } from "~/components/board/layout/scaled-board-canvas";
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
import { useAdvancedFocus } from "../advanced-focus/context";
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
@@ -148,7 +148,7 @@ const LoadedBoardItemContent = ({
const cardRef = useRef<HTMLDivElement>(null);
const previewEntranceFrameRef = useRef<number | null>(null);
const board = useRequiredBoard();
const boardCanvasScale = useBoardCanvasScale();
const boardContentScale = useBoardContentScale();
const t = useI18n();
const [isEditMode] = useEditMode();
const settings = useSettings();
@@ -163,8 +163,8 @@ const LoadedBoardItemContent = ({
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
const widgetName = getWidgetName(item.kind, t);
const previewDimensions = useMemo(
() => ({ width, height, scale: boardCanvasScale }),
[boardCanvasScale, height, width],
() => ({ width, height, scale: boardContentScale }),
[boardContentScale, height, width],
);
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
const advancedViewId = `advanced-focus-${item.id}`;
@@ -314,7 +314,7 @@ const LoadedBoardItemContent = ({
item={item}
width={width}
height={height}
displayScale={boardCanvasScale}
displayScale={boardContentScale}
widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef}
displayMode={isAdvanced ? "advanced" : "compact"}
@@ -5,9 +5,10 @@ import { IconCopy, IconDotsVertical, IconLayoutKanban, IconPencil, IconTrash } f
import { useSession } from "@homarr/auth/client";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getWidgetName } from "@homarr/definitions";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmMenuItem } from "@homarr/ui";
import type { WidgetDefinition } from "@homarr/widgets/definition";
import type { WidgetPreviewDimensions } from "@homarr/widgets/modals";
@@ -39,7 +40,6 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
const tItem = useI18n("item");
const t = useI18n();
const { openModal } = useModalAction(LazyWidgetEditModal);
const { openConfirmModal } = useConfirmModal();
const openMoveModal = useOpenItemMoveModal();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
@@ -143,21 +143,14 @@ const BoardItemMenuInner = ({ item, definition, previewDimensions, resetErrorBou
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item
<InlineConfirmMenuItem
color="red"
confirmLabel={tItem("remove.message")}
leftSection={<IconTrash size={16} />}
onClick={() => {
setIsMenuOpen(false);
openConfirmModal({
title: tItem("remove.message"),
children: label,
labels: { confirm: tItem("action.remove") },
onConfirm: () => removeItem({ itemId: item.id }),
});
}}
onConfirm={() => removeItem({ itemId: item.id })}
>
{tItem("action.remove")}
</Menu.Item>
</InlineConfirmMenuItem>
</Menu.Dropdown>
</Menu>
);
@@ -17,7 +17,10 @@
.canvas {
position: absolute;
inset: 0 auto auto 0;
}
.canvas,
.contentScale {
/*
* Keep dashboard UI at its normal readable density while the fixed canvas
* is fitted below 100%. Above 100%, the canvas still behaves like browser
@@ -8,6 +8,10 @@ import { useElementSize } from "@mantine/hooks";
import classes from "./scaled-board-canvas.module.css";
const BoardCanvasScaleContext = createContext(1);
// Widget content can also be scaled by containers inside the board canvas.
const BoardContentScaleContext = createContext(1);
export const BoardContentScaleProvider = BoardContentScaleContext.Provider;
export const useBoardContentScale = () => useContext(BoardContentScaleContext);
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
@@ -86,7 +90,9 @@ export const ScaledBoardCanvas = ({
zoom: scale,
}}
>
<BoardCanvasScaleContext.Provider value={scale}>{children}</BoardCanvasScaleContext.Provider>
<BoardCanvasScaleContext.Provider value={scale}>
<BoardContentScaleProvider value={scale}>{children}</BoardContentScaleProvider>
</BoardCanvasScaleContext.Provider>
</Box>
</Box>
</Box>
@@ -18,7 +18,13 @@ import {
getReadonlyCanvasAttributes,
normalizeGridPlacement,
} from "~/components/board/layout";
import { calculateBoardUiScale, useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import {
BoardContentScaleProvider,
calculateBoardUiScale,
useBoardCanvasScale,
useBoardContentScale,
} from "~/components/board/layout/scaled-board-canvas";
import canvasClasses from "~/components/board/layout/scaled-board-canvas.module.css";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
@@ -50,6 +56,7 @@ export const SectionGrid = ({
}: SectionGridProps) => {
const [isEditMode] = useEditMode();
const canvasScale = useBoardCanvasScale();
const parentContentScale = useBoardContentScale();
const editorRuntimeStatus = useGridEditorRuntimeStatus();
const editorRegistry = useGridEditorRegistry();
const editorHostRef = useRef<HTMLDivElement>(null);
@@ -144,10 +151,16 @@ export const SectionGrid = ({
const viewportRowCount =
viewportRowCountOverride ?? (isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount);
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
// The collapsible label keeps a fixed physical height while the grid uses logical canvas pixels.
// Convert that height so the label reserves the same space at every board scale.
let effectiveParentContentScale = effectiveCanvasScale;
if (Number.isFinite(parentContentScale) && parentContentScale > 0) {
effectiveParentContentScale = parentContentScale;
}
// Compensate every ancestor container while retaining the board's enlargement above 100%.
const parentUiScale =
(calculateBoardUiScale(effectiveCanvasScale) * effectiveCanvasScale) / effectiveParentContentScale;
// Match the label's Mantine height in its parent's logical coordinate system.
const collapsibleHeaderInset =
section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale : 0;
section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT * parentUiScale : 0;
const fullGridWidth = getLogicalGridSize(columnCount);
const fullGridHeight = getLogicalGridSize(rowCount);
const logicalWidth = fullGridWidth;
@@ -172,7 +185,8 @@ export const SectionGrid = ({
// both reads and writes --board-canvas-ui-scale (even indirectly, through another property)
// is a circular reference - CSS invalidates the whole group rather than using "the old value",
// silently breaking every icon/text/custom-CSS size that compensates off it for descendants.
const combinedUiScale = calculateBoardUiScale(canvasScale) / containerContentScale;
const combinedUiScale = parentUiScale / containerContentScale;
const contentScale = effectiveParentContentScale * containerContentScale;
// A collapsed container's compact coordinates are display-only. Its own
// nested grid stays inactive until an explicit edit interaction expands it.
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
@@ -282,21 +296,23 @@ export const SectionGrid = ({
onKeyDownCapture={expandCollapsedSectionsForKeyboardEdit}
>
<Box
className={classes.staticGrid}
className={combineClasses(classes.staticGrid, canvasClasses.contentScale)}
style={
{
width: fullGridWidth,
height: fullGridHeight,
zoom: containerContentScale,
margin: containerContentScale < 1 ? "0 auto" : undefined,
...(containerContentScale < 1 ? { "--board-canvas-ui-scale": combinedUiScale } : {}),
"--board-canvas-ui-scale": combinedUiScale,
} as CSSProperties
}
data-grid-section-id={section.id}
data-kind={section.kind}
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
>
<SectionContent />
<BoardContentScaleProvider value={contentScale}>
<SectionContent />
</BoardContentScaleProvider>
</Box>
<div ref={editorHostRef} className={classes.editorPortalHost} />
</Box>
+1 -8
View File
@@ -454,7 +454,7 @@ export const userRouter = createTRPCRouter({
}
const user = await ctx.db.query.users.findFirst({
columns: { name: true, email: true, provider: true },
columns: { email: true, provider: true },
where: eq(users.id, input.id),
});
@@ -472,13 +472,6 @@ export const userRouter = createTRPCRouter({
});
}
if (isDemoMode && input.name !== user.name) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Username changes are disabled in demo mode",
});
}
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, "credentials", input.name, input.id);
const emailDirty = input.email && user.email !== input.email;
@@ -198,7 +198,7 @@ describe("WudIntegration authentication", () => {
expect(requestInit?.timeout).toBe(10_000);
});
test("sends configured Basic auth credentials over an HTTP URL", async () => {
test("does not send credentials over a non-HTTPS URL", async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify(sampleContainersResponse), {
status: 200,
@@ -209,7 +209,7 @@ describe("WudIntegration authentication", () => {
const integration = new WudIntegration({
id: "test-wud-http",
name: "Test WUD HTTP",
url: "http://wud:3000",
url: "http://wud.example.com",
externalUrl: null,
decryptedSecrets: [
{ kind: "username", value: TEST_USERNAME },
@@ -221,7 +221,7 @@ describe("WudIntegration authentication", () => {
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
const headers = (requestInit?.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBe(`Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`);
expect(headers.Authorization).toBeUndefined();
});
});
@@ -238,16 +238,15 @@ describe("WudIntegration testing endpoint", () => {
fetchAsync: vi.fn().mockResolvedValue(response),
}) as unknown as IntegrationTestingInput;
test("returns success when /api/containers responds 200", async () => {
const input = makeInput(new Response(JSON.stringify(sampleContainersResponse), { status: 200 }));
test("returns success when /api/app responds 200", async () => {
const input = makeInput(new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 }));
const result = await invokeTesting(createIntegration(), input);
expect(result.success).toBe(true);
expect(String(vi.mocked(input.fetchAsync).mock.calls[0]?.[0])).toBe(`${TEST_URL}/api/containers`);
});
test("returns a status-code failure when /api/containers responds non-OK", async () => {
test("returns a status-code failure when /api/app responds non-OK", async () => {
const input = makeInput(new Response("unauthorized", { status: 401 }));
const result = await invokeTesting(createIntegration(), input);
@@ -256,7 +255,7 @@ describe("WudIntegration testing endpoint", () => {
});
test("passes the Basic auth header through to fetchAsync when configured", async () => {
const response = new Response(JSON.stringify(sampleContainersResponse), { status: 200 });
const response = new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 });
const input = makeInput(response);
await invokeTesting(createIntegrationWithBasicAuth(), input);
@@ -14,7 +14,7 @@ const CONTAINERS_REQUEST_TIMEOUT_MS = 10_000;
export class WudIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/containers"), {
const response = await input.fetchAsync(this.url("/api/app"), {
headers: this.getAuthHeaders(),
});
@@ -23,10 +23,10 @@ export class WudIntegration extends Integration {
}
try {
await parseWudContainersResponseAsync(response);
await response.json();
} catch (error) {
return TestConnectionError.ParseResult(
new ParseError("Invalid WUD containers response", {
new ParseError("Invalid WUD app response", {
cause: error instanceof Error ? error : new Error(String(error)),
}),
);
@@ -55,6 +55,10 @@ export class WudIntegration extends Integration {
return undefined;
}
if (this.url("/api/app").protocol !== "https:") {
return undefined;
}
const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString(
"base64",
);
+14 -1
View File
@@ -8,6 +8,7 @@ import { IconMapPin } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { formatLocalizedDate, formatLocalizedTime } from "../common/locale";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
@@ -27,7 +28,19 @@ import {
getUvCategory,
} from "./display";
export default function AirQualityWidget({ options, width, height, displayMode }: WidgetComponentProps<"airQuality">) {
export default function AirQualityWidget({
options,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"airQuality">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const query = clientApi.widget.airQuality.atLocation.useQuery({
latitude: options.location.latitude,
longitude: options.location.longitude,
+11 -2
View File
@@ -11,6 +11,7 @@ import { useTimeAgo } from "@homarr/common";
import type { AnchorNotePermission } from "@homarr/integrations";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
@@ -37,10 +38,18 @@ const isForbiddenError = (error: unknown): boolean => {
export default function AnchorNoteWidget({
options,
integrationIds,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"anchorNote">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.anchorNote");
const noteId = options.noteId.trim();
if (!noteId) {
+9 -1
View File
@@ -1,8 +1,16 @@
.appText {
min-width: 0;
min-height: 0;
flex-shrink: 1;
overflow-wrap: anywhere;
overflow: hidden;
}
.appIcon {
max-height: 100%;
max-width: 100%;
overflow: auto;
flex: 1 !important;
flex: 1;
object-fit: contain;
scale: 0.8;
transition: scale 0.2s ease-in-out;
+44 -11
View File
@@ -1,8 +1,8 @@
"use client";
import type { PropsWithChildren } from "react";
import type { CSSProperties, PropsWithChildren } from "react";
import { Fragment, Suspense } from "react";
import { Box, Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { Box, Flex, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconMinus } from "@tabler/icons-react";
import combineClasses from "clsx";
@@ -12,6 +12,7 @@ import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { getWidgetDisplayScale } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
@@ -21,7 +22,14 @@ import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
import { PingIndicator } from "./ping/ping-indicator";
export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
export default function AppWidget({
options,
isEditMode,
height,
width,
displayScale = 1,
displayMode,
}: WidgetComponentProps<"app">) {
const tCommon = useI18n("common");
const settings = useSettings();
const board = useRequiredBoard();
@@ -33,8 +41,31 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
if (!app) return <WidgetEmptyState />;
const isTiny = height < 100 || width < 100;
// Readable board tokens stay fixed on screen, but must yield space to the icon
// when the tile itself becomes small. Below 100px, scale the whole composition.
const scale = getWidgetDisplayScale({ displayScale, displayMode });
const maximumContentScale = Math.min(width, height) / 100;
let uiScale = "var(--board-canvas-ui-scale, 1)";
if (displayMode === "advanced") uiScale = "1";
const textSize = `min(calc(14px * ${uiScale}), ${14 * maximumContentScale}px)`;
const spacing = `min(calc(12px * ${uiScale}), ${12 * maximumContentScale}px)`;
const rowGap = `min(calc(6px * ${uiScale}), ${6 * maximumContentScale}px)`;
const isColumnLayout = options.layout.startsWith("column");
const isTiny = Math.min(width, height) * scale < 100;
const textStyle: CSSProperties = {};
let titleLineClamp: number | undefined;
let descriptionLineClamp = 4;
if (isTiny) {
titleLineClamp = 2;
descriptionLineClamp = 2;
textStyle.flexShrink = 1;
textStyle.maxWidth = "50%";
textStyle.maxHeight = "100%";
if (isColumnLayout) {
textStyle.maxWidth = "100%";
textStyle.maxHeight = "50%";
}
}
return (
<Box h="100%" w="100%" pos="relative">
@@ -44,22 +75,22 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
enabled={options.descriptionDisplayMode === "tooltip" && Boolean(app.description) && !isEditMode}
>
<Flex
p={isTiny ? 4 : "sm"}
className={combineClasses("app-flex-wrapper", app.name, app.id, href && classes.appWithUrl)}
h="100%"
w="100%"
direction={options.layout}
justify="center"
align="center"
gap={isColumnLayout ? 0 : "sm"}
style={{ padding: spacing, gap: isColumnLayout ? 0 : rowGap }}
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
>
<Stack gap={0}>
<Stack gap={0} className={classes.appText} style={textStyle}>
{options.showTitle && (
<Text
className="app-title"
fw={700}
size={isTiny ? rem(8) : "sm"}
lineClamp={titleLineClamp}
style={{ fontSize: textSize }}
ta={isColumnLayout ? "center" : undefined}
>
{app.name}
@@ -68,10 +99,10 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
{options.descriptionDisplayMode === "normal" && (
<Text
className="app-description"
size={isTiny ? rem(8) : "sm"}
style={{ fontSize: textSize }}
ta={isColumnLayout ? "center" : undefined}
c="dimmed"
lineClamp={4}
lineClamp={descriptionLineClamp}
>
{app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
@@ -88,9 +119,11 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
alt={app.name}
className={combineClasses(classes.appIcon, "app-icon")}
style={{
flex: isColumnLayout ? "1 0 25%" : "0 0 50%",
height: "100%",
width: "100%",
minWidth: "20%",
minWidth: 0,
minHeight: 0,
maxWidth: isColumnLayout ? undefined : "50%",
}}
/>
@@ -7,15 +7,24 @@ import { getIconUrl } from "@homarr/definitions";
import { useByteFormatter } from "@homarr/settings";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
export default function ArchiveTeamWarriorWidget({
integrationIds,
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"archiveTeamWarrior">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const integrationId = integrationIds[0];
if (!integrationId) {
@@ -8,6 +8,7 @@ import { formatDuration } from "@homarr/common";
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
import { useByteFormatter } from "@homarr/settings";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
@@ -42,12 +43,12 @@ export default function AudioStatsWidget({
const currentStreams = streamResults ?? [];
if (!response) return <WidgetEmptyState />;
let responsiveWidth = width;
let responsiveHeight = height;
if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode,
});
const summary = (
<AudioStatsContent
+11 -2
View File
@@ -9,6 +9,7 @@ import { getIntegrationName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
@@ -58,10 +59,18 @@ const iconSizeByWidth = [
export default function BazarrWidget({
integrationIds,
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"bazarr">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.bazarr");
const { data: badges, error } = clientApi.widget.bazarr.getBadges.useQuery(
{ integrationId: integrationIds[0] ?? "" },
@@ -37,6 +37,7 @@ import { useByteFormatter } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetDisplayScale, getWidgetLayoutSize } from "../common/widget-layout-size";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
@@ -456,10 +457,17 @@ export default function BeszelSystemGridWidget({
options,
integrationIds,
isEditMode,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"beszelSystemGrid">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.beszel");
const tCommon = useI18n("common");
const board = useRequiredBoard();
@@ -520,7 +528,9 @@ export default function BeszelSystemGridWidget({
style={{
display: "grid",
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${minimumCellHeight}px)` : `repeat(${rows}, 1fr)`,
gridTemplateRows: scrollEnabled
? `repeat(${rows}, ${minimumCellHeight / getWidgetDisplayScale({ displayScale, displayMode })}px)`
: `repeat(${rows}, 1fr)`,
gap: size.gap,
overflow: scrollEnabled ? "auto" : "hidden",
}}
@@ -13,6 +13,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
@@ -33,6 +34,7 @@ export default function BeszelSystemStatsWidget({
width,
height,
displayScale = 1,
displayMode,
boardId,
itemId,
setOptions,
@@ -71,12 +73,12 @@ export default function BeszelSystemStatsWidget({
{ value: "1w", label: t("period.oneWeek") },
{ value: "30d", label: t("period.thirtyDays") },
];
let responsiveWidth = width;
let responsiveHeight = height;
if (Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode,
});
useWidgetRuntimeQueries(
widgetRuntimeRef,
selectedSystem && options.timePeriod !== "1m"
@@ -27,6 +27,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useByteFormatter } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { HomarrDataTable } from "../common/homarr-data-table";
import { getUsableWidgetQueryData } from "../common/query-state";
@@ -112,12 +113,16 @@ export default function BeszelSystemTableWidget({
options,
integrationIds,
isEditMode,
width,
width: logicalWidth,
boardId,
itemId,
setOptions,
displayMode,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"beszelSystemTable">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.beszelSystemTable");
const tBeszel = useI18n("widget.beszel");
const tCommon = useI18n("common");
@@ -25,6 +25,7 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
@@ -68,12 +69,12 @@ export default function BookmarksWidget({
});
const data = [...configuredItems, ...legacyItems];
let responsiveWidth = width;
let responsiveHeight = height;
if (!advanced && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode,
});
const contentHeight = Math.max(0, responsiveHeight - (options.title.length > 0 ? 36 : 0));
let layoutWidth = responsiveWidth;
if (options.layout === "gridHorizontal") layoutWidth = width;
+20 -2
View File
@@ -13,6 +13,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { useSettings } from "@homarr/settings";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import actionTargetClasses from "../common/action-target.module.css";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
@@ -68,7 +69,15 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
setMonth: (date: Date) => void;
}
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => {
const FetchCalendar = ({
month,
setMonth,
isEditMode,
integrationIds,
options,
displayMode,
displayScale,
}: FetchCalendarProps) => {
const input = {
integrationIds,
month: month.getMonth() + 1,
@@ -111,11 +120,13 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
setMonth={setMonth}
options={options}
displayMode={displayMode}
displayScale={displayScale}
/>
);
};
interface CalendarBaseProps {
displayScale?: number;
isEditMode: boolean;
events: CalendarEventWithSource[];
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
@@ -135,13 +146,20 @@ const CalendarBase = ({
setMonth,
options,
displayMode,
displayScale,
}: CalendarBaseProps) => {
const locale = useCurrentIntlLocale();
const { firstDayOfWeek } = useSettings();
const board = useRequiredBoard();
const mantineTheme = useMantineTheme();
const actualItemRadius = mantineTheme.radius[board.itemRadius];
const { ref, width, height } = useElementSize();
const { ref, width: logicalWidth, height: logicalHeight } = useElementSize();
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const isSmall = width < 256;
const normalizedEvents = useMemo(
+14 -1
View File
@@ -4,6 +4,7 @@ import { Box, Center, Stack, Text, Title } from "@mantine/core";
import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { AdvancedClockView } from "./advanced-view";
import { clockTimeFormatShowsSeconds, resolveClockTimeFormat } from "./format";
@@ -13,7 +14,19 @@ import { getResolvedLocalTimeZone, isTimeZoneSupported } from "./world-clock";
dayjs.extend(advancedFormat);
export default function ClockWidget({ options, width, height, displayMode }: WidgetComponentProps<"clock">) {
export default function ClockWidget({
options,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"clock">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const isAdvanced = displayMode === "advanced";
const showSeconds = clockTimeFormatShowsSeconds(options.customTimeFormat);
const time = useCurrentTime({ showSeconds });
@@ -0,0 +1,22 @@
interface WidgetDisplay {
displayScale?: number;
displayMode?: "compact" | "advanced";
}
export const getWidgetDisplayScale = ({ displayScale = 1, displayMode }: WidgetDisplay): number => {
// Advanced surfaces are outside the scaled board, even when their source tile is not.
if (displayMode === "advanced" || !Number.isFinite(displayScale) || displayScale <= 0) return 1;
return displayScale;
};
/** Visible dimensions for density, breakpoints, and content budgets.
* Keep the original logical dimensions for canvas/SVG geometry and CSS pixel sizes.
*/
export const getWidgetLayoutSize = ({
width,
height,
...display
}: WidgetDisplay & { width: number; height: number }) => {
const scale = getWidgetDisplayScale(display);
return { width: width * scale, height: height * scale };
};
+6 -1
View File
@@ -5,6 +5,7 @@ import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
@@ -16,9 +17,13 @@ import type { InstanceData } from "./types";
export default function CoolifyWidget({
options,
integrationIds,
width,
width: logicalWidth,
displayMode,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"coolify">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.coolify");
if (integrationIds.length === 0) {
+2
View File
@@ -175,8 +175,10 @@ export type WidgetComponentProps<TKind extends WidgetKind> = WidgetProps<TKind>
isEditMode: boolean;
displayMode?: "compact" | "advanced";
setOptions: ({ newOptions }: { newOptions: Partial<inferOptionsFromCreator<WidgetOptionsRecordOf<TKind>>> }) => void;
/** Logical pixels for rendering geometry. Use getWidgetLayoutSize for responsive decisions. */
width: number;
height: number;
/** Board scale; advanced surfaces render outside the board at scale 1. */
displayScale?: number;
widgetRuntimeRef?: WidgetRuntimeRef;
widgetStateRef?: React.MutableRefObject<Record<string, unknown> | null>;
@@ -38,6 +38,7 @@ import type { WidgetComponentProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import actionTargetClasses from "../../common/action-target.module.css";
import classes from "./component.module.css";
import TimerPopover from "./timer-popover";
@@ -58,9 +59,12 @@ export default function DnsHoleControlsWidget({
options,
integrationIds,
isEditMode,
width,
width: logicalWidth,
displayMode,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<typeof widgetKind>) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const board = useRequiredBoard();
// DnsHole integrations with interaction permissions
const integrationsWithInteractions = useIntegrationsWithInteractAccess()
@@ -29,6 +29,7 @@ import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { getWidgetDisplayScale, getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
@@ -38,9 +39,18 @@ import { WidgetQueryLoadingState } from "../../common/query-state-indicator";
export default function DnsHoleSummaryWidget({
options,
integrationIds,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
}: WidgetComponentProps<typeof widgetKind>) {
const scale = getWidgetDisplayScale({ displayScale, displayMode });
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({
integrationIds,
});
@@ -67,7 +77,14 @@ export default function DnsHoleSummaryWidget({
<SimpleGrid cols={2} spacing="xs" p="xs" {...layoutProps} style={{ ...layoutProps.style, flex: 1, minHeight: 0 }}>
{data.length > 0 ? (
stats.map((item) => (
<StatCard key={item.color} item={item} usePiHoleColors={options.usePiHoleColors} data={data} t={t} />
<StatCard
displayScale={scale}
key={item.color}
item={item}
usePiHoleColors={options.usePiHoleColors}
data={data}
t={t}
/>
))
) : (
<Stack
@@ -171,13 +188,15 @@ interface StatItem {
}
interface StatCardProps {
displayScale: number;
item: StatItem;
data: DnsHoleSummary[];
usePiHoleColors: boolean;
t: TranslationFunction;
}
const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
const { ref, height, width } = useElementSize();
const StatCard = ({ item, data, usePiHoleColors, t, displayScale }: StatCardProps) => {
const { ref, height: logicalHeight, width: logicalWidth } = useElementSize();
const { width, height } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale });
const isLong = width > height + 20;
const canStackText = height > 32;
const hideLabel = (height <= 32 && width <= 256) || (height <= 64 && width <= 92);
+6 -1
View File
@@ -46,6 +46,7 @@ import { useByteFormatter } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import actionTargetClasses from "../common/action-target.module.css";
@@ -241,13 +242,17 @@ const useContainerAction = (action: ContainerAction) => {
export default function DockerWidget({
options,
width,
width: logicalWidth,
isEditMode,
displayMode,
boardId,
itemId,
setOptions,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"dockerContainers">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("docker");
const tCommon = useI18n("common");
const tWidget = useI18n("widget.dockerContainers");
+11 -2
View File
@@ -53,6 +53,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useByteFormatter } from "@homarr/settings";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
@@ -302,10 +303,18 @@ export default function DownloadClientsWidget({
setOptions,
boardId,
itemId,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"downloads">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const { formatByteRate, formatBytes, formatBytesPair } = useByteFormatter();
const board = useOptionalBoard();
const { data: session } = useSession();
+11 -2
View File
@@ -25,6 +25,7 @@ import type { FirewallInterfacesSummary } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { calculateBandwidth } from "./bandwidth";
import { FirewallMenu } from "./firewall-menu";
@@ -60,11 +61,19 @@ export const hasFirewallPartialFailure = (firewallId: string, queries: readonly
export default function FirewallWidget({
integrationIds,
width,
height,
width: logicalWidth,
height: logicalHeight,
itemId,
displayMode,
displayScale,
}: WidgetComponentProps<"firewall">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const [selectedFirewall, setSelectedFirewall] = useState("");
const isAdvanced = displayMode === "advanced";
const isTiny = !isAdvanced && (width < 256 || height < 180);
@@ -8,6 +8,7 @@ import type { Resource } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import { WidgetEmptyState } from "../../common/empty-state";
import { getUsableWidgetQueryData } from "../../common/query-state";
import type { WidgetComponentProps } from "../../definition";
@@ -38,6 +39,8 @@ export const ClusterHealthMonitoring = ({
integrationId,
options,
width,
height,
displayScale,
displayMode,
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
const t = useI18n("widget.healthMonitoring");
@@ -68,7 +71,8 @@ export const ClusterHealthMonitoring = ({
const cpuPercent = maxCpu ? (usedCpu / maxCpu) * 100 : 0;
const memPercent = maxMem ? (usedMem / maxMem) * 100 : 0;
const isAdvanced = displayMode === "advanced";
const isTiny = displayMode !== "advanced" && width < 256;
const { width: responsiveWidth } = getWidgetLayoutSize({ width, height, displayScale, displayMode });
const isTiny = displayMode !== "advanced" && responsiveWidth < 256;
return (
<Stack h={isAdvanced ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"} pos="relative">
{(isAdvanced || options.showUptime) && !isTiny && (
@@ -5,6 +5,7 @@ import { IconCpu } from "@tabler/icons-react";
import { zoomCompensatedSize } from "@homarr/ui";
import { progressColor } from "../system-health";
import classes from "../system-health.module.css";
export const CpuRing = ({
cpuUtilization,
@@ -17,6 +18,7 @@ export const CpuRing = ({
}) => {
return (
<GaugeChart
classNames={{ label: classes.gaugeLabel }}
className="health-monitoring-cpu"
aria-label={ariaLabel}
roundCaps
@@ -5,6 +5,7 @@ import { IconCpu } from "@tabler/icons-react";
import { zoomCompensatedSize } from "@homarr/ui";
import { progressColor } from "../system-health";
import classes from "../system-health.module.css";
const CPU_TEMPERATURE_MIN_CELSIUS = 0;
const CPU_TEMPERATURE_MAX_CELSIUS = 100;
@@ -31,6 +32,7 @@ export const CpuTempRing = ({
return (
<GaugeChart
classNames={{ label: classes.gaugeLabel }}
className="health-monitoring-cpu-temperature"
aria-label={ariaLabel}
roundCaps
@@ -5,6 +5,7 @@ import { IconDeviceDesktop } from "@tabler/icons-react";
import { zoomCompensatedSize } from "@homarr/ui";
import { progressColor } from "../system-health";
import classes from "../system-health.module.css";
interface GpuRingProps {
gpu: {
@@ -43,6 +44,7 @@ export const GpuRing = ({ gpu, isTiny, fahrenheit, ariaLabel }: GpuRingProps) =>
multiline
>
<GaugeChart
classNames={{ label: classes.gaugeLabel }}
className={`health-monitoring-gpu health-monitoring-gpu-${gpu.gpuId}`}
aria-label={ariaLabel}
roundCaps
@@ -6,6 +6,7 @@ import { useByteFormatter } from "@homarr/settings";
import { zoomCompensatedSize } from "@homarr/ui";
import { progressColor } from "../system-health";
import classes from "../system-health.module.css";
export const MemoryRing = ({
available,
@@ -24,6 +25,7 @@ export const MemoryRing = ({
return (
<Tooltip label={`${memoryUsage.memUsed.percent}%`}>
<GaugeChart
classNames={{ label: classes.gaugeLabel }}
className="health-monitoring-memory"
aria-label={ariaLabel}
roundCaps
@@ -33,3 +33,12 @@
transition: none;
}
}
/* Gauge labels live inside an SVG foreignObject. The SVG already scales its
* coordinate system to the compensated gauge size; compensating again makes
* the label grow as the board shrinks. */
.gaugeLabel {
--mantine-scale: 1;
--board-canvas-ui-scale: 1;
--mantine-font-size-xs: 0.75rem;
}
@@ -42,6 +42,7 @@ import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
@@ -61,6 +62,8 @@ export const SystemHealthMonitoring = ({
options,
integrationIds,
width,
height,
displayScale = 1,
displayMode,
withScrollArea = true,
}: WidgetComponentProps<"healthMonitoring"> & { withScrollArea?: boolean }) => {
@@ -75,7 +78,13 @@ export const SystemHealthMonitoring = ({
const { formatBytes, formatBytesPair } = useByteFormatter();
const isAdvanced = displayMode === "advanced";
const isTiny = !isAdvanced && width < 256;
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode,
});
const isTiny = !isAdvanced && (responsiveWidth < 256 || responsiveHeight < 160);
const showCpu = isAdvanced || options.cpu;
const showMemory = isAdvanced || options.memory;
const showGpu = isAdvanced || options.gpu;
@@ -27,6 +27,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { iconSizes } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeActions } from "../runtime-hooks";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
@@ -45,12 +46,20 @@ const statusPresentation = {
export default function IndexerManagerWidget({
options,
integrationIds,
width,
height,
width: logicalWidth,
height: logicalHeight,
isEditMode,
widgetRuntimeRef,
displayMode,
displayScale,
}: WidgetComponentProps<"indexerManager">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.indexerManager");
const tCommon = useI18n("common");
const indexersQuery = clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds });
+6 -4
View File
@@ -20,11 +20,11 @@ import { formatBytes, formatNumber } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationSelectedError } from "../errors/no-integration-selected";
// Board grid: a widget N cells wide is N*200 + (N-1)*12 px (1→200, 2→412, 3→624, 4→836).
// Width drives how much info fits side-by-side; height only matters for the
// Visible width drives how much info fits side-by-side; height only matters for the
// single-row (short) case where the widget must drop down to essentials.
const isNarrowWidth = (width: number) => width < 300; // 1 cell (200px)
const isWideWidth = (width: number) => width >= 550; // 3+ cells (624px+)
@@ -43,9 +43,11 @@ export default function LlamacppWidget({
options,
width,
height,
displayScale,
displayMode = "compact",
}: WidgetComponentProps<"llamacpp">) {
const integrationId = integrationIds[0];
const layoutSize = getWidgetLayoutSize({ width, height, displayScale, displayMode });
if (!integrationId) {
throw new NoIntegrationSelectedError();
}
@@ -54,8 +56,8 @@ export default function LlamacppWidget({
<LlamacppContent
integrationId={integrationId}
options={options}
width={width}
height={height}
width={layoutSize.width}
height={layoutSize.height}
displayMode={displayMode}
/>
);
@@ -26,6 +26,7 @@ import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/typ
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
@@ -66,10 +67,13 @@ const getMediaMissingDensity = (contentWidth: number, columns: number, isThin: b
export default function MediaMissingWidget({
integrationIds,
options,
width,
width: logicalWidth,
displayMode,
widgetRuntimeRef,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"mediaMissing">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.mediaMissing");
const isAdvanced = displayMode === "advanced";
const showMissing = isAdvanced || options.showMissing;
@@ -13,6 +13,7 @@ import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import { WidgetEmptyState } from "../../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
@@ -28,8 +29,13 @@ export default function MediaServerWidget({
integrationIds,
isEditMode,
options,
width,
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
}: WidgetComponentProps<"mediaRequests-requestList">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.mediaRequests-requestList");
const interactIntegrationIds = new Set(
useIntegrationsWithInteractAccess()
@@ -21,6 +21,7 @@ import type { RequestStats } from "@homarr/integrations/types";
import { openMediaRequestSearch } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import { WidgetEmptyState } from "../../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
@@ -38,9 +39,18 @@ const JELLYSEERR_COLOR = "#6677CC";
export default function MediaServerWidget({
integrationIds,
isEditMode,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
}: WidgetComponentProps<"mediaRequests-requestStats">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.mediaRequests-requestStats");
const tCommon = useI18n("common");
const requestStatsQuery = clientApi.widget.mediaRequests.getStats.useQuery({ integrationIds });
@@ -38,6 +38,7 @@ import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
@@ -169,10 +170,13 @@ function StreamTableHeader({
export default function MediaServerWidget({
options,
integrationIds,
width,
width: logicalWidth,
isEditMode,
displayMode,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"mediaServer">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const currentStreamsQuery = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
integrationIds,
showOnlyPlaying: options.showOnlyPlaying,
@@ -10,6 +10,7 @@ import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
@@ -51,11 +52,19 @@ const getTranscodingWidthLayout = (width: number) =>
export default function MediaTranscodingWidget({
integrationIds,
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
widgetRuntimeRef,
displayScale,
}: WidgetComponentProps<"mediaTranscoding">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const isAdvanced = displayMode === "advanced";
const queuePageSize = getQueuePageSize(height, isAdvanced);
const [queuePagination, setQueuePagination] = useState<QueuePaginationState>(() => ({
@@ -9,14 +9,22 @@ import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { WidgetEmptyState } from "../../common/empty-state";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { WidgetComponentProps } from "../../definition";
export default function MinecraftServerStatusWidget({
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"minecraftServerStatus">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const { data: result, isPending, error } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
const t = useI18n("widget.minecraftServerStatus");
const tCommon = useI18n("common");
@@ -5,6 +5,7 @@ import { Badge, Box, Card, Center, Group, ScrollArea, SimpleGrid, Stack, Text }
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { WidgetComponentProps } from "../../definition";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../../common/query-state";
@@ -41,12 +42,12 @@ export default function NetworkControllerNetworkStatusWidget({
const countWifiUsers = summaries.reduce((sum, { summary }) => sum + summary.wifi.users, 0);
const countLanGuests = summaries.reduce((sum, { summary }) => sum + summary.lan.guests, 0);
const countLanUsers = summaries.reduce((sum, { summary }) => sum + summary.lan.users, 0);
let responsiveWidth = width;
let responsiveHeight = height;
if (!isAdvanced && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode,
});
const layout = getNetworkControllerStatusLayout({
width: responsiveWidth,
height: responsiveHeight,
+6 -1
View File
@@ -58,6 +58,7 @@ import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { InlineConfirmActionIcon } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import actionTargetClasses from "../common/action-target.module.css";
import { createReadOnlyTaskItemTransaction, ReadOnlyTaskItem } from "./read-only-task-item";
@@ -88,9 +89,13 @@ export function Notebook({
isEditMode,
boardId,
itemId,
height,
height: logicalHeight,
displayMode = "compact",
width: logicalWidth,
displayScale,
}: WidgetComponentProps<"notebook">) {
const { height } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const [content, setContent] = useState(options.content);
const previousContentRef = useRef(options.content);
const [saveError, setSaveError] = useState<string | null>(null);
@@ -9,6 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { getCompactStatLayout } from "../common/compact-stat-layout";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
@@ -149,12 +150,12 @@ export default function PaperlessNgxWidget({
.map(([, statKey]) => statKey)
.filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey)));
let responsiveWidth = width;
let responsiveHeight = height;
if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode,
});
const advanced = displayMode === "advanced";
const layout = getCompactStatLayout({
width: responsiveWidth,
+11 -2
View File
@@ -20,6 +20,7 @@ import { useI18n } from "@homarr/translation/client";
import { iconSizes, zoomCompensatedSize } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
@@ -70,10 +71,18 @@ const getLayoutMode = (width: number, height: number): LayoutMode => {
export default function PatchMonWidget({
integrationIds,
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"patchmon">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.patchmon");
const integrationId = integrationIds[0] ?? "";
const statsQuery = clientApi.widget.patchmon.getStats.useQuery({ integrationId }, { staleTime: 60 * 1000 });
+14 -1
View File
@@ -9,6 +9,7 @@ import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { WidgetEmptyState } from "../common/empty-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
@@ -43,7 +44,19 @@ const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => {
};
};
export default function RssFeed({ options, width, height, displayMode }: WidgetComponentProps<"rssFeed">) {
export default function RssFeed({
options,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"rssFeed">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const feed = useLiveFeed({
urls: options.feedUrls,
maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100,
@@ -10,6 +10,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { WidgetComponentProps } from "../../definition";
import { isSmartHomeTiny } from "../layout";
import { getEntityStateLabel } from "./state";
@@ -18,10 +19,18 @@ export default function SmartHomeEntityStateWidget({
options,
integrationIds,
isEditMode,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"smartHome-entityState">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.smartHome-entityState");
const tWidgetCommon = useI18n("widget.common");
const tCommon = useI18n("common");
@@ -21,6 +21,7 @@ import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../../common/widget-layout-size";
import type { WidgetComponentProps } from "../../definition";
import { isSmartHomeTiny } from "../layout";
@@ -28,10 +29,17 @@ export default function SmartHomeTriggerAutomationWidget({
options,
integrationIds,
isEditMode,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"smartHome-executeAutomation">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const integrationId = integrationIds[0];
const canInteract = useIntegrationsWithInteractAccess().some(({ id }) => id === integrationId);
const [lastExecutedAt, setLastExecutedAt] = React.useState<Date | null>(null);
@@ -14,10 +14,12 @@ export function AveragesSection({
stats,
width = 500,
compactSurface = false,
displayScale,
}: {
stats: SpeedtestTrackerStats;
width?: number;
compactSurface?: boolean;
displayScale?: number;
}) {
const t = useI18n("widget.speedtestTracker");
@@ -28,6 +30,7 @@ export function AveragesSection({
</SectionLabel>
<SimpleGrid cols={width < 220 ? 1 : 3} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}>
<SpeedStatCard
displayScale={displayScale}
icon={IconArrowDown}
color="blue"
value={formatStatsSpeed(stats.download)}
@@ -36,6 +39,7 @@ export function AveragesSection({
legacySurface={compactSurface}
/>
<SpeedStatCard
displayScale={displayScale}
icon={IconArrowUp}
color="teal"
value={formatStatsSpeed(stats.upload)}
@@ -44,6 +48,7 @@ export function AveragesSection({
legacySurface={compactSurface}
/>
<SpeedStatCard
displayScale={displayScale}
icon={IconWaveSine}
color="orange"
value={`${stats.ping.avg.toFixed(1)} ms`}
@@ -6,6 +6,7 @@ import { Box, Group, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { getWidgetDisplayScale, getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
@@ -20,8 +21,13 @@ const emptyDashboardData = [] as const;
export default function SpeedtestTrackerWidget({
options,
integrationIds,
width,
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
}: WidgetComponentProps<"speedtestTracker">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.speedtestTracker");
const dashboardQuery = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
const dashboardData = getUsableWidgetQueryData(dashboardQuery) ?? emptyDashboardData;
@@ -44,10 +50,20 @@ export default function SpeedtestTrackerWidget({
const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults;
const latest = showLatestResult && combined.latestResult && (
<LatestResultSection result={combined.latestResult} width={width} compactSurface />
<LatestResultSection
result={combined.latestResult}
width={width}
displayScale={getWidgetDisplayScale({ displayScale, displayMode })}
compactSurface
/>
);
const averages = showStats && combined.stats && (
<AveragesSection stats={combined.stats} width={width} compactSurface />
<AveragesSection
stats={combined.stats}
width={width}
displayScale={getWidgetDisplayScale({ displayScale, displayMode })}
compactSurface
/>
);
return (
@@ -14,10 +14,12 @@ export function LatestResultSection({
result,
width = 500,
compactSurface = false,
displayScale,
}: {
result: SpeedtestTrackerResult;
width?: number;
compactSurface?: boolean;
displayScale?: number;
}) {
const t = useI18n("widget.speedtestTracker");
const locale = useCurrentIntlLocale();
@@ -40,6 +42,7 @@ export function LatestResultSection({
</Group>
<SimpleGrid cols={cols} spacing="xs" style={{ flex: 1, gridTemplateRows: "1fr" }}>
<SpeedStatCard
displayScale={displayScale}
icon={IconArrowDown}
color="blue"
value={formatResultSpeed(result, "download")}
@@ -47,6 +50,7 @@ export function LatestResultSection({
legacySurface={compactSurface}
/>
<SpeedStatCard
displayScale={displayScale}
icon={IconArrowUp}
color="teal"
value={formatResultSpeed(result, "upload")}
@@ -54,6 +58,7 @@ export function LatestResultSection({
legacySurface={compactSurface}
/>
<SpeedStatCard
displayScale={displayScale}
icon={IconWaveSine}
color="orange"
value={result.ping !== null ? `${result.ping.toFixed(1)} ms` : "—"}
@@ -62,6 +67,7 @@ export function LatestResultSection({
/>
{result.healthy !== null && (
<SpeedStatCard
displayScale={displayScale}
icon={result.healthy ? IconCircleCheck : IconCircleX}
color={result.healthy ? "green" : "red"}
value={result.healthy ? t("healthy") : t("unhealthy")}
@@ -1,5 +1,6 @@
"use client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { Card, Flex, Text, Tooltip, VisuallyHidden } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
@@ -14,6 +15,7 @@ export interface SpeedStatCardProps {
label: string;
compact?: boolean;
legacySurface?: boolean;
displayScale?: number;
}
export function SpeedStatCard({
@@ -23,8 +25,10 @@ export function SpeedStatCard({
label,
compact = false,
legacySurface = false,
displayScale,
}: SpeedStatCardProps) {
const { ref, height, width } = useElementSize<HTMLDivElement>();
const { ref, height: logicalHeight, width: logicalWidth } = useElementSize<HTMLDivElement>();
const { width, height } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale });
const board = useRequiredBoard();
const isWide = width > height + 20;
const hideLabel = height > 0 && height <= 38;
+10 -2
View File
@@ -7,6 +7,7 @@ import { IconMinus, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
@@ -71,10 +72,17 @@ export function getStockSummary(priceHistory: number[], previousClose: number):
export default function StockPriceWidget({
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode = "compact",
displayScale,
}: WidgetComponentProps<"stockPrice">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.stockPrice");
const tCommon = useI18n("common");
const locale = useCurrentIntlLocale();
@@ -12,6 +12,7 @@ export const CombinedNetworkTrafficChart = ({
hasShadow,
labelDisplayMode,
advanced = false,
displayScale,
}: {
usageOverTime: {
up: number;
@@ -20,6 +21,7 @@ export const CombinedNetworkTrafficChart = ({
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
displayScale?: number;
}) => {
const chartData = usageOverTime.map((usage, index) => ({
index,
@@ -51,6 +53,7 @@ export const CombinedNetworkTrafficChart = ({
return (
<CommonChart
displayScale={displayScale}
data={chartData}
dataKey={"index"}
series={[
@@ -1,22 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type {
CSSProperties,
PointerEvent as ReactPointerEvent,
ReactNode,
} from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from "react";
import { useState } from "react";
import type { AreaChartSeries } from "@mantine/charts";
import { AreaChart, LineChart } from "@mantine/charts";
import {
Card,
Center,
Group,
Stack,
Text,
Tooltip,
useComputedColorScheme,
useMantineTheme,
} from "@mantine/core";
import { Card, Center, Group, Stack, Text, Tooltip, useComputedColorScheme, useMantineTheme } from "@mantine/core";
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
import type { YAxisProps } from "recharts";
@@ -24,6 +11,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { zoomCompensatedSize } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { getWidgetDisplayScale } from "../../common/widget-layout-size";
import type { LabelDisplayModeOption } from "..";
export const CommonChart = ({
@@ -38,6 +26,7 @@ export const CommonChart = ({
lastValue,
chartType = "line",
advanced = false,
displayScale,
}: {
data: Record<string, any>[];
dataKey: string;
@@ -53,8 +42,11 @@ export const CommonChart = ({
lastValue?: string;
chartType?: "line" | "area";
advanced?: boolean;
displayScale?: number;
}) => {
const { ref: elementSizeRef, height } = useElementSize();
const { ref: elementSizeRef, height: logicalHeight } = useElementSize();
const height =
logicalHeight * getWidgetDisplayScale({ displayScale, displayMode: advanced ? "advanced" : "compact" });
const theme = useMantineTheme();
const colorScheme = useComputedColorScheme("light");
const board = useRequiredBoard();
@@ -62,28 +54,22 @@ export const CommonChart = ({
const ref = useMergedRef(elementSizeRef, hoverRef);
const opacity = board.opacity / 100;
let backgroundColor =
colorScheme === "dark"
? `rgba(57, 57, 57, ${opacity})`
: `rgba(246, 247, 248, ${opacity})`;
let backgroundColor = colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
const cardStyle: CSSProperties = { overflow: "hidden" };
const chartRootStyle: CSSProperties = {
padding: 5,
borderRadius: theme.radius[board.itemRadius],
};
if (advanced) {
backgroundColor =
"rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
backgroundColor = "rgb(from var(--mantine-color-primaryColor-filled) r g b / calc(var(--opacity, 1) * 0.12))";
cardStyle.border =
"1px solid rgb(from var(--mantine-color-secondaryColor-filled) r g b / calc(var(--opacity, 1) * 0.45))";
chartRootStyle.backgroundColor = backgroundColor;
}
const ChartComponent = chartType === "line" ? LineChart : AreaChart;
const showIcon =
labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
const showText =
labelDisplayMode === "text" || labelDisplayMode === "textWithIcon";
const showIcon = labelDisplayMode === "icon" || labelDisplayMode === "textWithIcon";
const showText = labelDisplayMode === "text" || labelDisplayMode === "textWithIcon";
// Track which data point the cursor is over so the tooltip can reflect that point
// instead of always showing the latest value while scrubbing across the chart.
@@ -100,20 +86,12 @@ export const CommonChart = ({
// Clamp on read rather than syncing via an effect - handles data growing/shrinking
// between renders (e.g. history resetting on integration change) without ever
// handing a stale or out-of-range index to tooltipLabel.
const clampedHoveredIndex = Math.max(
0,
Math.min(hoveredIndex, data.length - 1),
);
const clampedHoveredIndex = Math.max(0, Math.min(hoveredIndex, data.length - 1));
const resolvedTooltipLabel =
typeof tooltipLabel === "function"
? tooltipLabel(clampedHoveredIndex)
: (tooltipLabel ?? lastValue);
typeof tooltipLabel === "function" ? tooltipLabel(clampedHoveredIndex) : (tooltipLabel ?? lastValue);
return (
<Tooltip.Floating
label={resolvedTooltipLabel}
disabled={data.length <= 1 || !resolvedTooltipLabel}
>
<Tooltip.Floating label={resolvedTooltipLabel} disabled={data.length <= 1 || !resolvedTooltipLabel}>
<Card
ref={ref}
h={"100%"}
@@ -149,33 +127,19 @@ export const CommonChart = ({
</Text>
)}
{lastValue && (
<Text
c={"dimmed"}
size={height > 100 ? "md" : "xs"}
lineClamp={1}
>
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} lineClamp={1}>
{lastValue}
</Text>
)}
</Group>
)}
{advanced &&
data.length > 1 &&
height > 0 &&
height <= 40 &&
lastValue &&
!hovered && (
<Center
pos="absolute"
w="100%"
h="100%"
style={{ zIndex: 2, pointerEvents: "none" }}
>
<Text size="xs" fw={600}>
{lastValue}
</Text>
</Center>
)}
{advanced && data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && (
<Center pos="absolute" w="100%" h="100%" style={{ zIndex: 2, pointerEvents: "none" }}>
<Text size="xs" fw={600}>
{lastValue}
</Text>
</Center>
)}
{data.length <= 1 ? (
<Center pos="absolute" w="100%" h="100%">
<Stack px={"xs"} align={"center"} gap={4}>
@@ -187,12 +151,7 @@ export const CommonChart = ({
/>
)}
{showText && (
<Text
c={"dimmed"}
size={height > 100 ? "md" : "xs"}
fw={"bold"}
ta="center"
>
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"} ta="center">
{title}
</Text>
)}
@@ -10,16 +10,19 @@ export const SystemResourceCPUChart = ({
hasShadow,
labelDisplayMode,
advanced = false,
displayScale,
}: {
cpuUsageOverTime: number[];
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
displayScale?: number;
}) => {
const chartData = cpuUsageOverTime.map((usage, index) => ({ index, usage }));
return (
<CommonChart
displayScale={displayScale}
data={chartData}
dataKey={"index"}
series={[{ name: "usage", color: "blue.5" }]}
@@ -10,16 +10,19 @@ export const SystemResourceGPUChart = ({
hasShadow,
labelDisplayMode,
advanced = false,
displayScale,
}: {
gpuUsageOverTime: number[];
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
displayScale?: number;
}) => {
const chartData = gpuUsageOverTime.map((usage, index) => ({ index, usage }));
return (
<CommonChart
displayScale={displayScale}
data={chartData}
dataKey={"index"}
series={[{ name: "usage", color: "grape.5" }]}
@@ -12,12 +12,14 @@ export const SystemResourceMemoryChart = ({
hasShadow,
labelDisplayMode,
advanced = false,
displayScale,
}: {
memoryUsageOverTime: number[];
totalCapacityInBytes: number;
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
displayScale?: number;
}) => {
const chartData = memoryUsageOverTime.map((usage, index) => ({
index,
@@ -45,6 +47,7 @@ export const SystemResourceMemoryChart = ({
return (
<CommonChart
displayScale={displayScale}
data={chartData}
dataKey={"index"}
series={[{ name: "usage", color: "red.6" }]}
@@ -12,12 +12,14 @@ export const NetworkTrafficChart = ({
hasShadow,
labelDisplayMode,
advanced = false,
displayScale,
}: {
usageOverTime: number[];
isUp: boolean;
hasShadow: boolean;
labelDisplayMode: LabelDisplayModeOption;
advanced?: boolean;
displayScale?: number;
}) => {
const chartData = usageOverTime.map((usage, index) => ({ index, usage }));
const t = useI18n("widget.systemResources.card");
@@ -29,6 +31,7 @@ export const NetworkTrafficChart = ({
return (
<CommonChart
displayScale={displayScale}
data={chartData}
dataKey={"index"}
series={[{ name: "usage", color: "yellow.5" }]}
@@ -5,6 +5,7 @@ import { Box, Center, Group, Loader, ScrollArea, SimpleGrid, Stack, Text } from
import { clientApi } from "@homarr/api/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
@@ -89,6 +90,7 @@ export default function SystemResources({
options,
width,
height,
displayScale,
displayMode,
}: WidgetComponentProps<"systemResources">) {
const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds });
@@ -150,6 +152,7 @@ export default function SystemResources({
width={availableWidth}
height={availableHeight}
isAdvanced={isAdvanced}
displayScale={displayScale}
showTitle={isAdvanced || data.length > 1}
/>
);
@@ -211,6 +214,7 @@ interface SystemChartsProps {
height: number;
isAdvanced: boolean;
showTitle: boolean;
displayScale?: number;
}
const SystemCharts = ({
@@ -223,13 +227,20 @@ const SystemCharts = ({
height,
isAdvanced,
showTitle,
displayScale,
}: SystemChartsProps) => {
const { width: responsiveWidth, height: responsiveHeight } = getWidgetLayoutSize({
width,
height,
displayScale,
displayMode: isAdvanced ? "advanced" : "compact",
});
const networkItems = getNetworkHistory(items);
const visibleCharts = getVisibleSystemCharts({
configuredCharts: options.visibleCharts,
hasGpu,
hasNetwork: networkItems.length > 0,
height,
height: responsiveHeight,
isAdvanced,
});
const showNetwork = visibleCharts.includes("network");
@@ -270,6 +281,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
displayScale={displayScale}
/>
</Box>
)}
@@ -281,6 +293,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
displayScale={displayScale}
/>
</Box>
)}
@@ -291,11 +304,12 @@ const SystemCharts = ({
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
displayScale={displayScale}
/>
</Box>
)}
{showNetwork &&
(width >= 300 ? (
(responsiveWidth >= 300 ? (
<Group h="100%" gap="xs" grow wrap="nowrap">
<NetworkTrafficChart
usageOverTime={networkItems.map((network) => network.down)}
@@ -303,6 +317,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
displayScale={displayScale}
/>
<NetworkTrafficChart
usageOverTime={networkItems.map((network) => network.up)}
@@ -310,6 +325,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
displayScale={displayScale}
/>
</Group>
) : (
@@ -319,6 +335,7 @@ const SystemCharts = ({
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
advanced={isAdvanced}
displayScale={displayScale}
/>
</Box>
))}
+11 -2
View File
@@ -29,6 +29,7 @@ import {
requestBrowserNotificationPermission,
showBrowserNotification,
} from "../common/browser-alert";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { useLocalWidgetState } from "../common/use-local-widget-state";
import { useWidgetNow } from "../common/use-widget-now";
import type { WidgetComponentProps } from "../definition";
@@ -52,12 +53,20 @@ const completionSoundIntervalMs = 300;
export default function TimerWidget({
boardId,
displayMode,
height,
height: logicalHeight,
isEditMode,
itemId,
options,
width,
width: logicalWidth,
displayScale,
}: WidgetComponentProps<"timer">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const now = useWidgetNow("second");
const t = useI18n("widget.timer");
const reducedMotion = useReducedMotion();
+11 -2
View File
@@ -4,6 +4,7 @@ import { IconAlertTriangle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { DynamicSelectOption } from "../_inputs/widget-dynamic-select-input";
import { formatLocalizedTime } from "../common/locale";
import type { WidgetComponentProps } from "../definition";
@@ -11,10 +12,18 @@ import type { WidgetComponentProps } from "../definition";
export default function TimetableWidget({
options,
displayMode = "compact",
width,
height,
width: logicalWidth,
height: logicalHeight,
itemId,
displayScale,
}: WidgetComponentProps<"timetable">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.timetable");
if (!options.station) {
+6 -1
View File
@@ -6,6 +6,7 @@ import { clientApi } from "@homarr/api/client";
import type { TracearrDashboardData } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
@@ -36,9 +37,13 @@ export const getTracearrSectionVisibility = (options: TracearrSectionOptions, is
export default function TracearrWidget({
options,
integrationIds,
width,
width: logicalWidth,
displayMode = "compact",
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"tracearr">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
if (integrationIds.length === 0) {
throw new NoIntegrationDataError();
}
+5 -1
View File
@@ -16,6 +16,7 @@ import type { TraefikDashboardData, TraefikProtocolSummary, TraefikResourceSumma
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
@@ -103,9 +104,12 @@ export default function TraefikWidget(props: WidgetComponentProps<"traefik">) {
function TraefikWidgetContent({
integrationIds,
options,
width,
width: logicalWidth,
displayMode = "compact",
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"traefik">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const t = useI18n("widget.traefik");
const tCommon = useI18n("common");
const dashboardQuery = clientApi.widget.traefik.getDashboard.useQuery({ integrationIds });
+10 -2
View File
@@ -6,6 +6,7 @@ import { getQueryKey } from "@trpc/react-query";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeQueries } from "../runtime-hooks";
import { UmamiContent } from "./umami-content";
@@ -13,11 +14,18 @@ import { UmamiContent } from "./umami-content";
export default function UmamiWidget({
options,
integrationIds,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode = "compact",
widgetRuntimeRef,
displayScale,
}: WidgetComponentProps<"umami">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const integrationId = integrationIds[0] ?? "";
const commonTopInput = {
integrationId,
+13 -6
View File
@@ -20,6 +20,7 @@ import type { UpsStatus, UpsSummary } from "@homarr/integrations/types";
import type { ScopedTranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
@@ -34,20 +35,26 @@ const statusColors: Record<UpsStatus, string> = {
unknown: "gray",
};
const neutralSurfaceBackground =
"rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.12))";
const neutralSurfaceBorder =
"rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
const neutralSurfaceBackground = "rgb(from var(--mantine-color-default-hover) r g b / calc(var(--opacity, 1) * 0.12))";
const neutralSurfaceBorder = "rgb(from var(--mantine-color-default-border) r g b / calc(var(--opacity, 1) * 0.45))";
type UpsLayout = "mini" | "compact" | "full";
export default function UpsWidget({
options,
integrationIds,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode = "compact",
displayScale,
}: WidgetComponentProps<"ups">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
if (integrationIds.length === 0) {
throw new NoIntegrationSelectedError();
}
+10 -2
View File
@@ -9,6 +9,7 @@ import { formatNumber } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
@@ -82,10 +83,17 @@ const heroVariantByRing = {
export default function UptimeKumaWidget({
integrationIds,
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode = "compact",
displayScale,
}: WidgetComponentProps<"uptimeKuma">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
if (integrationIds.length === 0) {
throw new NoIntegrationDataError();
}
+15 -1
View File
@@ -5,11 +5,25 @@ import { Center, Flex, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core
import { clientApi } from "@homarr/api/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { VpnIntegrationCard } from "./vpn-card";
export default function VpnWidget({ options, integrationIds, width, height }: WidgetComponentProps<"vpn">) {
export default function VpnWidget({
options,
integrationIds,
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
}: WidgetComponentProps<"vpn">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const summariesQuery = clientApi.widget.vpn.getSummaries.useQuery({
...options,
integrationIds,
+10 -2
View File
@@ -5,6 +5,7 @@ import { Box, Center, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
@@ -15,10 +16,17 @@ import { CompactWeather } from "./compact";
export default function WeatherWidget({
isEditMode,
options,
width,
height,
width: logicalWidth,
height: logicalHeight,
displayMode,
displayScale,
}: WidgetComponentProps<"weather">) {
const { width, height } = getWidgetLayoutSize({
width: logicalWidth,
height: logicalHeight,
displayScale,
displayMode,
});
const t = useI18n("widget.weather");
const input = {
latitude: options.location.latitude,
+11 -8
View File
@@ -11,12 +11,21 @@ import type { WudContainerUpdate } from "@homarr/integrations";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getWidgetLayoutSize } from "../common/widget-layout-size";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
export default function WudWidget({ integrationIds, options, width, displayMode }: WidgetComponentProps<"wud">) {
export default function WudWidget({
integrationIds,
options,
width: logicalWidth,
displayMode,
height: logicalHeight,
displayScale,
}: WidgetComponentProps<"wud">) {
const { width } = getWidgetLayoutSize({ width: logicalWidth, height: logicalHeight, displayScale, displayMode });
const integrationId = integrationIds[0];
if (!integrationId) return null;
return (
@@ -208,13 +217,7 @@ const UpdateCard = ({
const showVersionTooltip = versionText !== null && !isDigestUpdate && versionText !== fullVersionText;
return (
<Card
className={combineClasses(className)}
radius={radius}
p="xs"
bg="transparent"
style={{ overflow: "visible" }}
>
<Card className={combineClasses(className)} radius={radius} p="xs" bg="transparent" style={{ overflow: "visible" }}>
<Group justify="space-between" wrap="nowrap" gap="xs" miw={0}>
<Text size="xs" fw={500} lineClamp={1} miw={0}>
{update.name}