Compare commits

...
Author SHA1 Message Date
homarr-renovate[bot] dbda477c77 chore(deps): update dependency next-auth to v5.0.0-beta.32 [security] (#6840)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-09-13 14:25:25 +00:00
homarr-renovate[bot] a5c92b114e chore(deps): update dependency hono@<4.6.5 to >=4.13.7 [security] (#6842)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-09-13 03:53:52 +00:00
homarr-renovate[bot] 58ac28d6b5 chore(deps): update dependency @auth/core to v0.41.3 [security] (#6839)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-09-13 00:05:05 +00:00
Kushie 43a57a2dad fix(firewall widget): allow content to shrink and scroll (#6818) 2026-09-12 10:49:01 +02:00
Releases Homarr 7859faaa1d chore(release): 1.77.1 [skip ci] 2026-09-11 19:22:36 +00:00
homarr-releases[bot] bff6ec735e chore(release): automatic release v1.77.1 2026-09-11 19:21:19 +00:00
Thomas 1fbe844598 fix(settings): move board access control out of the nested form (#6662) 2026-09-10 18:13:56 +02:00
Thomas 365ef7f02b Revert "feat: announce Homarr v2 beta" (#6813) 2026-09-10 16:36:54 +02:00
homarr-releases[bot] 2b1a03849d chore: update bug report template (#6798) 2026-09-06 22:31:26 +02:00
Releases Homarr 4c243bc374 chore(release): 1.77.0 [skip ci] 2026-09-06 19:31:37 +00:00
homarr-releases[bot] ecd02f73c0 chore(release): automatic release v1.77.0 2026-09-06 19:30:29 +00:00
Thomas cd23b36f3c fix(ci): use pinned pnpm for releases (#6796) 2026-09-06 21:18:30 +02:00
homarr-releases[bot] f7f29eade9 chore(release): automatic release v1.77.0 2026-09-06 18:47:01 +00:00
Thomas 32a51c1e60 feat: announce Homarr v2 beta (#6793)
* feat: announce Homarr v2 beta

* chore: refine v2 beta announcement

* feat: expand v2 beta guidance

* chore: refine v2 beta resources

* chore: simplify v2 beta modal

* chore: improve v2 beta feedback links

* fix: keep v2 modal above header

* chore: sharpen v2 beta message

* fix: clear header for v2 modal
2026-09-06 20:36:04 +02:00
homarr-crowdin[bot] da32acb4f0 chore(lang): updated translations from crowdin
* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

---------

Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-09-06 01:52:04 +00:00
homarr-update-contributors[bot] 1da13d9bb3 chore: update contributors (#6767)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-09-04 15:58:09 +00:00
homarr-crowdin[bot] cb0fec088f chore(lang): updated translations from crowdin
* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

---------

Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-09-03 02:05:21 +00:00
homarr-crowdin[bot] a2fe8dd461 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-09-01 02:36:36 +00:00
homarr-update-contributors[bot] b6dfcd69ba chore: update contributors (#6735)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-08-28 21:54:00 +00:00
homarr-crowdin[bot]andThomas a38b0ef71d chore(lang): updated translations from crowdin (#6732)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-08-28 14:07:42 +02:00
Thomas 2cf3027eeb Merge pull request #6720 from homarr-labs/docs/update-helm-docs
docs(helm): update chart installation page
2026-08-28 08:32:40 +02:00
homarr-releases[bot] fa18f6fc71 chore: update bug report template (#6724) 2026-08-28 08:32:03 +02:00
Releases Homarr 331925c197 chore(release): 1.76.2 [skip ci] 2026-08-27 20:09:22 +00:00
Thomas 0e51f745d3 Merge pull request #6723 from homarr-labs/dev
chore(release): automatic release v1.76.2
2026-08-27 22:08:05 +02:00
Thomas d6f720d0e6 Revert "feat(http): set custom User-Agent on outbound integration requests (#6606)" (#6722)
This reverts commit ddbf05524b.
2026-08-27 22:00:23 +02:00
Thomas 5324bf12d4 fix(startup): use stable internal service addresses (#6721) 2026-08-27 21:52:54 +02:00
github-actions[bot] 2922a02ecf docs(helm): update chart installation page 2026-08-27 17:09:27 +00:00
Thomas 87cdcf28e0 fix(mcp): return valid string for void tool results (#6703) 2026-08-27 11:10:37 +02:00
homarr-releases[bot] 441b181e79 chore: update bug report template (#6716)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-08-27 11:08:43 +02:00
Releases Homarr eba639b59c chore(release): 1.76.1 [skip ci] 2026-08-26 16:43:52 +00:00
Thomas Camlong da36283ad1 chore(release): retrigger release pipeline 2026-08-26 18:30:12 +02:00
Thomas Camlong c050368782 chore(release): trigger release pipeline 2026-08-26 18:16:26 +02:00
homarr-releases[bot] 82b50f0fd7 chore(release): automatic release v1.76.1 2026-08-26 15:50:55 +00:00
Thomas a0149d5894 Merge pull request #6673 from homarr-labs/investigate-6672-immich-401 2026-08-22 19:44:03 +02:00
ajnart 8579fcbfc2 test(http,immich): address CodeRabbit review
- guarantee server teardown via try/finally in both wire-level tests
- add tuple-array HeadersInit coverage to the helper suite
2026-08-22 17:21:38 +02:00
ajnart b6f3aba23e test(http,immich): prove User-Agent fix at the wire level
Add two regression tests that fail on v1.76.0 and pass with the fix:
- wire-level test: a Headers instance (the shape @immich/sdk hands over)
  must reach the remote server with all headers intact
- real-SDK e2e: getServerStatistics through fetchWithTrustedCertificatesAsync
  against a live local server must deliver x-api-key
2026-08-22 17:16:31 +02:00
ajnart 31e218295d fix(http): preserve SDK headers when injecting User-Agent
The v1.76.0 User-Agent injection object-spread `fetchOptions.headers`, which
silently drops all headers when the value is a Headers instance. The Immich
SDK passes a Headers object with the merged x-api-key header, so requests
lost their API key and returned 401 (issue #6672). Build a real Headers
instance instead, keeping case-insensitive user-agent handling.
2026-08-22 17:12:43 +02:00
Thomas aca3b5d861 fix: correct Unraid CPU, link context menus, and board resizing (#6510)
Fix Unraid logical CPU averaging, preserve native context menus for app and bookmark widgets in view mode, and prevent scrollbar-width GridStack resize oscillation.
2026-08-22 10:53:26 +02:00
homarr-crowdin[bot] a7a2f0223a chore(lang): updated translations from crowdin (#6596)
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-08-22 08:49:51 +02:00
homarr-releases[bot] 7773fdc10b chore: update bug report template (#6655) 2026-08-21 22:14:45 +02:00
Thomas 7019a1d0a4 Merge pull request #6656 from homarr-labs/docs/update-helm-docs 2026-08-21 22:14:26 +02:00
github-actions[bot] 2d535caeac docs(helm): update chart installation page 2026-08-21 17:41:09 +00:00
Releases Homarr 2013fbe80d chore(release): 1.76.0 [skip ci] 2026-08-21 17:20:50 +00:00
homarr-releases[bot] 219ef58926 chore(release): automatic release v1.76.0 2026-08-21 17:19:40 +00:00
homarr-update-contributors[bot] 97195bc463 chore: update contributors (#6649)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-08-21 12:53:40 +00:00
Danura30082 73a742154e docs: Proxmox certificate obtention instructions (#6613) 2026-08-20 18:51:58 +02:00
Afonso Jorge Ramos 993a267e0c fix(truenas): keep reporting pools without usable dataset space (#6635) 2026-08-20 15:37:10 +02:00
Thomas f1c49d1fbf fix(startup): only chown runtime paths for custom PUID/PGID (#6598) 2026-08-19 08:29:17 +02:00
Thomas 95c66c61eb fix(widgets): stop Health Monitoring system tab firing NOT_FOUND when Proxmox is mixed in (#6623) 2026-08-18 19:56:47 +02:00
Thomas ec2f8c6c2c fix(widgets): match OMV disk temperature via normalized partition names (#6615) (#6619)
OMV reports filesystem devices (sda1) without the /dev/ prefix, so the
partition-suffix normalization never applied and filesystem-to-SMART
matching returned no temperature. Strip partition suffixes from
unprefixed sd/vd, nvme and mmcblk names, and use the shared normalized
matcher in both the health monitoring and system disks widgets.
2026-08-18 19:39:27 +02:00
Thomas 05ba57c2fa chore: migrate cursor rules to portable agent skills + mantine MCP guidance
* chore: migrate cursor rules to portable agent skills and add mantine MCP guidance

Replace Cursor-only .cursor/rules/*.mdc with Agent Skills open-standard
SKILL.md files in .agents/skills/ (canonical), mirrored to .cursor/skills/
and .claude/skills/ so they work across Claude Code, Cursor, Codex, and
OpenCode. Add mantine MCP server guidance to AGENTS.md and un-ignore
.agents/skills/ in .gitignore.

* refactor: single-source agent skills at .agents/skills

Cursor, Codex, and OpenCode all read .agents/skills/ natively, so the
.cursor/skills/ mirror was redundant. Claude Code reads .claude/skills/,
which is now a symlink back to .agents/skills/ so the content lives once.
Deletes .cursor/skills/ and converts .claude/skills/ to a symlink.

* chore: ship mantine MCP server via project-scoped .mcp.json

Declares the keyless @mantine/mcp-server in .mcp.json (Claude Code
project-scope standard, committable) and documents MCP server setup and
the mantine entry in AGENTS.md.

* chore: address coderabbit security-review and markdown lint findings

Add explicit security-review guidance before exposing MCP tools and label
plain-text code fences to satisfy markdownlint MD040.
2026-08-16 16:27:51 +02:00
Thomas ddbf05524b feat(http): set custom User-Agent on outbound integration requests (#6606)
* feat(http): set custom User-Agent on outbound integration requests

Set a default 'User-Agent: Homarr/<version> (+https://homarr.dev)' header
on all outbound HTTP requests made via fetchWithTrustedCertificatesAsync,
so Homarr's integration traffic is distinguishable in reverse-proxy,
WAF, and security-monitoring logs instead of appearing as generic undici.

Closes #6604

* refactor(version): centralize Homarr version in @homarr/version package

Replace fragile deep-relative 'package.json' imports across the monorepo
with a single source of truth: the new @homarr/version leaf package reads
the version once from the root package.json and exports it as VERSION.

Consumers now import by package name incl. the new User-Agent header in
fetchWithTrustedCertificatesAsync (packages/core):
  - core, api (info router), analytics, request-handler (update checker)
This also removes all manually-maintained relative version imports.

* fix(http): normalize User-Agent injection with Headers

Object-spreading fetchOptions.headers breaks when callers pass a Headers
instance (spread yields no entries) or a tuple array (numeric keys), and is
case-sensitive. Build a Headers instance and set the default User-Agent only
when no case-insensitive User-Agent entry exists, via a shared helper in both
fetch paths.

Adds unit tests covering record, Headers, tuple-array, and casing variants.

* revert(version): undo version refactor and tests, keep single-file change

Keep the User-Agent change as a single edit to
packages/core/src/infrastructure/http/request.ts. Remove the @homarr/version
package, the consumer migrations, and the unit tests.
2026-08-16 16:09:15 +02:00
Thomas df438b4cd2 Merge pull request #6571 from homarr-labs/feat/fix-home-assistant-actions 2026-08-16 00:23:14 +02:00
Thomas a1f91eb385 Merge pull request #6567 from homarr-labs/feat/fix-truenas-free-space 2026-08-15 20:20:35 +02:00
Thomas 8d5ffb2dd9 feat(paseo): add worktree install script (#6599)
* feat(paseo): add worktree install script

* feat(paseo): inline worktree setup in paseo.json

* feat(paseo): add scripts and teardown to paseo.json

* feat(paseo): expand workspace scripts catalog

* feat(paseo): reset encryption key in worktree env
2026-08-15 12:29:52 +02:00
Thomas Camlong 56e6b23af6 test(e2e): remove flaky credentials onboarding test 2026-08-15 11:05:06 +02:00
Thomas Camlong 5488bf4d18 fix(truenas): use lowercase in operator for dataset query 2026-08-15 01:33:20 +02:00
homarr-crowdin[bot] 621ea07549 chore(lang): updated translations from crowdin (#6553) 2026-08-14 20:39:02 +02:00
homarr-update-contributors[bot] f64c01456b chore: update contributors (#6588) 2026-08-14 20:38:42 +02:00
homarr-releases[bot] 56198c3f02 chore: update bug report template (#6591) 2026-08-14 20:38:26 +02:00
Thomas ca1bb6f341 Merge pull request #6592 from homarr-labs/docs/update-helm-docs
docs(helm): update chart installation page
2026-08-14 20:38:10 +02:00
github-actions[bot] bf6b6663cd docs(helm): update chart installation page 2026-08-14 18:25:32 +00:00
Releases Homarr 5c2d86767f chore(release): 1.75.0 [skip ci] 2026-08-14 17:45:24 +00:00
homarr-releases[bot] 7c75642d4d chore(release): automatic release v1.75.0 2026-08-14 17:43:56 +00:00
Thomas Camlong 7652eb8c1c preserve smart home toggle response 2026-08-13 17:04:00 +02:00
Thomas Camlong e761c10f32 fix Home Assistant widget actions 2026-08-13 16:21:48 +02:00
Thomas 2513e8454f fix: open user menu instantly (#6568) 2026-08-13 11:44:10 +02:00
ajnart f6137b2dcc fix(truenas): guard empty dataset percentage 2026-08-12 18:24:33 +02:00
ajnart 425dd90871 fix(truenas): report usable dataset space 2026-08-12 18:19:07 +02:00
Thomas 527e911eef fix(beszel): let Recharts own tooltip lifecycle (#6560) 2026-08-11 15:35:17 +02:00
Thomas ff2cdecc86 feat: rewrite image picker (#6556)
* feat: rewrite image picker

* style: auto-fix lint and format

* fix: address image picker review

* fix: preserve unicode icon searches

* test: stabilize custom widget login

* fix: suggest image searches only on create
2026-08-11 14:49:52 +02:00
Sven 742bde1d27 fix(synology): support vol_info in storage_v2 responses (#6554) 2026-08-11 08:59:28 +00:00
homarr-update-contributors[bot] 64febbce12 chore: update integrations list readme (#6551)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-08-10 11:44:39 +00:00
TimonandThomas 98bb1aecf8 feat(integrations): add What's Up Docker (WUD) integration and widget (#6523)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-08-10 08:42:23 +00:00
homarr-crowdin[bot]andThomas 06c489bd48 chore(lang): updated translations from crowdin (#6548)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-08-10 09:06:56 +02:00
142 changed files with 31131 additions and 27419 deletions

No files matched your search

@@ -1,6 +1,6 @@
--- ---
description: Comprehensive Homarr codebase architecture and conventions reference name: codebase-context
alwaysApply: true description: Comprehensive Homarr codebase architecture and conventions reference. Use when orienting in the monorepo, understanding package dependency layers, the three runtime services and ports, tRPC router structure, multi-database support, widget/integration/cron system architecture, routing, auth providers, env vars, or key patterns before writing code.
--- ---
# Homarr Codebase Context # Homarr Codebase Context
@@ -27,7 +27,7 @@ Homarr is an open-source, self-hosted dashboard for managing homelab services. I
## Repository Structure ## Repository Structure
``` ```text
homarr/ homarr/
├── apps/ ├── apps/
│ ├── nextjs/ # Main Next.js application (port 3000) │ ├── nextjs/ # Main Next.js application (port 3000)
@@ -66,8 +66,6 @@ homarr/
│ ├── analytics/ # Server-side analytics (Umami) │ ├── analytics/ # Server-side analytics (Umami)
│ ├── server-settings/ # Server setting keys/types │ ├── server-settings/ # Server setting keys/types
│ ├── settings/ # User-facing settings UI context │ ├── settings/ # User-facing settings UI context
│ ├── old-import/ # Legacy Homarr import
│ ├── old-schema/ # Legacy Homarr zod schemas
│ └── cli/ # Node CLI for ops (brocli) │ └── cli/ # Node CLI for ops (brocli)
├── tooling/ ├── tooling/
│ ├── typescript/ # Base tsconfig │ ├── typescript/ # Base tsconfig
@@ -79,11 +77,11 @@ homarr/
## Three Runtime Services ## Three Runtime Services
| Service | Port | Role | | Service | Port | Role |
|---------|------|------| | --------- | ---- | -------------------------------------------- |
| Next.js | 3000 | Main web app (SSR + API routes) | | Next.js | 3000 | Main web app (SSR + API routes) |
| WebSocket | 3001 | tRPC subscriptions via `ws` | | WebSocket | 3001 | tRPC subscriptions via `ws` |
| Tasks | 3002 | Cron job runner + management API via Fastify | | Tasks | 3002 | Cron job runner + management API via Fastify |
In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header. In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header.
@@ -112,6 +110,7 @@ In Docker, nginx listens on **7575** and proxies to all three. The tasks service
### Multi-Database Support ### Multi-Database Support
Drizzle schemas exist in three parallel implementations: Drizzle schemas exist in three parallel implementations:
- `packages/db/schema/sqlite.ts` - `packages/db/schema/sqlite.ts`
- `packages/db/schema/mysql.ts` - `packages/db/schema/mysql.ts`
- `packages/db/schema/postgresql.ts` - `packages/db/schema/postgresql.ts`
@@ -121,6 +120,7 @@ Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-pos
### Widget System ### Widget System
Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record<WidgetKind, ...>`). Each widget: Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record<WidgetKind, ...>`). Each widget:
- Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })` - Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })`
- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options - Uses `optionsBuilder.from(factory => ({ ... }))` for typed options
- Loads dynamically via `next/dynamic` - Loads dynamically via `next/dynamic`
@@ -133,6 +133,7 @@ Integrations are defined in `packages/definitions/src/integration.ts` (`integrat
### Cron Job System ### Cron Job System
Jobs defined in `packages/cron-jobs/src/jobs/`. Each job: Jobs defined in `packages/cron-jobs/src/jobs/`. Each job:
- Uses `createCronJob` with a cron expression - Uses `createCronJob` with a cron expression
- Publishes results to Redis channels - Publishes results to Redis channels
- Widget subscriptions consume those channels via tRPC subscriptions - Widget subscriptions consume those channels via tRPC subscriptions
@@ -160,6 +161,7 @@ Configured via `AUTH_PROVIDERS` env. Supports: `credentials` (local), `ldap`, `o
## Environment Variables ## Environment Variables
Key env vars (from `.env.example`): Key env vars (from `.env.example`):
- `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres` - `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres`
- `DB_URL`: Database connection string - `DB_URL`: Database connection string
- `AUTH_SECRET`: NextAuth secret - `AUTH_SECRET`: NextAuth secret
@@ -191,16 +193,3 @@ Key env vars (from `.env.example`):
- Drag-and-drop via `@dnd-kit/*` - Drag-and-drop via `@dnd-kit/*`
- `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`) - `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`)
- `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom` - `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom`
## Documentation (`apps/docs`)
Docusaurus 3 site at `apps/docs/`, package name `@homarr/docs`.
- Run with `pnpm dev:docs` from root
- Build with `pnpm turbo build --filter=@homarr/docs`
- Uses Tailwind CSS 3 for custom pages (not Mantine)
- Imports `@homarr/definitions` for type-safe integration/widget metadata
- When adding or modifying widgets, integrations, cron jobs, API routes, env vars, or user-facing features, you MUST also update corresponding docs in `apps/docs/docs/`
- Integration docs: `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
- Widget docs: `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
- Strict link checking: broken links/anchors cause build failure
@@ -1,3 +1,8 @@
---
name: datatable-migration
description: Playbook for migrating tables from mantine-react-table (MRT) to mantine-datatable. Use when converting any table component from mantine-react-table to mantine-datatable, working on DataTable column/sort/expansion/context-menu code, or implementing responsive column visibility, column persistence, or transparent tables in widgets.
---
# Migrating Tables from mantine-react-table to mantine-datatable # Migrating Tables from mantine-react-table to mantine-datatable
This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables. This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables.
@@ -7,10 +12,12 @@ This document captures every pattern, fix, and decision from the downloads widge
Current `mantine-react-table` consumers that could be migrated: Current `mantine-react-table` consumers that could be migrated:
**Widgets (in `packages/widgets/`):** **Widgets (in `packages/widgets/`):**
- `docker/component.tsx` — Docker container table - `docker/component.tsx` — Docker container table
- `media-server/component.tsx` — Media server sessions - `media-server/component.tsx` — Media server sessions
**Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):** **Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):**
- `tools/docker/docker-table.tsx` - `tools/docker/docker-table.tsx`
- `tools/tasks/_components/tasks-table.tsx` - `tools/tasks/_components/tasks-table.tsx`
- `tools/api/components/api-keys.tsx` - `tools/api/components/api-keys.tsx`
@@ -19,12 +26,12 @@ Current `mantine-react-table` consumers that could be migrated:
- `tools/kubernetes/` — 8 Kubernetes resource tables - `tools/kubernetes/` — 8 Kubernetes resource tables
**Modals:** **Modals:**
- `packages/modals-collection/src/search-engines/request-media-modal.tsx` - `packages/modals-collection/src/search-engines/request-media-modal.tsx`
**Shared hook to remove once all migrated:** **Shared hook to remove once all migrated:**
- `packages/ui/src/hooks/use-translated-mantine-react-table.ts`
--- - `packages/ui/src/hooks/use-translated-mantine-react-table.ts`
## 1. Core Migration: mantine-react-table → mantine-datatable ## 1. Core Migration: mantine-react-table → mantine-datatable
@@ -67,6 +74,7 @@ const columns: DataTableColumn<MyRow>[] = [
``` ```
Key differences: Key differences:
- `accessorKey` → `accessor` - `accessorKey` → `accessor`
- `header` → `title` - `header` → `title`
- `size` → `width` - `size` → `width`
@@ -118,8 +126,6 @@ const sortedData = useMemo(() => {
<DataTable records={sortedData} sortStatus={sortStatus} onSortStatusChange={setSortStatus} /> <DataTable records={sortedData} sortStatus={sortStatus} onSortStatusChange={setSortStatus} />
``` ```
---
## 2. Making Tables Transparent (Widget Context) ## 2. Making Tables Transparent (Widget Context)
Widgets sit inside board cards that have user-controlled opacity. The table must be fully transparent so the card background shows through. mantine-datatable defaults to `var(--mantine-color-body)` which is opaque. Widgets sit inside board cards that have user-controlled opacity. The table must be fully transparent so the card background shows through. mantine-datatable defaults to `var(--mantine-color-body)` which is opaque.
@@ -144,13 +150,21 @@ Create a CSS file alongside the component:
/* Hover: translucent instead of opaque */ /* Hover: translucent instead of opaque */
.my-table .mantine-datatable-table tbody tr:hover { .my-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important; background-color: color-mix(
in srgb,
var(--mantine-color-default-hover) 40%,
transparent
) !important;
} }
/* Headers: frosted glass effect */ /* Headers: frosted glass effect */
.my-table th { .my-table th {
white-space: nowrap; white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important; background-color: color-mix(
in srgb,
var(--mantine-color-body) 60%,
transparent
) !important;
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
} }
@@ -172,14 +186,16 @@ Create a CSS file alongside the component:
/* Row expansion area */ /* Row expansion area */
.my-table .mantine-datatable-row-expansion-cell { .my-table .mantine-datatable-row-expansion-cell {
background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important; background-color: color-mix(
in srgb,
var(--mantine-color-body) 40%,
transparent
) !important;
} }
``` ```
Import it: `import "./styles.css";` and apply via `className="my-table"`. Import it: `import "./styles.css";` and apply via `className="my-table"`.
---
## 3. Responsive Column Visibility ## 3. Responsive Column Visibility
Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop. Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop.
@@ -195,11 +211,38 @@ interface SizeConfig {
} }
const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [ const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [
{ maxWidth: 300, config: { fontSize: "xs", iconSize: 12, cellPadding: 2, showSpeedColumns: false, showTimeColumn: false, showStateColumn: false } }, {
{ maxWidth: 500, config: { fontSize: "xs", iconSize: 14, cellPadding: 4, showSpeedColumns: false, showTimeColumn: true, showStateColumn: false } }, maxWidth: 300,
config: {
fontSize: "xs",
iconSize: 12,
cellPadding: 2,
showSpeedColumns: false,
showTimeColumn: false,
showStateColumn: false,
},
},
{
maxWidth: 500,
config: {
fontSize: "xs",
iconSize: 14,
cellPadding: 4,
showSpeedColumns: false,
showTimeColumn: true,
showStateColumn: false,
},
},
]; ];
const DEFAULT_SIZE_CONFIG: SizeConfig = { fontSize: "sm", iconSize: 14, cellPadding: 4, showSpeedColumns: true, showTimeColumn: true, showStateColumn: true }; const DEFAULT_SIZE_CONFIG: SizeConfig = {
fontSize: "sm",
iconSize: 14,
cellPadding: 4,
showSpeedColumns: true,
showTimeColumn: true,
showStateColumn: true,
};
function getSizeConfig(width: number): SizeConfig { function getSizeConfig(width: number): SizeConfig {
for (const { maxWidth, config } of SIZE_BREAKPOINTS) { for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
@@ -215,7 +258,9 @@ const size = useMemo(() => getSizeConfig(width), [width]);
Then use a visibility check lookup table for columns: Then use a visibility check lookup table for columns:
```typescript ```typescript
const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = { const columnVisibilityChecks: Partial<
Record<string, (ctx: ColumnContext) => boolean>
> = {
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns, upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
downSpeed: (ctx) => ctx.size.showSpeedColumns, downSpeed: (ctx) => ctx.size.showSpeedColumns,
time: (ctx) => ctx.size.showTimeColumn, time: (ctx) => ctx.size.showTimeColumn,
@@ -230,8 +275,6 @@ function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean {
} }
``` ```
---
## 4. Column Persistence (Drag/Resize → Server) ## 4. Column Persistence (Drag/Resize → Server)
`useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices. `useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices.
@@ -263,15 +306,6 @@ Even hidden options need translation keys or the translation test fails:
"widget.mywidget.option.columnWidths.label": "Column widths" "widget.mywidget.option.columnWidths.label": "Column widths"
``` ```
### Old-import mappings
Add to `packages/old-import/src/widgets/options.ts`:
```typescript
columnOrder: () => undefined,
columnWidths: () => undefined,
```
### Dynamic storeKey ### Dynamic storeKey
Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding: Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding:
@@ -285,8 +319,13 @@ const storeKey = `mytable-${itemId ?? "preview"}-${[...options.columns].toSorted
On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back: On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back:
```typescript ```typescript
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } = const {
useDataTableColumns<MyRow>({ key: storeKey, columns }); effectiveColumns,
columnsOrder,
columnsWidth,
setColumnsOrder,
setMultipleColumnWidths,
} = useDataTableColumns<MyRow>({ key: storeKey, columns });
const lastStoreKey = useRef(storeKey); const lastStoreKey = useRef(storeKey);
const hydrated = useRef(false); const hydrated = useRef(false);
@@ -300,11 +339,22 @@ useEffect(() => {
if (savedOrder.length > 0) setColumnsOrder(savedOrder); if (savedOrder.length > 0) setColumnsOrder(savedOrder);
if (Object.keys(savedWidths).length > 0) { if (Object.keys(savedWidths).length > 0) {
setMultipleColumnWidths( setMultipleColumnWidths(
Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })), Object.entries(savedWidths).map(([accessor, w]) => ({
accessor,
width: w,
})),
); );
} }
requestAnimationFrame(() => { hydrated.current = true; }); requestAnimationFrame(() => {
}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]); hydrated.current = true;
});
}, [
storeKey,
savedOrder,
savedWidths,
setColumnsOrder,
setMultipleColumnWidths,
]);
``` ```
### Persist localStorage → server ### Persist localStorage → server
@@ -317,22 +367,26 @@ const prevWidths = useRef(columnsWidth);
useEffect(() => { useEffect(() => {
if (!hydrated.current) return; if (!hydrated.current) return;
const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current); const orderChanged =
const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current); JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
const widthsChanged =
JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
prevOrder.current = columnsOrder; prevOrder.current = columnsOrder;
prevWidths.current = columnsWidth; prevWidths.current = columnsWidth;
if (!orderChanged && !widthsChanged) return; if (!orderChanged && !widthsChanged) return;
if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) }); if (orderChanged)
persistOption({ columnOrder: JSON.stringify(columnsOrder) });
if (widthsChanged) { if (widthsChanged) {
const widthMap: Record<string, number> = {}; const widthMap: Record<string, number> = {};
for (const entry of columnsWidth) { for (const entry of columnsWidth) {
const key = Object.keys(entry)[0]; const key = Object.keys(entry)[0];
if (!key) continue; if (!key) continue;
const w = entry[key as keyof typeof entry]; const v = entry[key as keyof typeof entry];
if (typeof w === "number") widthMap[key] = w; if (typeof v === "number") widthMap[key] = v;
else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10); else if (typeof v === "string" && v.endsWith("px"))
widthMap[key] = parseInt(v, 10);
} }
persistOption({ columnWidths: JSON.stringify(widthMap) }); persistOption({ columnWidths: JSON.stringify(widthMap) });
} }
@@ -341,8 +395,6 @@ useEffect(() => {
The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation). The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation).
---
## 5. Context Menu (Right-Click) ## 5. Context Menu (Right-Click)
Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly: Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly:
@@ -374,15 +426,41 @@ const handleContextMenu = useCallback(
The menu component uses `Portal` for correct z-index: The menu component uses `Portal` for correct z-index:
```tsx ```tsx
function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: () => void }) { function RowContextMenu({
state,
onClose,
}: {
state: ContextMenuState;
onClose: () => void;
}) {
return ( return (
<Portal> <Portal>
<Menu opened onClose={onClose} closeOnItemClick={false} position="right-start" offset={0}> <Menu
opened
onClose={onClose}
closeOnItemClick={false}
position="right-start"
offset={0}
>
<Menu.Target> <Menu.Target>
<Box style={{ position: "fixed", left: state.x, top: state.y, width: 0, height: 0 }} /> <Box
style={{
position: "fixed",
left: state.x,
top: state.y,
width: 0,
height: 0,
}}
/>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item onClick={() => { /* action */ onClose(); }}>Action</Menu.Item> <Menu.Item
onClick={() => {
/* action */ onClose();
}}
>
Action
</Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
</Portal> </Portal>
@@ -392,8 +470,6 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose:
**Key fix:** Use `closeOnItemClick={false}` if any menu item needs a two-step confirmation (like delete → confirm). Close the menu on scroll: `onScroll={() => { if (contextMenu) closeContextMenu(); }}`. **Key fix:** Use `closeOnItemClick={false}` if any menu item needs a two-step confirmation (like delete → confirm). Close the menu on scroll: `onScroll={() => { if (contextMenu) closeContextMenu(); }}`.
---
## 6. Row Expansion ## 6. Row Expansion
```typescript ```typescript
@@ -413,8 +489,6 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose:
**Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work. **Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work.
---
## 7. Common Pitfalls & Fixes ## 7. Common Pitfalls & Fixes
### Status indicator dots ### Status indicator dots
@@ -475,8 +549,11 @@ Always wrap in try/catch:
```typescript ```typescript
function parseJsonOption<T>(value: string | undefined, fallback: T): T { function parseJsonOption<T>(value: string | undefined, fallback: T): T {
if (!value) return fallback; if (!value) return fallback;
try { return JSON.parse(value) as T; } try {
catch { return fallback; } return JSON.parse(value) as T;
} catch {
return fallback;
}
} }
``` ```
@@ -494,20 +571,19 @@ If the widget has configurable default sort, sync it:
```typescript ```typescript
useEffect(() => { useEffect(() => {
setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection }); setSortStatus({
columnAccessor: options.defaultSort,
direction: defaultSortDirection,
});
}, [options.defaultSort, defaultSortDirection]); }, [options.defaultSort, defaultSortDirection]);
``` ```
---
## 8. Accessibility ## 8. Accessibility
- All `ActionIcon` components need `aria-label` - All `ActionIcon` components need `aria-label`
- Filter toggles (client badges, status badges) need `aria-pressed={isActive}` - Filter toggles (client badges, status badges) need `aria-pressed={isActive}`
- `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize - `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize
---
## 9. Error Handling ## 9. Error Handling
- Every mutation needs an `onError` callback with `showErrorNotification` - Every mutation needs an `onError` callback with `showErrorNotification`
@@ -518,12 +594,14 @@ useEffect(() => {
```typescript ```typescript
const mutationOptions = { const mutationOptions = {
onSettled: () => void utils.myQuery.invalidate(), onSettled: () => void utils.myQuery.invalidate(),
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }), onError: () =>
showErrorNotification({
title: t("errors.actionFailed"),
message: t("errors.actionFailedMessage"),
}),
}; };
``` ```
---
## 10. i18n ## 10. i18n
- No hardcoded strings. Every user-visible string goes through `useScopedI18n` - No hardcoded strings. Every user-visible string goes through `useScopedI18n`
@@ -534,8 +612,6 @@ const mutationOptions = {
- Errors: `t("errors.{errorKey}")` - Errors: `t("errors.{errorKey}")`
- Hidden options still need `.label` translation keys - Hidden options still need `.label` translation keys
---
## 11. Edit Mode ## 11. Edit Mode
Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop: Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop:
@@ -550,8 +626,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
<DataTable style={{ pointerEvents: tablePointerEvents }} /> <DataTable style={{ pointerEvents: tablePointerEvents }} />
``` ```
---
## 12. DataTable Props Reference (Commonly Used) ## 12. DataTable Props Reference (Commonly Used)
```typescript ```typescript
@@ -590,8 +664,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
/> />
``` ```
---
## 13. Layout Stability ## 13. Layout Stability
- Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic - Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic
@@ -600,8 +672,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
- Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length - Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length
- `flexShrink: 0` on badges/icons prevents them from being squeezed by long names - `flexShrink: 0` on badges/icons prevents them from being squeezed by long names
---
## 14. Checklist for Each Migration ## 14. Checklist for Each Migration
1. Replace imports (`mantine-react-table` → `mantine-datatable`) 1. Replace imports (`mantine-react-table` → `mantine-datatable`)
@@ -610,13 +680,12 @@ if (isEditMode) rowContextMenuHandler = undefined;
4. Add CSS file for transparent backgrounds (if widget context) 4. Add CSS file for transparent backgrounds (if widget context)
5. Add responsive column visibility (if widget context) 5. Add responsive column visibility (if widget context)
6. Add hidden options + hydration logic (if column persistence needed) 6. Add hidden options + hydration logic (if column persistence needed)
7. Add old-import mappings for new options 7. Add translation keys (including hidden option labels)
8. Add translation keys (including hidden option labels) 8. Add context menu if row actions exist
9. Add context menu if row actions exist 9. Add row expansion if detail view exists
10. Add row expansion if detail view exists 10. Add error handling on all mutations
11. Add error handling on all mutations 11. Add accessibility attributes
12. Add accessibility attributes 12. Test at multiple widget widths
13. Test at multiple widget widths 13. Update docs in `apps/docs/docs/widgets/` if applicable
14. Update docs in `apps/docs/docs/widgets/` if applicable 14. Run `pnpm turbo typecheck`
15. Run `pnpm turbo typecheck` — catches old-import mismatches 15. Run translation spec — catches missing keys
16. Run translation spec — catches missing keys
@@ -0,0 +1,44 @@
---
name: documentation-sync
description: Keep Homarr documentation in sync with code changes. Use when modifying code that affects user-facing behavior, or when adding/modifying widgets, integrations, cron jobs, API routes, env vars, permissions, auth providers, or UI in apps/docs. Covers the docs/ mapping table, integration doc pattern, and widget doc pattern.
---
# Documentation Sync
When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`.
## What triggers a doc update
| Code change | Doc location |
| ---------------------- | ----------------------------------------------------------- |
| New integration | `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts` |
| New widget | `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts` |
| Changed API surface | `apps/docs/docs/management/api.mdx` |
| New/changed env vars | `apps/docs/docs/advanced/` |
| New CLI commands | `apps/docs/docs/advanced/command-line/` |
| Changed auth providers | `apps/docs/docs/advanced/` SSO pages |
| UI/UX changes | Relevant getting-started or management pages |
| New cron job | `apps/docs/docs/management/tasks.mdx` |
| Changed permissions | `apps/docs/docs/management/users.mdx` |
## Integration doc pattern
Each integration has a folder under `apps/docs/docs/integrations/<slug>/`:
- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path
- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets`
- Import metadata from `@homarr/definitions` where possible
## Widget doc pattern
Each widget has a folder under `apps/docs/docs/widgets/<slug>/`:
- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration
- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding`
## Development
- Run docs locally: `pnpm dev:docs` from root
- Build docs: `pnpm turbo build --filter=@homarr/docs`
- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata
- Docusaurus strict mode throws on broken links — fix any broken references before committing
@@ -1,13 +1,14 @@
--- ---
description: How to expose tRPC procedures as MCP tools for AI clients name: mcp-integration
globs: packages/api/src/router/**/*.ts description: Expose tRPC procedures as MCP tools for AI clients. Use when modifying procedures in packages/api/src/router, adding a new tRPC procedure or router, or deciding what to expose via Homarr's MCP interface. Covers .meta({ mcp }), router registration in packages/api/src/mcp.ts, input schema rules, and what to expose vs skip.
alwaysApply: false
--- ---
# MCP Integration Guide # MCP Integration
Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr. Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr.
Exposing a procedure makes it callable by any AI client with an API key. Before adding `.meta({ mcp })`, review what the tool exposes: authorization requirements, sensitive data, tenant isolation, auditability, and whether the action is destructive. Prefer exposing read-only queries over mutations unless the mutation is safe and permission-scoped. When in doubt, ask for a security review before shipping a new MCP tool.
## Adding MCP to a procedure ## Adding MCP to a procedure
Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain: Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain:
@@ -16,7 +17,7 @@ Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure cha
// Read-only tool (query) // Read-only tool (query)
myProcedure: protectedProcedure myProcedure: protectedProcedure
.meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } }) .meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } })
.input(z.object({ limit: z.number().default(50) })) .input(z.object({ limit: z.number().default(10) }))
.query(async ({ ctx, input }) => { ... }) .query(async ({ ctx, input }) => { ... })
// Write tool (mutation) // Write tool (mutation)
@@ -38,23 +39,27 @@ The `.meta()` call must come **before** `.input()` and `.query()`/`.mutation()`
The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr. The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr.
**Must include:** **Must include:**
- What the tool returns or does - What the tool returns or does
- Which integrations/services it works with (if applicable) - Which integrations/services it works with (if applicable)
- How to get required IDs (e.g., "Use integration_all to get the integrationId") - How to get required IDs (e.g., "Use integration_all to get the integrationId")
- Permission requirements (if non-obvious) - Permission requirements (if non-obvious)
**Good:** **Good:**
```
```text
"Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all" "Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all"
``` ```
**Bad:** **Bad:**
```
```text
"Get calendar events" "Get calendar events"
``` ```
**For tools that return permission fields**, explain what they mean: **For tools that return permission fields**, explain what they mean:
```
```text
"List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error" "List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error"
``` ```
@@ -77,11 +82,13 @@ export const mcpRouter = createTRPCRouter({
## What to expose vs. what to skip ## What to expose vs. what to skip
**Expose:** **Expose:**
- Data queries (list, search, get by ID) - Data queries (list, search, get by ID)
- Actions users would ask an AI to do (toggle, create, delete, approve) - Actions users would ask an AI to do (toggle, create, delete, approve)
- Status checks (health, stats, summaries) - Status checks (health, stats, summaries)
**Skip:** **Skip:**
- Subscriptions (WebSocket-only, not supported by MCP) - Subscriptions (WebSocket-only, not supported by MCP)
- Internal procedures (session management, onboarding steps) - Internal procedures (session management, onboarding steps)
- File upload/download procedures - File upload/download procedures
+1
View File
@@ -0,0 +1 @@
../.agents/skills
-50
View File
@@ -1,50 +0,0 @@
---
description: Keep documentation in sync with code changes
globs:
- packages/definitions/src/**
- packages/widgets/src/**
- packages/integrations/src/**
- packages/api/src/**
- apps/nextjs/src/**
- apps/docs/**
---
# Documentation Sync Rule
When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`.
## What triggers a doc update
| Code change | Doc location |
|---|---|
| New integration | `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts` |
| New widget | `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts` |
| Changed API surface | `apps/docs/docs/management/api.mdx` |
| New/changed env vars | `apps/docs/docs/advanced/` |
| New CLI commands | `apps/docs/docs/advanced/command-line/` |
| Changed auth providers | `apps/docs/docs/advanced/` SSO pages |
| UI/UX changes | Relevant getting-started or management pages |
| New cron job | `apps/docs/docs/management/tasks.mdx` |
| Changed permissions | `apps/docs/docs/management/users.mdx` |
## Integration doc pattern
Each integration has a folder under `apps/docs/docs/integrations/<slug>/`:
- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path
- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets`
- Import metadata from `@homarr/definitions` where possible
## Widget doc pattern
Each widget has a folder under `apps/docs/docs/widgets/<slug>/`:
- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration
- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding`
## Development
- Run docs locally: `pnpm dev:docs` from root
- Build docs: `pnpm turbo build --filter=@homarr/docs`
- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata
- Docusaurus strict mode throws on broken links — fix any broken references before committing
+5
View File
@@ -33,6 +33,11 @@ body:
options: options:
# The below comment is used to insert a new version with on-release.yml # The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION# #NEXT_VERSION#
- 1.77.0
- 1.76.2
- 1.76.1
- 1.76.0
- 1.75.0
- 1.74.0 - 1.74.0
- 1.73.0 - 1.73.0
- 1.72.0 - 1.72.0
@@ -68,8 +68,6 @@ jobs:
with: with:
node-version: 24.18.0 node-version: 24.18.0
package-manager-cache: false package-manager-cache: false
- run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false'
- name: Install dependencies - name: Install dependencies
if: env.SKIP_RELEASE == 'false' if: env.SKIP_RELEASE == 'false'
run: | run: |
+2 -1
View File
@@ -71,7 +71,8 @@ apps/nextjs/public/images/background.png
# personal agent config # personal agent config
.opencode .opencode
.agents/ .agents/*
!.agents/skills/
skills-lock.json skills-lock.json
# docusaurus # docusaurus
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"mantine": {
"command": "npx",
"args": ["-y", "@mantine/mcp-server"]
}
}
}
+10
View File
@@ -84,6 +84,16 @@ When modifying user-facing code, you MUST also update the corresponding document
- Lint: oxlint (not ESLint) - Lint: oxlint (not ESLint)
- Format: oxfmt (not Prettier) - Format: oxfmt (not Prettier)
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app - UI: Mantine (not Tailwind) — Tailwind is only used in docs app
- Mantine: use the `mantine` MCP server (configured in `.mcp.json`; tools `get_item_doc` / `get_item_props` / `search_docs`) for current v9 APIs before writing component code. Prefer built-in primitives (`Combobox`/`useCombobox` for selects, the polymorphic `component` prop, `@mantine/hooks`) over hand-rolled equivalents. Check `packages/ui/` for existing conventions first.
## MCP servers
`.mcp.json` at the repo root declares project-scoped MCP servers (the standard Claude Code layout; other tools can import the same `mcpServers` block). See the `mantine` entry there for the Mantine v9 server, and the upstream docs at https://mantine.dev/guides/llms/ for per-tool setup (OpenCode: `.opencode/opencode.json`, Codex: `.codex/config.toml`). Only keyless server definitions belong in `.mcp.json`; anything needing secrets stays in local dotfiles.
## Agent Skills
Portable agent skills live in `.agents/skills/` (the Agent Skills open standard). Cursor, Codex, and OpenCode read `.agents/skills/` natively. Claude Code does not scan it, so `.claude/skills/` is a symlink back to `.agents/skills/`. Install skills at `.agents/skills/<name>/SKILL.md` — that single copy is used everywhere. See `.agents/skills/` for available skills (documentation-sync, mcp-integration, codebase-context, datatable-migration).
- Icons: @tabler/icons-react - Icons: @tabler/icons-react
- Docs app can import from `@homarr/definitions` for shared types - Docs app can import from `@homarr/definitions` for shared types
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images. - Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
+12
View File
@@ -1,3 +1,15 @@
## [1.77.1](https://github.com/homarr-labs/homarr/compare/v1.77.0...v1.77.1) (2026-09-11)
## [1.77.0](https://github.com/homarr-labs/homarr/compare/v1.76.2...v1.77.0) (2026-09-06)
## [1.76.2](https://github.com/homarr-labs/homarr/compare/v1.76.1...v1.76.2) (2026-08-27)
## [1.76.1](https://github.com/homarr-labs/homarr/compare/v1.76.0...v1.76.1) (2026-08-26)
## [1.76.0](https://github.com/homarr-labs/homarr/compare/v1.75.0...v1.76.0) (2026-08-21)
## [1.75.0](https://github.com/homarr-labs/homarr/compare/v1.74.0...v1.75.0) (2026-08-14)
## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07) ## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07)
## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31) ## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
+8
View File
@@ -206,6 +206,14 @@ packages/integrations/src/unraid @manuel-rw
apps/docs/docs/integrations/uptime-kuma @ajnart apps/docs/docs/integrations/uptime-kuma @ajnart
packages/integrations/src/uptime-kuma @ajnart packages/integrations/src/uptime-kuma @ajnart
# What's Up Docker (WUD) - introduced in https://github.com/homarr-labs/homarr/pull/6523 by @tiimonn
apps/docs/docs/integrations/whats-up-docker @tiimonn
apps/docs/docs/widgets/whats-up-docker @tiimonn
packages/api/src/router/widgets/wud.ts @tiimonn
packages/integrations/src/wud @tiimonn
packages/request-handler/src/wud.ts @tiimonn
packages/widgets/src/wud @tiimonn
# Traefik - by @Nerdy-Technician # Traefik - by @Nerdy-Technician
apps/docs/docs/integrations/traefik @Nerdy-Technician apps/docs/docs/integrations/traefik @Nerdy-Technician
apps/docs/docs/widgets/traefik @Nerdy-Technician apps/docs/docs/widgets/traefik @Nerdy-Technician
+2 -2
View File
@@ -55,8 +55,8 @@ pnpm typecheck
docs/ docs/
├── getting-started/ # Installation guides, glossary, prerequisites ├── getting-started/ # Installation guides, glossary, prerequisites
├── management/ # Boards, apps, users, integrations, settings, tasks, API ├── management/ # Boards, apps, users, integrations, settings, tasks, API
├── integrations/ # 55 integration guides (one folder each) ├── integrations/ # 56 integration guides (one folder each)
├── widgets/ # 42 widget guides (one folder each) ├── widgets/ # 43 widget guides (one folder each)
├── advanced/ # SSO, env vars, proxy, styling, CLI, development ├── advanced/ # SSO, env vars, proxy, styling, CLI, development
└── community/ # FAQ, donate, license, translations, get-in-touch └── community/ # FAQ, donate, license, translations, get-in-touch
``` ```
+18 -9
View File
@@ -9,17 +9,19 @@ tags:
--- ---
## Icon picker ## Icon picker
Icons in Homarr are automatically requested from multiple sources: Icons in Homarr are automatically requested from multiple sources:
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with over 1888+ curated, high-quality icons
- [selfh.st icons](https://github.com/selfhst/icons) - [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with more than 1,888 curated, high-quality icons
- [Simple icons](https://github.com/simple-icons/simple-icons) - [selfh.st icons](https://github.com/selfhst/icons)
- [Tabler icons](https://tabler-icons.io/) - [Simple icons](https://github.com/simple-icons/simple-icons)
- [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme) - [Tabler icons](https://tabler-icons.io/)
- [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets) - [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/) - [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets)
- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips. Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips.
The icon picker is a fast searcher that helps you to find your desired icon simply by entering a search term. The image picker searches this collection after a short typing delay. When a name is available, such as an app or search engine name, the picker uses it as the initial search. Results show your locally uploaded images first, followed by SVG icons and then other image formats.
### Finding Missing Icons ### Finding Missing Icons
@@ -29,6 +31,13 @@ If you can't find the icon you're looking for in Homarr's built-in picker, we st
Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scalable_Vector_Graphics)) are marked with a red badge "SVG" at the top right. Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scalable_Vector_Graphics)) are marked with a red badge "SVG" at the top right.
You can clear the selected image with the clear button or the usual keyboard shortcut for deleting a line (Command+Backspace on macOS or Control+Backspace on other platforms). Clearing the field does not restore the previous image.
### Using external icons ### Using external icons
If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon. If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon.
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it. Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. The picker previews the image and shows whether it loaded successfully. It is important that the URL directly points to the file without any advertisements or elements around it.
### Uploading a local image
Users with media upload permission can select **Upload image** next to the field. After the upload finishes, the new local image is selected immediately and is available at the top of future searches.
@@ -2,9 +2,9 @@
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo"> <img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.26.0](https://img.shields.io/badge/Version-8.26.0-informational?style=flat) ![Version: 8.28.1](https://img.shields.io/badge/Version-8.28.1-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.74.0](https://img.shields.io/badge/AppVersion-v1.74.0-informational?style=flat) ![AppVersion: v1.76.1](https://img.shields.io/badge/AppVersion-v1.76.1-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes A Helm chart to deploy homarr for Kubernetes
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match | | httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | | image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository | | image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
| image.tag | string | `"v1.74.0"` | Overrides the image tag whose default is the chart appVersion | | image.tag | string | `"v1.76.1"` | Overrides the image tag whose default is the chart appVersion |
| imagePullSecrets | list | `[]` | Secrets for Docker registry | | imagePullSecrets | list | `[]` | Secrets for Docker registry |
| ingress.annotations | object | `{}` | Ingress annotations | | ingress.annotations | object | `{}` | Ingress annotations |
| ingress.enabled | bool | `false` | Enable ingress | | ingress.enabled | bool | `false` | Enable ingress |
@@ -27,6 +27,8 @@ import Alert from '@theme/Admonition';
]} ]}
/> />
The storage values shown by these widgets come from each pool's root ZFS dataset, so they already account for RAIDZ parity overhead and dataset limits and match what `zfs list` and the TrueNAS web interface report. When a pool cannot report its dataset space, Homarr falls back to the pool's physical values, which read higher on RAIDZ.
### Adding the integration ### Adding the integration
<AddingIntegration /> <AddingIntegration />
@@ -0,0 +1,48 @@
---
title: "What's Up Docker"
description: "What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available."
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { wudIntegration } from ".";
import { wudWidget } from "@site/docs/widgets/whats-up-docker";
<IntegrationHeader integration={wudIntegration} categories={["Health Monitoring"]} />
The What's Up Docker integration lets Homarr connect to your [WUD](https://github.com/getwud/wud) instance so you can see how many of your watched containers have image updates available directly from your dashboard.
### Widgets & Capabilities
<IntegrationCapabilites items={[{ widget: wudWidget }]} />
### Adding the integration
<AddingIntegration />
:::info URL configuration
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 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={[
{
credentials: [],
steps: [],
},
{
credentials: ["username", "password"],
steps: [
"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",
],
},
]}
/>
@@ -0,0 +1,9 @@
import { IntegrationDefinition } from "@site/src/types";
export const wudIntegration: IntegrationDefinition = {
name: "What's Up Docker",
description:
"What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
path: "../../integrations/whats-up-docker",
};
+1 -1
View File
@@ -28,7 +28,7 @@ The name must be at least one character long and can be chosen freely.
### Icon URL ### Icon URL
This is the URL to the icon that will be displayed on the app widget. This is the URL to the icon that will be displayed on the app widget.
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories. You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. The app name is pre-filled as the initial library search when no image is selected. Local images appear first, followed by SVG icons and other formats. Direct image URLs are previewed, used as entered, and are not searched in the icon repositories.
![Icon URL](./img/app-icon-url.png) ![Icon URL](./img/app-icon-url.png)
@@ -52,14 +52,21 @@ The certificates are stored in the `/appdata/trusted-certificates` directory whi
This means you can automate the update of the certificates by simply replacing or adding a new file in the directory. This means you can automate the update of the certificates by simply replacing or adding a new file in the directory.
## Obtaining certificates ## Obtaining certificates
Every integration has its own way of obtaining certificates. Every integration has its own way of obtaining certificates.
Generally it is recommended to search for the documentation of the integration you are using. Generally it is recommended to search for the documentation of the integration you are using.
Most of the time you can find the certificate in the file system of the integration. Most of the time you can find the certificate in the file system of the integration.
The certificate file needs to be uploaded to Homarr. To first get the file on your computer you can either:
- use `scp user@host:/path/to/file ./certificate.pem`
- use `cat /path/to/file` to print the certificate. Then recreate the file on your machine.
Below you can find instructions for some of the integrations. Below you can find instructions for some of the integrations.
### Pi-hole (v6) ### Pi-hole (v6)
Since version 6 of Pi-hole, the web interface can be accessed through HTTPS. Since version 6 of Pi-hole, the web interface can be accessed through HTTPS.
The certificate can be found in the path `/etc/pihole/tls_ca.crt`. To add the certificate, this file needs to be copied and uploaded to Homarr. The certificate can be found in the path `/etc/pihole/tls_ca.crt`.
You can find more details regarding SSL / TLS for Pi-hole [here](https://docs.pi-hole.net/api/tls/). You can find more details regarding SSL / TLS for Pi-hole [here](https://docs.pi-hole.net/api/tls/).
### Proxmox
By default, Proxmox VE uses a node certificate signed by the cluster. You only need to configure Homarr with the certificate of a single node.
The certificate can be found in any node on the path `/etc/pve/pve-root-ca.pem`.
You can learn more about certificate management in Proxmox [here](https://pve.proxmox.com/wiki/Certificate_Management).
+13 -15
View File
@@ -1,30 +1,27 @@
--- ---
title: 'App' title: "App"
description: 'Embeds an app into the board.' description: "Embeds an app into the board."
hide_title: true hide_title: true
--- ---
import { AddingWidget } from '@site/src/components/widgets/adding'; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from '@site/src/components/widgets/configuration'; import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from '@site/src/components/widgets/header'; import { WidgetHeader } from "@site/src/components/widgets/header";
import { appWidget } from '.'; import { appWidget } from ".";
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
<WidgetHeader
widget={appWidget}
categories={['Links', 'Apps']}
/>
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board. The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board.
It also supports a status check to indicate whether Homarr can reach the application or website. It also supports a status check to indicate whether Homarr can reach the application or website.
In view mode, right-clicking uses the browser's link menu. Homarr's widget menu remains available in edit mode.
### Screenshots ### Screenshots
import githubApp from './img/github.png'; import githubApp from "./img/github.png";
import jellyseerrApp from './img/jellyseerr-description-tooltip.png'; import jellyseerrApp from "./img/jellyseerr-description-tooltip.png";
import piHoleApp from './img/pi-hole-ping-icon.png'; import piHoleApp from "./img/pi-hole-ping-icon.png";
<div className='flex gap-1'> <div className="flex gap-1">
<img src={githubApp} alt="app github with-status-check" width={128 * 1.5} height={128 * 1.5} /> <img src={githubApp} alt="app github with-status-check" width={128 * 1.5} height={128 * 1.5} />
<img src={piHoleApp} alt="app pi-hole with-status-check status-check-icon" width={128 * 1.5} height={128 * 1.5} /> <img src={piHoleApp} alt="app pi-hole with-status-check status-check-icon" width={128 * 1.5} height={128 * 1.5} />
<img src={jellyseerrApp} alt="app jellyseerr with-description-tooltip" height={128 * 1.5} /> <img src={jellyseerrApp} alt="app jellyseerr with-description-tooltip" height={128 * 1.5} />
@@ -34,4 +31,5 @@ import piHoleApp from './img/pi-hole-ping-icon.png';
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={appWidget.configuration} /> <WidgetConfig configuration={appWidget.configuration} />
+14 -16
View File
@@ -1,29 +1,26 @@
--- ---
title: 'Bookmarks' title: "Bookmarks"
description: 'Displays multiple app links' description: "Displays multiple app links"
hide_title: true hide_title: true
--- ---
import { AddingWidget } from '@site/src/components/widgets/adding'; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from '@site/src/components/widgets/configuration'; import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from '@site/src/components/widgets/header'; import { WidgetHeader } from "@site/src/components/widgets/header";
import { bookmarksWidget } from '.'; import { bookmarksWidget } from ".";
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
<WidgetHeader
widget={bookmarksWidget}
categories={['Links', 'Apps']}
/>
The bookmarks widget allows you to display multiple app links in a single widget. The bookmarks widget allows you to display multiple app links in a single widget.
You can add links to your favorite applications, websites, or any other resources you want quick access to. You can add links to your favorite applications, websites, or any other resources you want quick access to.
In view mode, right-clicking uses the browser's link menu. Homarr's widget menu remains available in edit mode.
### Screenshots ### Screenshots
import gridIconsOnly from './img/grid-icons-only.png'; import gridIconsOnly from "./img/grid-icons-only.png";
import gridWithTitle from './img/grid-with-title.png'; import gridWithTitle from "./img/grid-with-title.png";
import verticalListAll from './img/vertical-list-all.png'; import verticalListAll from "./img/vertical-list-all.png";
import vericalListSimple from './img/vertical-list-simple.png'; import vericalListSimple from "./img/vertical-list-simple.png";
<div className='flex gap-1'> <div className='flex gap-1'>
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
@@ -33,12 +30,13 @@ import vericalListSimple from './img/vertical-list-simple.png';
<img src={gridWithTitle} alt="bookmarks grid with-title" width={256 + 4} height={256 + 4} /> <img src={gridWithTitle} alt="bookmarks grid with-title" width={256 + 4} height={256 + 4} />
<img src={verticalListAll} alt="bookmarks vertical-list all-elements" width={128} height={256 + 4} /> <img src={verticalListAll} alt="bookmarks vertical-list all-elements" width={128} height={256 + 4} />
</div>
</div>
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={bookmarksWidget.configuration} /> <WidgetConfig configuration={bookmarksWidget.configuration} />
@@ -43,6 +43,7 @@ import temperature from './img/temperature.png';
### Interactions ### Interactions
- Toggle entity state by clicking on the entity (only when clickable enabled). - Toggle entity state by clicking on the entity (only when clickable enabled).
- After a successful toggle, the displayed state refreshes immediately.
### Adding the widget ### Adding the widget
@@ -64,4 +65,4 @@ import retrieveEntityId from './img/retrieve-entity-id.png';
<div> <div>
<img src={retrieveEntityId} alt="homeassistant retrieve-entity-id" width={256 * 2} /> <img src={retrieveEntityId} alt="homeassistant retrieve-entity-id" width={256 * 2} />
</div> </div>
@@ -47,4 +47,7 @@ import withDisplayname from './img/with-displayname.png';
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
Use the Home Assistant entity ID (for example, `automation.turn_on_lights`) in the **Automation ID** field, not the automation's internal ID.
<WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} /> <WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} />
@@ -0,0 +1,35 @@
---
title: "What's Up Docker"
description: "Displays how many watched containers have image updates available."
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { wudWidget } from ".";
import { wudIntegration } from "@site/docs/integrations/whats-up-docker";
<WidgetHeader widget={wudWidget} categories={["Health Monitoring"]} />
This widget shows two stats from your WUD instance: the total number of monitored containers, and how many of them currently have an image update available. Optionally, it can also list each outdated container with its current and new version, linking to the release notes when WUD provides one.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: wudIntegration,
note: "Displays monitored container and available-update counts",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={wudWidget.configuration} />
@@ -0,0 +1,37 @@
import { WidgetDefinition } from "@site/src/types";
import { IconBrandDocker } from "@tabler/icons-react";
export const wudWidget: WidgetDefinition = {
icon: IconBrandDocker,
name: "What's Up Docker",
description: "Displays how many watched containers have image updates available.",
path: "../../widgets/whats-up-docker",
configuration: {
items: [
{
name: "Show title",
description: "Displays the icon and \"What's Up Docker\" label above the stats",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show update ring",
description: "Displays a ring showing the percentage of monitored containers with a pending update",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Layout",
description: "Arrange the two stats side by side (Horizontal) or stacked (Vertical)",
values: { type: "select", options: ["Horizontal", "Vertical"] },
defaultValue: "Horizontal",
},
{
name: "Show container update list",
description: "Lists each monitored container that has an image update available, linking to its release notes when known",
values: { type: "boolean" },
defaultValue: "no",
},
],
},
};
@@ -178,35 +178,37 @@ export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisib
const isPending = savePartialSettings.isPending || saveLayouts.isPending; const isPending = savePartialSettings.isPending || saveLayouts.isPending;
return ( return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}> <Stack gap="xl">
<Stack gap="xl"> <form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<GeneralSettingsContent board={board} form={form} /> <Stack gap="xl">
<LayoutSettingsContent form={form} /> <GeneralSettingsContent board={board} form={form} />
<BackgroundSettingsContent form={form} /> <LayoutSettingsContent form={form} />
<ColorSettingsContent form={form} /> <BackgroundSettingsContent form={form} />
<CustomCssSettingsContent form={form} /> <ColorSettingsContent form={form} />
<BehaviorSettingsContent form={form} /> <CustomCssSettingsContent form={form} />
<BehaviorSettingsContent form={form} />
{hasFullAccess && ( {form.isDirty() && (
<SectionCard title={tSection("access.title")}> <UnsavedChangesBar>
<BoardAccessSettings board={board} initialPermissions={permissions} /> <Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
</SectionCard> {t("common.action.discard")}
)} </Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{t("common.action.saveChanges")}
</Button>
</UnsavedChangesBar>
)}
</Stack>
</form>
{hasFullAccess && <DangerZoneSettingsContent hideVisibility={hideVisibility} />} {hasFullAccess && (
<SectionCard title={tSection("access.title")}>
<BoardAccessSettings board={board} initialPermissions={permissions} />
</SectionCard>
)}
{form.isDirty() && ( {hasFullAccess && <DangerZoneSettingsContent hideVisibility={hideVisibility} />}
<UnsavedChangesBar> </Stack>
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
{t("common.action.discard")}
</Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{t("common.action.saveChanges")}
</Button>
</UnsavedChangesBar>
)}
</Stack>
</form>
); );
}; };
@@ -697,7 +697,11 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
<Stack gap="sm"> <Stack gap="sm">
<TextInput label={t("field.name")} required {...form.getInputProps("name")} /> <TextInput label={t("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} {...form.getInputProps("description")} /> <Textarea label={t("field.description")} {...form.getInputProps("description")} />
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} /> <IconPicker
withAsterisk={false}
suggestedSearch={mode === "create" ? form.values.name : undefined}
{...form.getInputProps("iconUrl")}
/>
</Stack> </Stack>
</Fieldset> </Fieldset>
@@ -57,7 +57,10 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
/> />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
<IconPicker {...form.getInputProps("iconUrl")} /> <IconPicker
{...form.getInputProps("iconUrl")}
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
/>
<Fieldset legend={t("search.engine.page.edit.configControl")}> <Fieldset legend={t("search.engine.page.edit.configControl")}>
<SegmentedControl <SegmentedControl
@@ -14,6 +14,7 @@ import { db } from "@homarr/db";
import { getPackageVersion } from "~/versions/package-reader"; import { getPackageVersion } from "~/versions/package-reader";
import { extractMcpTools } from "../_extract-tools"; import { extractMcpTools } from "../_extract-tools";
import { serializeMcpToolResult } from "../_serialize-result";
const logger = createLogger({ module: "mcpRoute" }); const logger = createLogger({ module: "mcpRoute" });
@@ -203,7 +204,7 @@ const mcpHandler = createMcpHandler(
const input = args && Object.keys(args).length > 0 ? args : undefined; const input = args && Object.keys(args).length > 0 ? args : undefined;
const result = await procedure(input); const result = await procedure(input);
return { return {
content: [{ type: "text" as const, text: JSON.stringify(result) }], content: [{ type: "text" as const, text: serializeMcpToolResult(result) }],
}; };
} catch (error) { } catch (error) {
const message = sanitizeErrorMessage(error); const message = sanitizeErrorMessage(error);
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { serializeMcpToolResult } from "./_serialize-result";
describe("serializeMcpToolResult", () => {
it("serializes objects as JSON strings", () => {
expect(serializeMcpToolResult({ boardId: "1" })).toBe('{"boardId":"1"}');
});
it("serializes primitive values", () => {
expect(serializeMcpToolResult("hello")).toBe('"hello"');
expect(serializeMcpToolResult(42)).toBe("42");
expect(serializeMcpToolResult(true)).toBe("true");
});
it("serializes null as the string null", () => {
expect(serializeMcpToolResult(null)).toBe("null");
});
it("serializes void results as the string null so MCP content text is always a string", () => {
expect(serializeMcpToolResult(undefined)).toBe("null");
});
});
@@ -0,0 +1,3 @@
export function serializeMcpToolResult(result: unknown): string {
return JSON.stringify(result) ?? "null";
}
@@ -44,9 +44,13 @@ interface WidgetContextMenuProps {
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => { export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
const { data: session } = useSession(); const { data: session } = useSession();
const [isEditMode] = useEditMode();
const settings = useSettings(); const settings = useSettings();
if (!session || !settings.enableRightClickOnWidgets) return <>{children}</>; if (!session || !settings.enableRightClickOnWidgets) return <>{children}</>;
const isLinkWidget = item.kind === "app" || item.kind === "bookmarks";
// Keep the Homarr menu in edit mode so these widgets can still be edited or removed.
if (isLinkWidget && !isEditMode) return <>{children}</>;
return ( return (
<WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}> <WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}>
@@ -301,6 +301,8 @@ interface UseCssVariableConfiguration {
isDynamic: boolean; isDynamic: boolean;
} }
const minimumResizeWidthDelta = 20;
/** /**
* This hook is used to configure the css variables for the gridstack * This hook is used to configure the css variables for the gridstack
* Those css variables are used to define the size of the gridstack items * Those css variables are used to define the size of the gridstack items
@@ -319,9 +321,30 @@ const useCssVariableConfiguration = ({
columnCount, columnCount,
isDynamic, isDynamic,
}: UseCssVariableConfiguration) => { }: UseCssVariableConfiguration) => {
const prevSizeRef = useRef<{ width: number; height: number } | null>(null);
const onResize = useCallback(() => { const onResize = useCallback(() => {
if (!wrapperRef.current) return; if (!wrapperRef.current) return;
if (!gridRef.current) return; if (!gridRef.current) return;
const currentWidth = wrapperRef.current.clientWidth;
const currentHeight = wrapperRef.current.clientHeight;
const prevSize = prevSizeRef.current;
// Only skip recalculation when the width change is insignificant
// and, for dynamic sections, the height is unchanged. This prevents infinite
// feedback loops when the vertical scrollbar appears/disappears, causing
// clientWidth to oscillate by ~15px, while still reacting to height changes.
if (
prevSize !== null &&
Math.abs(currentWidth - prevSize.width) < minimumResizeWidthDelta &&
(!isDynamic || currentHeight === prevSize.height)
) {
return;
}
prevSizeRef.current = { width: currentWidth, height: currentHeight };
handleResizeChange( handleResizeChange(
wrapperRef.current, wrapperRef.current,
gridRef.current, gridRef.current,
@@ -1,38 +1,17 @@
"use client"; "use client";
import type { ComponentProps, ReactNode } from "react"; import type { ReactNode } from "react";
import { Suspense, use, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Box, Loader, UnstyledButton, useMantineColorScheme } from "@mantine/core"; import { UnstyledButton, useMantineColorScheme } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks"; import { useHotkeys } from "@mantine/hooks";
import { ErrorBoundary } from "react-error-boundary";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions"; import { hotkeys } from "@homarr/definitions";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
import { UpdateIndicator } from "./update"; import { UpdateIndicator } from "./update";
type UserAvatarMenuModule = typeof import("~/components/user-avatar-menu");
type UserAvatarMenuProps = ComponentProps<UserAvatarMenuModule["UserAvatarMenu"]>;
let userAvatarMenuPromise: Promise<UserAvatarMenuModule> | undefined;
const loadUserAvatarMenu = () => {
if (userAvatarMenuPromise) return userAvatarMenuPromise;
const promise = import("~/components/user-avatar-menu");
userAvatarMenuPromise = promise;
void promise.catch(() => {
if (userAvatarMenuPromise === promise) userAvatarMenuPromise = undefined;
});
return promise;
};
const preloadUserAvatarMenu = () => void loadUserAvatarMenu().catch(() => undefined);
const LazyUserAvatarMenu = (props: UserAvatarMenuProps) => {
const { UserAvatarMenu } = use(loadUserAvatarMenu());
return <UserAvatarMenu {...props} />;
};
interface UserButtonClientProps { interface UserButtonClientProps {
avatar: ReactNode; avatar: ReactNode;
isAdmin: boolean; isAdmin: boolean;
@@ -41,8 +20,6 @@ interface UserButtonClientProps {
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => { export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false); const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
const [isMenuMounted, setIsMenuMounted] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const session = useSession(); const session = useSession();
const { toggleColorScheme } = useMantineColorScheme(); const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]); useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
@@ -66,60 +43,14 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButto
staleTime: 60 * 60 * 1_000, staleTime: 60 * 60 * 1_000,
}); });
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined; const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
const openMenu = () => {
setIsMenuMounted(true);
setIsMenuOpen(true);
};
const renderButton = (onClick: () => void, isLoading = false) => (
<UnstyledButton
onClick={onClick}
onFocus={preloadUserAvatarMenu}
onMouseEnter={preloadUserAvatarMenu}
onPointerDown={preloadUserAvatarMenu}
aria-busy={isLoading || undefined}
disabled={isLoading}
>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
<Box pos="relative" style={{ display: "inline-flex" }}>
<Box opacity={isLoading ? 0.35 : 1}>{avatar}</Box>
{isLoading && (
<Loader
aria-label="Loading user menu"
size={18}
pos="absolute"
top="50%"
left="50%"
style={{ transform: "translate(-50%, -50%)" }}
/>
)}
</Box>
</UpdateIndicator>
</UnstyledButton>
);
const button = renderButton(openMenu);
const loadingButton = renderButton(() => undefined, true);
if (!isMenuMounted) return button;
return ( return (
<ErrorBoundary <UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}>
fallbackRender={({ resetErrorBoundary }) => <UnstyledButton>
renderButton(() => { <UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
resetErrorBoundary(); {avatar}
openMenu(); </UpdateIndicator>
}) </UnstyledButton>
} </UserAvatarMenu>
>
<Suspense fallback={loadingButton}>
<LazyUserAvatarMenu
availableUpdates={visibleUpdates}
isDockerEnabled={isDockerEnabled}
opened={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
{button}
</LazyUserAvatarMenu>
</Suspense>
</ErrorBoundary>
); );
}; };
@@ -21,17 +21,9 @@ interface UserAvatarMenuProps {
children: ReactNode; children: ReactNode;
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"]; availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
isDockerEnabled?: boolean; isDockerEnabled?: boolean;
opened: boolean;
onOpenChange: (opened: boolean) => void;
} }
export const UserAvatarMenu = ({ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
children,
availableUpdates,
isDockerEnabled,
opened,
onOpenChange,
}: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu"); const t = useScopedI18n("common.userAvatar.menu");
const session = useSession(); const session = useSession();
@@ -48,7 +40,7 @@ export const UserAvatarMenu = ({
return ( return (
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board // We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withinPortal keepMounted opened={opened} onChange={onOpenChange}> <Menu width={300} withinPortal keepMounted>
<Menu.Dropdown> <Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} /> <AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}> <Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
+7
View File
@@ -429,6 +429,13 @@
<br/> <br/>
<p align="center">Uptime<br/>Kuma</p> <p align="center">Uptime<br/>Kuma</p>
</a> </a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/whats-up-docker" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg" alt="What's Up Docker" width="90" height="90" />
<br/>
<p align="center">What's<br/>Up<br/>Docker</p>
</a>
</td></tr> </td></tr>
</tbody> </tbody>
</table> </table>
+4 -1
View File
@@ -45,7 +45,10 @@ describe("Custom JSX custom widgets", () => {
await page.goto(`${baseUrl}/auth/login`); await page.goto(`${baseUrl}/auth/login`);
await page.getByLabel("Username").fill(adminCredentials.username); await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password); await page.locator("#password").fill(adminCredentials.password);
const loginNavigation = page.waitForURL(baseUrl, { timeout: 15_000, waitUntil: "commit" }); const loginNavigation = page.waitForURL((url) => url.origin === baseUrl && url.pathname !== "/auth/login", {
timeout: 60_000,
waitUntil: "commit",
});
await page.locator("css=button[type='submit']").click(); await page.locator("css=button[type='submit']").click();
await loginNavigation; await loginNavigation;
-36
View File
@@ -7,42 +7,6 @@ import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db"; import { createSqliteDbFileAsync } from "./shared/e2e-db";
describe("Onboarding", () => { describe("Onboarding", () => {
test("Credentials onboarding should be successful", async () => {
// Arrange
const { db, localMountPath } = await createSqliteDbFileAsync();
const homarrContainer = await createHomarrContainer({
mounts: {
"/appdata": localMountPath,
},
}).start();
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
const actions = new OnboardingActions(page, db);
const assertions = new OnboardingAssertions(page, db);
try {
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
await actions.startOnboardingAsync("scratch");
await actions.processUserStepAsync({
username: "admin",
password: "Comp(exP4sswOrd",
confirmPassword: "Comp(exP4sswOrd",
});
await actions.processSettingsStepAsync();
await actions.processIntegrationsStepAsync();
// Assert
await assertions.assertFinishStepVisibleAsync();
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
await assertions.assertDbOnboardingStepAsync("finish");
} finally {
await Promise.all([browser.close(), homarrContainer.stop()]);
}
}, 60_000);
test("External provider onboarding setup should be successful", async () => { test("External provider onboarding setup should be successful", async () => {
// Arrange // Arrange
const { db, localMountPath } = await createSqliteDbFileAsync(); const { db, localMountPath } = await createSqliteDbFileAsync();
+2 -2
View File
@@ -9,7 +9,7 @@ http {
# Route websockets traffic to port 3001 # Route websockets traffic to port 3001
location /websockets { location /websockets {
proxy_pass http://${HOSTNAME}:3001; proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade"; proxy_set_header Connection "Upgrade";
@@ -18,7 +18,7 @@ http {
# Route all other traffic to port 3000 # Route all other traffic to port 3000
location / { location / {
proxy_pass http://${HOSTNAME}:3000; proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homarr", "name": "homarr",
"version": "1.74.0", "version": "1.77.1",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "cross-env CI=true turbo build", "build": "cross-env CI=true turbo build",
+34 -31
View File
@@ -1,4 +1,4 @@
import { and, like } from "@homarr/db"; import { getImageMatchRank, normalizeImageName } from "@homarr/common";
import { icons } from "@homarr/db/schema"; import { icons } from "@homarr/db/schema";
import { iconsFindSchema } from "@homarr/validation/icons"; import { iconsFindSchema } from "@homarr/validation/icons";
@@ -15,38 +15,41 @@ export const iconsRouter = createTRPCRouter({
}) })
.input(iconsFindSchema) .input(iconsFindSchema)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const term = input.searchText?.toLowerCase().trim() ?? ""; const term = normalizeImageName(input.searchText ?? "");
const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0); const repositories = await ctx.db.query.iconRepositories.findMany({
orderBy: (table, { asc, sql }) => [sql`CASE WHEN ${table.slug} = 'local' THEN 0 ELSE 1 END`, asc(table.slug)],
let whereCondition = undefined; with: {
if (term.length > 0) { icons: {
whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`))); columns: { id: true, name: true, url: true },
} orderBy: (table, { asc, sql }) => [
sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`,
asc(table.name),
],
limit: term.length === 0 ? input.limitPerGroup : undefined,
},
},
});
return { return {
icons: await ctx.db.query.iconRepositories.findMany({ icons:
with: { term.length === 0
icons: { ? repositories
columns: { id: true, name: true, url: true }, : repositories.map((repository) => ({
where: whereCondition, ...repository,
orderBy: (table, { asc, sql }) => { icons: repository.icons
const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`; .flatMap((icon) => {
const nameAsc = asc(table.name); const rank = getImageMatchRank(term, icon.name || icon.url);
if (term.length === 0) { return rank === null ? [] : [{ icon, rank }];
return [svgFirst, nameAsc]; })
} .toSorted((a, b) =>
const tier = sql`CASE a.rank !== b.rank
WHEN LOWER(${table.name}) = ${term} THEN 0 ? a.rank - b.rank
WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1 : Number(b.icon.name.toLowerCase().endsWith(".svg")) -
WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2 Number(a.icon.name.toLowerCase().endsWith(".svg")) || a.icon.name.localeCompare(b.icon.name),
ELSE 3 )
END`; .slice(0, input.limitPerGroup)
return [tier, svgFirst, nameAsc]; .map(({ icon }) => icon),
}, })),
limit: input.limitPerGroup,
},
},
}),
countIcons: await ctx.db.$count(icons), countIcons: await ctx.db.$count(icons),
}; };
}), }),
@@ -0,0 +1,44 @@
import { describe, expect, test } from "vitest";
import { iconRepositories, icons } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { iconsRouter } from "../icons";
describe("findIcons", () => {
test.each(["homeassistant", "home_assistant", "home assistant.png"])(
"matches normalized search %s before applying the group limit",
async (searchText) => {
const db = createDb();
const caller = iconsRouter.createCaller({ db, deviceType: undefined, session: null });
await db.insert(iconRepositories).values({ id: "repository", slug: "dashboard-icons" });
await db.insert(icons).values([
{
id: "irrelevant",
name: "000-unrelated.svg",
url: "https://example.com/000-unrelated.svg",
checksum: "irrelevant",
iconRepositoryId: "repository",
},
{
id: "home-assistant",
name: "home-assistant.svg",
url: "https://example.com/home-assistant.svg",
checksum: "home-assistant",
iconRepositoryId: "repository",
},
]);
const result = await caller.findIcons({ searchText, limitPerGroup: 1 });
expect(result.icons[0]?.icons).toEqual([
{
id: "home-assistant",
name: "home-assistant.svg",
url: "https://example.com/home-assistant.svg",
},
]);
},
);
});
+1
View File
@@ -44,4 +44,5 @@ export const widgetRouter = createTRPCRouter({
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)), traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)), customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)), secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
wud: lazy(() => import("./wud").then((mod) => mod.wudRouter)),
}); });
+16 -1
View File
@@ -1,3 +1,4 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
@@ -41,6 +42,13 @@ export const smartHomeRouter = createTRPCRouter({
const client = await createIntegrationAsync(integration); const client = await createIntegrationAsync(integration);
const success = await client.triggerToggleAsync(input.entityId); const success = await client.triggerToggleAsync(input.entityId);
if (!success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Home Assistant failed to toggle the entity",
});
}
return success; return success;
}), }),
executeAutomation: protectedProcedure executeAutomation: protectedProcedure
@@ -55,6 +63,13 @@ export const smartHomeRouter = createTRPCRouter({
.input(z.object({ automationId: z.string() })) .input(z.object({ automationId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => { .mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration); const client = await createIntegrationAsync(integration);
await client.triggerAutomationAsync(input.automationId); const success = await client.triggerAutomationAsync(input.automationId);
if (!success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Home Assistant failed to trigger the automation",
});
}
}), }),
}); });
+25
View File
@@ -0,0 +1,25 @@
import { wudStatsRequestHandler } from "@homarr/request-handler/wud";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const wudRouter = createTRPCRouter({
getStats: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Returns monitored-container counts and available updates from a What's Up Docker integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
},
})
.concat(createOneIntegrationMiddleware("query", "wud"))
.query(async ({ ctx }) => {
const handler = wudStatsRequestHandler.handler(ctx.integration, {});
const { data, timestamp } = await handler.getDataAsync();
return {
stats: data,
updatedAt: timestamp,
};
}),
});
+1 -1
View File
@@ -43,7 +43,7 @@ async function importCookiesAsync() {
function getBaseUrl() { function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin; if (typeof window !== "undefined") return window.location.origin;
return `http://${process.env.HOSTNAME ?? "localhost"}:3000`; return "http://127.0.0.1:3000";
} }
export const trpcPath = "/api/trpc"; export const trpcPath = "/api/trpc";
+31
View File
@@ -28,3 +28,34 @@ export const bestMatch = <TItem extends Record<string, unknown>>(
return best; return best;
}, null); }, null);
}; };
export const normalizeImageName = (value: string) => {
const fileName = value.split(/[?#]/, 1)[0]?.split("/").pop() ?? value;
const withoutExtension = fileName.replace(/\.(?:avif|gif|jpe?g|png|svg|webp)$/i, "");
return withoutExtension
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/&/g, " and ")
.replace(/[^\p{L}\p{M}\p{N}]+/gu, " ")
.trim()
.toLowerCase();
};
export const getImageMatchRank = (normalizedSearch: string, candidateValue: string): number | null => {
const candidate = normalizeImageName(candidateValue);
if (!candidate) return null;
const compactSearch = normalizedSearch.replaceAll(" ", "");
const compactCandidate = candidate.replaceAll(" ", "");
if (candidate === normalizedSearch) return 0;
if (compactCandidate === compactSearch) return 1;
if (candidate.startsWith(`${normalizedSearch} `)) return 2;
const searchTokens = normalizedSearch.split(" ");
const candidateTokens = candidate.split(" ");
if (searchTokens.every((term) => candidateTokens.some((token) => token === term))) return 3;
if (searchTokens.every((term) => candidateTokens.some((token) => token.startsWith(term)))) return 4;
if (candidate.includes(normalizedSearch)) return 5;
return null;
};
+25 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { bestMatch, capitalize } from "../string"; import { bestMatch, capitalize, getImageMatchRank, normalizeImageName } from "../string";
const capitalizeTestCases = [ const capitalizeTestCases = [
["hello", "Hello"], ["hello", "Hello"],
@@ -54,3 +54,27 @@ describe("bestMatch should find the best match in an array of options", () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
}); });
describe("image name matching", () => {
test.each([
["home-assistant.svg", "home assistant"],
["https://example.com/Home_Assistant.PNG?v=1", "home assistant"],
["jelly.fin.webp", "jelly fin"],
["Caf\u00e9.svg", "cafe"],
["\u6771\u4eac.svg", "\u6771\u4eac"],
["\u0939\u093f\u0928\u094d\u0926\u0940.svg", "\u0939\u093f\u0928\u094d\u0926\u0940"],
])("normalizes %s", (value, expected) => {
expect(normalizeImageName(value)).toBe(expected);
});
test.each([
["homeassistant", "home-assistant.svg", 1],
["home assistant", "home_assistant.png", 0],
["home assistant", "Home Assistant.webp", 0],
["\u6771\u4eac", "\u6771\u4eac.svg", 0],
["\u0939\u093f\u0928\u094d\u0926\u0940", "\u0939\u093f\u0928\u094d\u0926\u0940.png", 0],
["home assistant", "unrelated.svg", null],
])("ranks %s against %s", (search, candidate, expected) => {
expect(getImageMatchRank(normalizeImageName(search), candidate)).toBe(expected);
});
});
@@ -13,7 +13,6 @@ import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
import type { TrustedCertificateHostname } from "../certificates/hostnames"; import type { TrustedCertificateHostname } from "../certificates/hostnames";
import { withTimeoutAsync } from "./timeout"; import { withTimeoutAsync } from "./timeout";
export const createCustomCheckServerIdentity = ( export const createCustomCheckServerIdentity = (
trustedHostnames: TrustedCertificateHostname[], trustedHostnames: TrustedCertificateHostname[],
): typeof checkServerIdentity => { ): typeof checkServerIdentity => {
@@ -210,6 +210,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/unifi-controller" | "/docs/integrations/unifi-controller"
| "/docs/integrations/unraid" | "/docs/integrations/unraid"
| "/docs/integrations/uptime-kuma" | "/docs/integrations/uptime-kuma"
| "/docs/integrations/whats-up-docker"
| "/docs/management/api" | "/docs/management/api"
| "/docs/management/apps" | "/docs/management/apps"
| "/docs/management/backup" | "/docs/management/backup"
@@ -276,6 +277,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/video" | "/docs/widgets/video"
| "/docs/widgets/vpn" | "/docs/widgets/vpn"
| "/docs/widgets/weather" | "/docs/widgets/weather"
| "/docs/widgets/whats-up-docker"
| "" | ""
| "/sitemap.xml" | "/sitemap.xml"
| "/docs/integrations/sabnzbd" | "/docs/integrations/sabnzbd"
@@ -330,6 +332,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/peanut" | "/docs/integrations/peanut"
| "/docs/integrations/beszel" | "/docs/integrations/beszel"
| "/docs/integrations/traefik" | "/docs/integrations/traefik"
| "/docs/integrations/whats-up-docker"
| "/docs/widgets/clock" | "/docs/widgets/clock"
| "/docs/widgets/weather" | "/docs/widgets/weather"
| "/docs/widgets/app" | "/docs/widgets/app"
@@ -382,4 +385,5 @@ export type HomarrDocumentationPath =
| "/docs/widgets/beszel-alerts" | "/docs/widgets/beszel-alerts"
| "/docs/widgets/beszel-system-stats" | "/docs/widgets/beszel-system-stats"
| "/docs/widgets/traefik" | "/docs/widgets/traefik"
| "/docs/widgets/custom-api"; | "/docs/widgets/custom-api"
| "/docs/widgets/whats-up-docker";
@@ -54,5 +54,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
beszel: "beszel", beszel: "beszel",
traefik: "traefik", traefik: "traefik",
archiveTeamWarrior: null, archiveTeamWarrior: null,
wud: "whats-up-docker",
mock: null, mock: null,
}; };
@@ -55,4 +55,5 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
beszelSystemStats: "beszel-system-stats", beszelSystemStats: "beszel-system-stats",
traefik: "traefik", traefik: "traefik",
customApi: "custom-api", customApi: "custom-api",
wud: "whats-up-docker",
}; };
+8
View File
@@ -468,6 +468,14 @@ export const integrationDefs = {
category: ["archiving"], category: ["archiving"],
documentationUrl: null, documentationUrl: null,
}, },
wud: {
name: "What's Up Docker",
secretKinds: [[], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/whats-up-docker"),
defaultPort: 3000,
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) // This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: { mock: {
name: "Mock", name: "Mock",
@@ -24,7 +24,9 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
networkControllerSummary: getIntegrationKindsByCategory("networkController"), networkControllerSummary: getIntegrationKindsByCategory("networkController"),
networkControllerStatus: getIntegrationKindsByCategory("networkController"), networkControllerStatus: getIntegrationKindsByCategory("networkController"),
indexerManager: getIntegrationKindsByCategory("indexerManager"), indexerManager: getIntegrationKindsByCategory("indexerManager"),
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"), healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter(
(kind) => kind !== "patchmon" && kind !== "wud",
),
firewall: getIntegrationKindsByCategory("firewall"), firewall: getIntegrationKindsByCategory("firewall"),
notifications: getIntegrationKindsByCategory("notifications"), notifications: getIntegrationKindsByCategory("notifications"),
mediaReleases: ["mock", "emby", "jellyfin", "plex"], mediaReleases: ["mock", "emby", "jellyfin", "plex"],
@@ -50,6 +52,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
archiveTeamWarrior: ["archiveTeamWarrior"], archiveTeamWarrior: ["archiveTeamWarrior"],
anchorNote: ["anchor"], anchorNote: ["anchor"],
traefik: ["traefik"], traefik: ["traefik"],
wud: ["wud"],
}; };
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => { export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
+1
View File
@@ -53,6 +53,7 @@ export const widgetKinds = [
"beszelSystemStats", "beszelSystemStats",
"traefik", "traefik",
"customApi", "customApi",
"wud",
] as const; ] as const;
export type WidgetKind = (typeof widgetKinds)[number]; export type WidgetKind = (typeof widgetKinds)[number];
@@ -1,3 +1,89 @@
[data-combobox-selected="true"] .iconCard { .dropdown {
border-color: var(--mantine-primary-color-6); min-width: min(32rem, calc(100vw - 2rem));
}
.options {
max-height: min(28rem, 60vh);
overflow-y: auto;
overscroll-behavior: contain;
}
.iconGrid {
grid-template-columns: repeat(auto-fill, minmax(3.25rem, 1fr));
}
.iconOption {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
height: 3.25rem;
padding: var(--mantine-spacing-xs);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
transition:
border-color 160ms ease,
background-color 160ms ease,
transform 160ms ease;
}
.iconOption:hover,
.iconOption[data-combobox-selected="true"] {
border-color: var(--mantine-primary-color-6);
background: var(--mantine-primary-color-light);
}
.iconOption:active {
transform: scale(0.96);
}
.formatBadge,
.sectionBadge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--mantine-radius-xs);
background: var(--mantine-color-red-9);
color: var(--mantine-color-white);
font-size: 0.625rem;
font-weight: 700;
line-height: 1;
}
.formatBadge {
position: absolute;
top: 0.2rem;
right: 0.2rem;
padding: 0.15rem 0.2rem;
}
.sectionBadge {
padding: 0.2rem 0.3rem;
}
.urlPreview {
background: var(--mantine-color-default-hover);
}
.previewFrame {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
flex: 0 0 3rem;
}
.previewLoader {
position: absolute;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.iconOption {
transition: none;
}
} }
@@ -4,31 +4,84 @@ import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vi
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import type { Root } from "react-dom/client"; import type { Root } from "react-dom/client";
import type { IconGroup } from "./icon-picker.utils";
const { findIconsQuery } = vi.hoisted(() => ({ const { findIconsQuery, refetch, uploadSelection, sessionPermissions } = vi.hoisted(() => ({
findIconsQuery: vi.fn((_query: { searchText: string }) => ({ findIconsQuery: vi.fn(),
data: { icons: [], countIcons: 0 }, refetch: vi.fn(),
isLoading: false, uploadSelection: vi.fn(),
isError: false, sessionPermissions: { current: [] as string[] },
refetch: vi.fn(),
})),
})); }));
interface QueryData {
icons: IconGroup[];
countIcons: number;
}
const emptyResult: QueryData = { icons: [], countIcons: 11_000 };
let queryResult: {
data?: QueryData;
isLoading: boolean;
isFetching: boolean;
isError: boolean;
};
vi.mock("@homarr/api/client", () => ({ vi.mock("@homarr/api/client", () => ({
clientApi: { icon: { findIcons: { useQuery: findIconsQuery } } }, clientApi: {
icon: {
findIcons: {
useQuery: (input: { searchText: string; limitPerGroup: number }, options: { enabled: boolean }) => {
findIconsQuery(input, options);
return { ...queryResult, refetch };
},
},
},
},
})); }));
vi.mock("@homarr/auth/client", () => ({ vi.mock("@homarr/auth/client", () => ({
useSession: () => ({ data: { user: { permissions: [] } } }), useSession: () => ({ data: { user: { permissions: sessionPermissions.current } } }),
})); }));
vi.mock("@homarr/translation", () => ({ supportedLanguages: [] })); vi.mock("@homarr/translation", () => ({ supportedLanguages: [] }));
vi.mock("@homarr/translation/client", () => ({ vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) => key, useScopedI18n: () => (key: string, params?: Record<string, string>) =>
params ? `${key}:${Object.values(params).join(",")}` : key,
}));
vi.mock("../upload-media/upload-media", () => ({
UploadMedia: ({
children,
onSuccess,
}: {
children: (props: object) => unknown;
onSuccess: (medias: unknown) => void;
}) =>
children({
loading: false,
onClick: () => {
uploadSelection();
onSuccess([{ id: "uploaded", url: "/api/user-medias/uploaded" }]);
},
}),
})); }));
vi.mock("../upload-media/upload-media", () => ({ UploadMedia: () => null }));
import { IconPicker } from "./icon-picker"; import { IconPicker } from "./icon-picker";
const directIconUrl = "https://cdn.example.com/shopify.svg"; const directImageUrl = "https://cdn.example.com/shopify.svg";
const iconGroups = [
{
id: "remote",
slug: "dashboard-icons",
icons: [
{ id: "png", name: "shopify.png", url: "https://cdn.example.com/shopify.png" },
{ id: "svg", name: "shopify.svg", url: "https://cdn.example.com/shopify.svg" },
],
},
{
id: "local",
slug: "local",
icons: [{ id: "local-icon", name: "my-shop.png", url: "/api/user-medias/local-icon" }],
},
];
const setInputValue = (input: HTMLInputElement, value: string) => { const setInputValue = (input: HTMLInputElement, value: string) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
@@ -37,6 +90,18 @@ const setInputValue = (input: HTMLInputElement, value: string) => {
input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("input", { bubbles: true }));
}; };
const pressKey = (input: HTMLInputElement, key: string, init?: KeyboardEventInit) => {
input.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...init }));
};
const focusInput = async (input: HTMLInputElement) => {
await act(async () => input.focus());
};
const advanceDebounce = async () => {
await act(async () => vi.advanceTimersByTimeAsync(300));
};
describe("IconPicker", () => { describe("IconPicker", () => {
let container: HTMLDivElement; let container: HTMLDivElement;
let root: Root; let root: Root;
@@ -45,9 +110,9 @@ describe("IconPicker", () => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true }); Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
Object.defineProperty(window, "matchMedia", { Object.defineProperty(window, "matchMedia", {
writable: true, writable: true,
value: (query: string) => ({ value: (media: string) => ({
matches: false, matches: false,
media: query, media,
onchange: null, onchange: null,
addListener: () => undefined, addListener: () => undefined,
removeListener: () => undefined, removeListener: () => undefined,
@@ -56,54 +121,186 @@ describe("IconPicker", () => {
dispatchEvent: () => false, dispatchEvent: () => false,
}), }),
}); });
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { value: () => undefined, writable: true });
}); });
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers();
findIconsQuery.mockClear(); findIconsQuery.mockClear();
refetch.mockClear();
uploadSelection.mockClear();
sessionPermissions.current = [];
queryResult = { data: emptyResult, isLoading: false, isFetching: false, isError: false };
}); });
afterEach(() => { afterEach(() => {
root.unmount(); act(() => root.unmount());
container.remove(); container.remove();
document.querySelectorAll("[data-combobox-dropdown]").forEach((element) => element.remove());
vi.useRealTimers();
}); });
const renderPicker = async (onChange: (value: string) => void) => { const renderPicker = async (
onChange: (value: string) => void,
props: Partial<React.ComponentProps<typeof IconPicker>> = {},
) => {
container = document.createElement("div"); container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
root = createRoot(container); root = createRoot(container);
await act(async () => { await act(async () => {
root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange }))); root.render(createElement(MantineProvider, null, createElement(IconPicker, { value: "", onChange, ...props })));
}); });
const input = container.querySelector("input"); const input = container.querySelector("input");
if (!input) throw new Error("Icon picker input was not rendered"); if (!input) throw new Error("Icon picker input was not rendered");
return input; return input;
}; };
test("keeps a direct URL visible without searching the icon repositories", async () => { test("clears an existing value without restoring it", async () => {
const onChange = vi.fn(); const onChange = vi.fn();
const input = await renderPicker(onChange); const input = await renderPicker(onChange, { value: directImageUrl });
await act(async () => { await act(async () => setInputValue(input, ""));
setInputValue(input, directIconUrl);
await new Promise((resolve) => setTimeout(resolve, 150));
});
expect(onChange).toHaveBeenCalledWith(directIconUrl); expect(onChange).toHaveBeenCalledWith("");
expect(input.value).toBe(directIconUrl); expect(input.value).toBe("");
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === directIconUrl)).toBe(false);
}); });
test("keeps icon names in repository search mode", async () => { test.each([{ metaKey: true }, { ctrlKey: true }])(
const onChange = vi.fn(); "clears the entire value with a line-delete shortcut",
const input = await renderPicker(onChange); async (modifier) => {
const onChange = vi.fn();
const input = await renderPicker(onChange, { value: directImageUrl });
await act(async () => { await act(async () => pressKey(input, "Backspace", modifier));
setInputValue(input, "shopify");
await new Promise((resolve) => setTimeout(resolve, 150));
});
expect(onChange).not.toHaveBeenCalled(); expect(onChange).toHaveBeenCalledWith("");
expect(input.value).toBe("shopify"); expect(input.value).toBe("");
},
);
test("debounces repository searches", async () => {
const input = await renderPicker(vi.fn());
findIconsQuery.mockClear();
await act(async () => setInputValue(input, "shopify"));
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(false);
await advanceDebounce();
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(true); expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "shopify")).toBe(true);
}); });
test("pre-searches the suggested app name when opened", async () => {
const input = await renderPicker(vi.fn(), { suggestedSearch: "Jellyfin" });
await focusInput(input);
expect(input.value).toBe("Jellyfin");
await advanceDebounce();
expect(findIconsQuery.mock.calls.some(([query]) => query.searchText === "Jellyfin")).toBe(true);
});
test("shows local images before SVG and raster library images", async () => {
queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
const input = await renderPicker(vi.fn());
await focusInput(input);
const content = document.body.textContent ?? "";
expect(document.body.querySelector('[role="listbox"]')?.getAttribute("aria-label")).toBe("iconPicker.results");
expect(content.indexOf("iconPicker.localImages")).toBeLessThan(content.indexOf("iconPicker.svgIcons"));
expect(content.indexOf("iconPicker.svgIcons")).toBeLessThan(content.indexOf("iconPicker.otherImages"));
});
test("selects a library image with a pointer", async () => {
queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
const onChange = vi.fn();
const input = await renderPicker(onChange);
await focusInput(input);
const option = document.body.querySelector<HTMLElement>(
'[data-combobox-option][value="/api/user-medias/local-icon"]',
);
if (!option) throw new Error("Local image option was not rendered");
await act(async () => option.click());
expect(onChange).toHaveBeenCalledWith("/api/user-medias/local-icon");
expect(input.value).toBe("/api/user-medias/local-icon");
});
test("selects a library image with the keyboard", async () => {
queryResult = { data: { icons: iconGroups, countIcons: 3 }, isLoading: false, isFetching: false, isError: false };
const onChange = vi.fn();
const input = await renderPicker(onChange);
await focusInput(input);
await act(async () => pressKey(input, "ArrowDown"));
await act(async () => pressKey(input, "Enter"));
expect(onChange).toHaveBeenCalledWith("/api/user-medias/local-icon");
});
test("keeps a direct URL visible and reports a successful preview", async () => {
const onChange = vi.fn();
const input = await renderPicker(onChange);
await act(async () => setInputValue(input, directImageUrl));
expect(onChange).toHaveBeenCalledWith(directImageUrl);
expect(input.value).toBe(directImageUrl);
expect(findIconsQuery.mock.calls.at(-1)?.[1]).toMatchObject({ enabled: false });
expect(document.body.textContent).toContain("iconPicker.urlLoading");
const preview = document.body.querySelector('[data-testid="direct-url-preview"]');
if (!preview) throw new Error("Direct URL preview was not rendered");
await act(async () => preview.dispatchEvent(new Event("load")));
expect(document.body.textContent).toContain("iconPicker.urlReady");
});
test("keeps a failed direct URL editable and shows an error", async () => {
const input = await renderPicker(vi.fn());
await act(async () => setInputValue(input, "https://invalid.example/image.png"));
const preview = document.body.querySelector('[data-testid="direct-url-preview"]');
if (!preview) throw new Error("Direct URL preview was not rendered");
await act(async () => preview.dispatchEvent(new Event("error")));
expect(input.value).toBe("https://invalid.example/image.png");
expect(document.body.textContent).toContain("iconPicker.urlError");
});
test("restores the committed value when an unfinished search is cancelled", async () => {
const input = await renderPicker(vi.fn(), { value: directImageUrl });
await act(async () => setInputValue(input, "unfinished search"));
await act(async () => pressKey(input, "Escape"));
expect(input.value).toBe(directImageUrl);
});
test("offers retry when the repository search fails", async () => {
queryResult = { data: undefined, isLoading: false, isFetching: false, isError: true };
const input = await renderPicker(vi.fn());
await focusInput(input);
const retry = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent === "action.tryAgain",
);
if (!retry) throw new Error("Retry button was not rendered");
await act(async () => retry.click());
expect(refetch).toHaveBeenCalledOnce();
});
test("selects an uploaded local image when the user has permission", async () => {
sessionPermissions.current = ["media-upload"];
const onChange = vi.fn();
await renderPicker(onChange);
const upload = container.querySelector<HTMLButtonElement>('[aria-label="iconPicker.uploadImage"]');
if (!upload) throw new Error("Upload button was not rendered");
await act(async () => upload.click());
expect(uploadSelection).toHaveBeenCalledOnce();
expect(onChange).toHaveBeenCalledWith("/api/user-medias/uploaded");
});
}); });
@@ -1,27 +1,36 @@
"use client"; "use client";
import type { FocusEventHandler } from "react"; import type { FocusEventHandler, KeyboardEventHandler } from "react";
import { startTransition, useEffect, useState } from "react"; import { startTransition, useEffect, useMemo, useState } from "react";
import { import {
ActionIcon, ActionIcon,
Box, Box,
Button, Button,
Card, CloseButton,
Combobox, Combobox,
Flex,
Group, Group,
Image, Image,
Indicator,
InputBase, InputBase,
Loader,
Paper, Paper,
SimpleGrid,
Skeleton, Skeleton,
Stack, Stack,
Text, Text,
UnstyledButton, ThemeIcon,
Tooltip,
useCombobox, useCombobox,
} from "@mantine/core"; } from "@mantine/core";
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks"; import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react"; import {
IconCheck,
IconCloud,
IconMoodEmpty,
IconPhoto,
IconPhotoOff,
IconSearchOff,
IconUpload,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
@@ -29,6 +38,7 @@ import { supportedLanguages } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { UploadMedia } from "../upload-media/upload-media"; import { UploadMedia } from "../upload-media/upload-media";
import { arrangeIconPickerSections, isDirectImageUrl, isImageSource, isSvgImage } from "./icon-picker.utils";
import classes from "./icon-picker.module.css"; import classes from "./icon-picker.module.css";
interface IconPickerProps { interface IconPickerProps {
@@ -39,9 +49,12 @@ interface IconPickerProps {
onBlur?: FocusEventHandler; onBlur?: FocusEventHandler;
label?: string; label?: string;
placeholder?: string; placeholder?: string;
suggestedSearch?: string | null;
withAsterisk?: boolean; withAsterisk?: boolean;
} }
type PreviewState = "idle" | "loading" | "ready" | "error";
export const IconPicker = ({ export const IconPicker = ({
value: propsValue, value: propsValue,
onChange, onChange,
@@ -51,188 +64,403 @@ export const IconPicker = ({
withAsterisk = true, withAsterisk = true,
label, label,
placeholder, placeholder,
suggestedSearch,
}: IconPickerProps) => { }: IconPickerProps) => {
const [value, setValue] = useUncontrolled({ value: propsValue, onChange }); const [value, setValue] = useUncontrolled({ value: propsValue, onChange, defaultValue: "" });
const [query, setQuery] = useState(""); const [draft, setDraft] = useState(propsValue ?? "");
const [hasEdited, setHasEdited] = useState(false);
useEffect(() => { const [keyboardIndex, setKeyboardIndex] = useState(-1);
setQuery(""); const [previewState, setPreviewState] = useState<PreviewState>("idle");
}, [value]);
const { data: session } = useSession(); const { data: session } = useSession();
const tCommon = useScopedI18n("common"); const tCommon = useScopedI18n("common");
const [debouncedQuery] = useDebouncedValue(query, 100); const [debouncedDraft] = useDebouncedValue(draft, 250);
const searchText = !isDirectIconUrl(debouncedQuery) && debouncedQuery !== (value ?? "") ? debouncedQuery : ""; const directUrl = isDirectImageUrl(draft) ? draft.trim() : null;
const isCommittedValueDraft = debouncedDraft === (value ?? "");
const searchText = directUrl !== null || isCommittedValueDraft ? "" : debouncedDraft.trim();
const canUpload = session?.user.permissions.includes("media-upload") ?? false;
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery( const query = clientApi.icon.findIcons.useQuery(
{ searchText }, { searchText, limitPerGroup: 24 },
{ placeholderData: (prev) => prev }, { enabled: directUrl === null, placeholderData: (previous) => previous },
); );
const combobox = useCombobox({ const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(), onDropdownClose: () => combobox.resetSelectedOption(),
onDropdownOpen: () => combobox.updateSelectedOptionIndex("active"),
}); });
const totalOptions = data?.icons.reduce((acc, group) => acc + group.icons.length, 0) ?? 0; useEffect(() => {
const groups = data?.icons.map((group) => { setDraft(value ?? "");
const options = group.icons.map((item) => ( setHasEdited(false);
<Combobox.Option }, [value]);
key={item.id}
value={item.url}
p={0}
h="calc(2*var(--mantine-spacing-sm)+25px)"
w="calc(2*var(--mantine-spacing-sm)+25px)"
>
<UnstyledButton
onClick={() => {
startTransition(() => {
setValue(item.url);
setQuery("");
combobox.closeDropdown();
});
}}
>
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
<Card p="sm" pos="relative" style={{ overflow: "visible", cursor: "pointer" }} className={classes.iconCard}>
<Box w={25} h={25}>
<Image src={item.url} w={25} h={25} />
</Box>
</Card>
</Indicator>
</UnstyledButton>
</Combobox.Option>
));
return ( useEffect(() => {
<Paper p="xs" key={group.slug} pt={2}> setPreviewState(directUrl ? "loading" : "idle");
<Text mb={8} size="sm" fw="bold"> }, [directUrl]);
{group.slug}
</Text>
<Flex gap={8} wrap={"wrap"}>
{options}
</Flex>
</Paper>
);
});
const inputValue = query || value || ""; const sections = useMemo(
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />; () => arrangeIconPickerSections(query.data?.icons ?? [], searchText),
const placeholderText = [query.data?.icons, searchText],
placeholder || );
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) || const orderedOptions = useMemo(
tCommon("iconPicker.headerLoading"); () => [...sections.local, ...sections.svg, ...sections.other],
[sections.local, sections.svg, sections.other],
);
const totalOptions = sections.local.length + sections.svg.length + sections.other.length;
const renderDropdown = () => { const commitValue = (nextValue: string) => {
if (isError) { startTransition(() => {
setValue(nextValue);
setDraft(nextValue);
setHasEdited(false);
setKeyboardIndex(-1);
});
};
const clearValue = () => {
commitValue("");
combobox.openDropdown("keyboard");
};
const openWithSuggestedSearch = () => {
if (!draft && !value && !hasEdited && suggestedSearch?.trim()) {
setDraft(suggestedSearch.trim());
}
combobox.openDropdown();
};
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Backspace") {
event.preventDefault();
clearValue();
return;
}
if (event.key === "Escape") {
setDraft(value ?? "");
setHasEdited(false);
combobox.closeDropdown("keyboard");
return;
}
if (!directUrl && orderedOptions.length > 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
event.preventDefault();
combobox.openDropdown("keyboard");
setKeyboardIndex((current) => {
const next =
event.key === "ArrowDown"
? current >= orderedOptions.length - 1
? 0
: current + 1
: current <= 0
? orderedOptions.length - 1
: current - 1;
combobox.selectOption(next);
return next;
});
return;
}
if (event.key === "Enter") {
if (keyboardIndex >= 0 && orderedOptions[keyboardIndex]) {
event.preventDefault();
commitValue(orderedOptions[keyboardIndex].url);
combobox.closeDropdown("keyboard");
} else if (isImageSource(draft)) {
event.preventDefault();
commitValue(draft.trim());
combobox.closeDropdown("keyboard");
}
}
};
const renderIconOptions = (icons: typeof sections.local, offset: number) => (
<SimpleGrid className={classes.iconGrid} spacing={6} verticalSpacing={6}>
{icons.map((icon, index) => (
<Tooltip key={icon.id} label={`${icon.name} · ${icon.repositorySlug}`} openDelay={500}>
<Combobox.Option
value={icon.url}
aria-label={icon.name}
active={icon.url === value || keyboardIndex === offset + index}
className={classes.iconOption}
>
<Image src={icon.url} alt="" w={30} h={30} fit="contain" fallbackSrc="/logo/logo.png" />
{isSvgImage(icon.url) && <span className={classes.formatBadge}>SVG</span>}
</Combobox.Option>
</Tooltip>
))}
</SimpleGrid>
);
const renderSearchResults = () => {
if (query.isError) {
return ( return (
<Stack align="center" gap="xs" p="md"> <PickerMessage
<IconSearchOff size={32} stroke={1.5} /> icon={<IconSearchOff size={22} />}
<Text size="sm">{tCommon("iconPicker.error")}</Text> title={tCommon("iconPicker.error")}
<Button size="xs" variant="default" onClick={() => refetch()}> action={
{tCommon("action.tryAgain")} <Button size="xs" variant="default" onClick={() => query.refetch()}>
</Button> {tCommon("action.tryAgain")}
</Stack> </Button>
}
/>
); );
} }
if (isLoading) {
return Array(15) if (query.isLoading) {
.fill(0) return (
.map((_, index) => ( <SimpleGrid className={classes.iconGrid} spacing={6} verticalSpacing={6} p="xs">
<Combobox.Option value={`skeleton-${index}`} key={index} disabled> {Array.from({ length: 12 }, (_, index) => (
<Skeleton height={25} visible /> <Skeleton key={index} height={52} radius="md" />
</Combobox.Option> ))}
)); </SimpleGrid>
);
} }
if (totalOptions > 0) {
return <Stack gap={4}>{groups}</Stack>; if (totalOptions === 0) {
return (
<PickerMessage
icon={<IconMoodEmpty size={22} />}
title={tCommon("iconPicker.noResults", { search: debouncedDraft.trim() })}
/>
);
} }
return ( return (
<Stack align="center" gap="xs" p="md"> <Stack gap="sm" p="xs">
<IconMoodEmpty size={32} stroke={1.5} /> {sections.local.length > 0 && (
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text> <PickerSection icon={<IconPhoto size={15} />} label={tCommon("iconPicker.localImages")}>
{renderIconOptions(sections.local, 0)}
</PickerSection>
)}
{sections.svg.length > 0 && (
<PickerSection label={tCommon("iconPicker.svgIcons")} badge="SVG">
{renderIconOptions(sections.svg, sections.local.length)}
</PickerSection>
)}
{sections.other.length > 0 && (
<PickerSection icon={<IconCloud size={15} />} label={tCommon("iconPicker.otherImages")}>
{renderIconOptions(sections.other, sections.local.length + sections.svg.length)}
</PickerSection>
)}
</Stack> </Stack>
); );
}; };
const leftSection = (() => {
if (directUrl && previewState === "loading") return <Loader size={15} />;
if (directUrl && previewState === "error") return <IconPhotoOff size={17} color="var(--mantine-color-red-6)" />;
if (shouldShowPreview(value)) {
return (
<Image
src={value}
alt=""
w={22}
h={22}
fit="contain"
onLoad={() => directUrl && setPreviewState("ready")}
onError={() => directUrl && setPreviewState("error")}
/>
);
}
return <IconPhoto size={17} stroke={1.5} />;
})();
const placeholderText =
placeholder ??
(query.data
? tCommon("iconPicker.header", { countIcons: String(query.data.countIcons) })
: tCommon("iconPicker.headerLoading"));
return ( return (
<Combobox store={combobox} withinPortal> <Combobox
<Combobox.Target> store={combobox}
<Group wrap="nowrap" gap="xs" w="100%" align="start"> withinPortal
<InputBase resetSelectionOnOptionHover
flex={1} onOptionSubmit={(url) => {
rightSection={<Combobox.Chevron />} commitValue(url);
leftSection={leftSection} combobox.closeDropdown();
value={inputValue} }}
onChange={(event) => { >
const nextValue = event.currentTarget.value; <Combobox.Target withExpandedAttribute>
const trimmedValue = nextValue.trim(); <InputBase
setQuery(nextValue); flex={1}
if (isDirectIconUrl(trimmedValue)) { leftSection={leftSection}
setValue(trimmedValue); rightSection={
combobox.closeDropdown(); <Group gap={2} wrap="nowrap">
} else { {canUpload && (
combobox.openDropdown(); <UploadMedia
} onSuccess={(medias) => {
}} const first = medias.at(0);
onClick={() => combobox.openDropdown()} if (!first) return;
onFocus={(event) => { commitValue(first.url);
onFocus?.(event); combobox.closeDropdown();
combobox.openDropdown(); }}
}} >
onBlur={(event) => { {({ onClick, loading }) => (
onBlur?.(event); <Tooltip label={tCommon("iconPicker.uploadImage")}>
combobox.closeDropdown(); <ActionIcon
setQuery(""); onClick={(event) => {
}} event.stopPropagation();
rightSectionPointerEvents="none" onClick();
withAsterisk={withAsterisk} }}
error={error} loading={loading}
label={label ?? tCommon("iconPicker.label")} size="sm"
placeholder={placeholderText} variant="subtle"
/> aria-label={tCommon("iconPicker.uploadImage")}
{session?.user.permissions.includes("media-upload") && ( >
<UploadMedia <IconUpload size={16} stroke={1.5} />
onSuccess={(medias) => { </ActionIcon>
const first = medias.at(0); </Tooltip>
if (!first) return; )}
startTransition(() => { </UploadMedia>
setValue(first.url);
});
}}
>
{({ onClick, loading }) => (
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
<IconUpload size={16} stroke={1.5} />
</ActionIcon>
)} )}
</UploadMedia> {(draft || value) && (
)} <CloseButton
</Group> size="sm"
aria-label={tCommon("iconPicker.clear")}
onMouseDown={(event) => event.preventDefault()}
onClick={clearValue}
/>
)}
{query.isFetching && !directUrl ? <Loader size={14} /> : <Combobox.Chevron />}
</Group>
}
rightSectionWidth={canUpload ? 86 : 56}
rightSectionPointerEvents="all"
value={draft}
onChange={(event) => {
const nextValue = event.currentTarget.value;
setDraft(nextValue);
setHasEdited(true);
setKeyboardIndex(-1);
combobox.openDropdown("keyboard");
combobox.updateSelectedOptionIndex();
if (!nextValue) {
setValue("");
} else if (isImageSource(nextValue)) {
setValue(nextValue.trim());
}
}}
onKeyDown={handleKeyDown}
onClick={openWithSuggestedSearch}
onFocus={(event) => {
onFocus?.(event);
openWithSuggestedSearch();
}}
onBlur={(event) => {
onBlur?.(event);
combobox.closeDropdown();
setDraft(value ?? "");
setHasEdited(false);
}}
withAsterisk={withAsterisk}
error={error}
label={label ?? tCommon("iconPicker.label")}
description={tCommon("iconPicker.description")}
styles={{
description: { color: "var(--mantine-color-text)" },
input: { color: "var(--mantine-color-text)" },
error: { color: "light-dark(var(--mantine-color-red-9), var(--mantine-color-red-2))" },
}}
placeholder={placeholderText}
autoComplete="off"
spellCheck={false}
/>
</Combobox.Target> </Combobox.Target>
<Combobox.Dropdown> <Combobox.Dropdown className={classes.dropdown}>
<Combobox.Options mah={350} style={{ overflowY: "auto" }}> <Combobox.Options className={classes.options} aria-label={tCommon("iconPicker.results")}>
{renderDropdown()} {directUrl ? (
<Paper p="sm" m="xs" className={classes.urlPreview}>
<Group wrap="nowrap" align="center">
<Box className={classes.previewFrame}>
{previewState === "loading" && <Loader size={18} className={classes.previewLoader} />}
{previewState === "error" ? (
<ThemeIcon variant="light" color="red" size={40}>
<IconPhotoOff size={21} />
</ThemeIcon>
) : (
<Image
src={directUrl}
alt=""
w={40}
h={40}
fit="contain"
data-testid="direct-url-preview"
onLoad={() => setPreviewState("ready")}
onError={() => setPreviewState("error")}
/>
)}
</Box>
<Box flex={1} miw={0}>
<Text size="sm" fw={600}>
{previewState === "error"
? tCommon("iconPicker.urlError")
: previewState === "ready"
? tCommon("iconPicker.urlReady")
: tCommon("iconPicker.urlLoading")}
</Text>
<Text size="xs" truncate>
{directUrl}
</Text>
</Box>
{previewState === "ready" && (
<ThemeIcon variant="light" color="green" size="sm" aria-hidden>
<IconCheck size={14} />
</ThemeIcon>
)}
</Group>
</Paper>
) : (
renderSearchResults()
)}
</Combobox.Options> </Combobox.Options>
</Combobox.Dropdown> </Combobox.Dropdown>
</Combobox> </Combobox>
); );
}; };
// This regex is used to prevent loading a preview like en or /en which would trigger a language change interface PickerSectionProps {
// See https://github.com/homarr-labs/homarr/issues/3070 label: string;
icon?: React.ReactNode;
badge?: string;
children: React.ReactNode;
}
const PickerSection = ({ label, icon, badge, children }: PickerSectionProps) => (
<Stack gap={6}>
<Group gap={6} px={2}>
{icon}
<Text size="xs" fw={600}>
{label}
</Text>
{badge && <span className={classes.sectionBadge}>{badge}</span>}
</Group>
{children}
</Stack>
);
interface PickerMessageProps {
icon: React.ReactNode;
title: string;
action?: React.ReactNode;
}
const PickerMessage = ({ icon, title, action }: PickerMessageProps) => (
<Stack align="center" gap="xs" p="lg" ta="center">
<ThemeIcon variant="light" color="gray" size="lg">
{icon}
</ThemeIcon>
<Text size="sm">{title}</Text>
{action}
</Stack>
);
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i"); const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
const shouldShowPreview = (value: string | null | undefined): value is string => { const shouldShowPreview = (value: string | null | undefined): value is string => {
if (!value) return false; if (!value) return false;
return !localizationPathRegex.test(value); return !localizationPathRegex.test(value);
}; };
const isDirectIconUrl = (value: string) => {
try {
const url = new URL(value.trim());
return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname);
} catch {
return false;
}
};
@@ -0,0 +1,50 @@
import { describe, expect, test } from "vitest";
import type { IconGroup } from "./icon-picker.utils";
import { arrangeIconPickerSections, isDirectImageUrl, isImageSource, isSvgImage } from "./icon-picker.utils";
const groups = [
{
id: "remote",
slug: "dashboard-icons",
icons: [
{ id: "long", name: "home-assistant-alt.svg", url: "https://cdn.example.com/home-assistant-alt.svg" },
{ id: "raster", name: "Home Assistant.png", url: "https://cdn.example.com/home-assistant.png" },
{ id: "svg", name: "home-assistant.svg", url: "https://cdn.example.com/home-assistant.svg?v=1" },
],
},
{
id: "local",
slug: "local",
icons: [{ id: "local", name: "Home Assistant.png", url: "/api/user-medias/home-assistant" }],
},
] as IconGroup[];
describe("icon picker utilities", () => {
test("arranges local images before remote SVG and raster images", () => {
const result = arrangeIconPickerSections(groups, "home assistant");
expect(result.local.map((icon) => icon.id)).toEqual(["local"]);
expect(result.svg.map((icon) => icon.id)).toEqual(["svg", "long"]);
expect(result.other.map((icon) => icon.id)).toEqual(["raster"]);
});
test.each([
["https://example.com/icon.png", true],
["http://localhost:3000/icon", true],
["ftp://example.com/icon.png", false],
["javascript:alert(1)", false],
["example.com/icon.png", false],
])("validates direct image URLs", (value, expected) => {
expect(isDirectImageUrl(value)).toBe(expected);
});
test("accepts internal image paths without treating search text as a source", () => {
expect(isImageSource("/api/user-medias/123")).toBe(true);
expect(isImageSource("shopify")).toBe(false);
});
test("recognizes SVG URLs with query strings case-insensitively", () => {
expect(isSvgImage("https://example.com/ICON.SVG?raw=1")).toBe(true);
});
});
@@ -0,0 +1,67 @@
import type { inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "@homarr/api";
import { getImageMatchRank, normalizeImageName } from "@homarr/common";
type RouterOutput = inferRouterOutputs<AppRouter>;
export type IconGroup = RouterOutput["icon"]["findIcons"]["icons"][number];
export type PickerIcon = IconGroup["icons"][number] & { repositorySlug: string };
export interface IconPickerSections {
local: PickerIcon[];
svg: PickerIcon[];
other: PickerIcon[];
}
export const arrangeIconPickerSections = (iconGroups: IconGroup[], searchTerm = ""): IconPickerSections => {
const sections: IconPickerSections = { local: [], svg: [], other: [] };
for (const group of iconGroups) {
for (const icon of group.icons) {
const pickerIcon = { ...icon, repositorySlug: group.slug };
if (group.slug === "local") {
sections.local.push(pickerIcon);
} else if (isSvgImage(icon.url)) {
sections.svg.push(pickerIcon);
} else {
sections.other.push(pickerIcon);
}
}
}
const normalizedSearch = normalizeImageName(searchTerm);
if (normalizedSearch) {
const compareMatches = (a: PickerIcon, b: PickerIcon) => {
const aRank = getImageMatchRank(normalizedSearch, a.name || a.url) ?? Number.MAX_SAFE_INTEGER;
const bRank = getImageMatchRank(normalizedSearch, b.name || b.url) ?? Number.MAX_SAFE_INTEGER;
return aRank - bRank || a.name.localeCompare(b.name);
};
return {
local: sections.local.toSorted(compareMatches),
svg: sections.svg.toSorted(compareMatches),
other: sections.other.toSorted(compareMatches),
};
}
return sections;
};
export const isDirectImageUrl = (value: string) => {
try {
const url = new URL(value.trim());
return (url.protocol === "http:" || url.protocol === "https:") && Boolean(url.hostname);
} catch {
return false;
}
};
export const isImageSource = (value: string) => isDirectImageUrl(value) || value.trim().startsWith("/");
export const isSvgImage = (value: string) => {
try {
const url = new URL(value, "http://homarr.local");
return url.pathname.toLowerCase().endsWith(".svg");
} catch {
return value.toLowerCase().split(/[?#]/, 1)[0]?.endsWith(".svg") ?? false;
}
};
-1
View File
@@ -2,7 +2,6 @@ export * from "./new-app/_app-new-form";
export * from "./new-app/_form"; export * from "./new-app/_form";
export * from "./icon-picker/icon-picker"; export * from "./icon-picker/icon-picker";
export * from "./new-app/icon-matcher";
export * from "./upload-media/upload-media"; export * from "./upload-media/upload-media";
@@ -3,17 +3,15 @@
import type { ChangeEventHandler } from "react"; import type { ChangeEventHandler } from "react";
import { useEffect, useImperativeHandle, useRef } from "react"; import { useEffect, useImperativeHandle, useRef } from "react";
import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core"; import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import type { z } from "zod/v4"; import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form"; import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { appManageSchema } from "@homarr/validation/app"; import { appManageSchema } from "@homarr/validation/app";
import { IconPicker } from "../icon-picker/icon-picker"; import { IconPicker } from "../icon-picker/icon-picker";
import { findBestIconMatch } from "./icon-matcher";
type FormType = z.infer<typeof appManageSchema>; type FormType = z.infer<typeof appManageSchema>;
@@ -81,9 +79,6 @@ export const AppForm = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialValuesKey]); }, [initialValuesKey]);
// Debounce the name value with 200ms delay
const [debouncedName] = useDebouncedValue(form.values.name, 200);
const shouldCreateAnother = useRef(false); const shouldCreateAnother = useRef(false);
const handleSubmit = (values: FormType) => { const handleSubmit = (values: FormType) => {
const redirect = !shouldCreateAnother.current; const redirect = !shouldCreateAnother.current;
@@ -130,29 +125,13 @@ export const AppForm = ({
} }
}; };
// Auto-select icon based on app name with debounced search
const { data: iconsData } = clientApi.icon.findIcons.useQuery(
{
searchText: debouncedName,
},
{
enabled: debouncedName.length > 3,
},
);
useEffect(() => {
if (debouncedName && !form.values.iconUrl && iconsData?.icons) {
const bestMatch = findBestIconMatch(debouncedName, iconsData.icons);
if (bestMatch) {
form.setFieldValue("iconUrl", bestMatch);
}
}
}, [debouncedName, iconsData]);
const formFields = ( const formFields = (
<Stack> <Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} /> <TextInput {...form.getInputProps("name")} withAsterisk label={t("app.field.name.label")} />
<IconPicker {...form.getInputProps("iconUrl")} /> <IconPicker
{...form.getInputProps("iconUrl")}
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
/>
<Textarea <Textarea
{...form.getInputProps("description")} {...form.getInputProps("description")}
label={t("app.field.description.label")} label={t("app.field.description.label")}
@@ -1,30 +0,0 @@
import type { inferRouterOutputs } from "@trpc/server";
import type { AppRouter } from "@homarr/api";
type RouterOutput = inferRouterOutputs<AppRouter>;
type IconGroupsOutput = RouterOutput["icon"]["findIcons"]["icons"];
export const findBestIconMatch = (searchTerm: string, iconGroups: IconGroupsOutput): string | null => {
const nameLower = searchTerm.toLowerCase();
const allIcons = iconGroups.flatMap((group) => group.icons);
const getIconPriority = (iconUrl: string) => {
const fileName = iconUrl.toLowerCase().split("/").pop()?.split(".")[0];
if (!fileName) return -1;
const isSvg = iconUrl.endsWith(".svg");
const isExactMatch = fileName === nameLower;
if (isExactMatch) return isSvg ? 0 : 1;
if (fileName.includes(nameLower)) return isSvg ? 2 : 3;
return -1;
};
for (let priority = 0; priority <= 3; priority++) {
const match = allIcons.find((icon) => getIconPriority(icon.url) === priority);
if (match) return match.url;
}
return null;
};
@@ -54,6 +54,7 @@ import { UmamiIntegration } from "../umami/umami-integration";
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration"; import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration"; import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
import { UnraidIntegration } from "../unraid/unraid-integration"; import { UnraidIntegration } from "../unraid/unraid-integration";
import { WudIntegration } from "../wud/wud-integration";
import type { Integration, IntegrationInput } from "./integration"; import type { Integration, IntegrationInput } from "./integration";
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>( export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
@@ -133,6 +134,7 @@ export const integrationCreators = {
beszel: BeszelIntegration, beszel: BeszelIntegration,
bazarr: BazarrIntegration, bazarr: BazarrIntegration,
traefik: TraefikIntegration, traefik: TraefikIntegration,
wud: WudIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>; } satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = { type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
@@ -134,7 +134,10 @@ export class HomeAssistantIntegration extends Integration implements ISmartHomeI
*/ */
private async postAsync(path: `/api/${string}`, body: Record<string, string>) { private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
return await fetchWithTrustedCertificatesAsync(this.url(path), { return await fetchWithTrustedCertificatesAsync(this.url(path), {
headers: this.getAuthHeaders(), headers: {
...this.getAuthHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify(body), body: JSON.stringify(body),
method: "POST", method: "POST",
}); });
+2
View File
@@ -1,5 +1,6 @@
// General integrations // General integrations
export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration"; export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration";
export { WudIntegration } from "./wud/wud-integration";
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration"; export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration"; export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
export { AnchorIntegration } from "./anchor/anchor-integration"; export { AnchorIntegration } from "./anchor/anchor-integration";
@@ -91,6 +92,7 @@ export type {
ArchiveTeamWarriorItem, ArchiveTeamWarriorItem,
ArchiveTeamWarriorStatus, ArchiveTeamWarriorStatus,
} from "./archive-team-warrior/archive-team-warrior-types"; } from "./archive-team-warrior/archive-team-warrior-types";
export type { WudStats, WudContainerUpdate } from "./wud/wud-types";
// Schemas // Schemas
export { anchorNotesListInputSchema } from "./anchor/anchor-types"; export { anchorNotesListInputSchema } from "./anchor/anchor-types";
@@ -85,6 +85,11 @@ export class SynologyClient {
if (volumesFromV2.length > 0) { if (volumesFromV2.length > 0) {
return volumesFromV2; return volumesFromV2;
} }
const legacyVolumesFromV2 = this.mapLegacyVolumes(storageV2.vol_info);
if (legacyVolumesFromV2.length > 0) {
return legacyVolumesFromV2;
}
} catch (error) { } catch (error) {
logger.debug("storage_v2 unavailable, falling back to legacy storage info", { logger.debug("storage_v2 unavailable, falling back to legacy storage info", {
integrationId: this.integrationId, integrationId: this.integrationId,
@@ -78,6 +78,7 @@ export const synologyStorageV2VolumeSchema = z
export const synologyStorageV2DataSchema = z.object({ export const synologyStorageV2DataSchema = z.object({
volumes: z.array(synologyStorageV2VolumeSchema).optional(), volumes: z.array(synologyStorageV2VolumeSchema).optional(),
vol_info: z.array(synologyVolumeInfoSchema).optional(),
}); });
export const synologyUtilizationCpuSchema = z.object({ export const synologyUtilizationCpuSchema = z.object({
@@ -426,6 +426,60 @@ describe("SynologyIntegration.getSystemInfoAsync", () => {
expect(result.fileSystem[0]?.deviceName).toBe("volume_1"); expect(result.fileSystem[0]?.deviceName).toBe("volume_1");
}); });
test("uses vol_info from storage_v2 without falling back to legacy storage", async () => {
mockSuccessfulFlow({
storageV2: {
success: true,
data: {
vol_info: [
{
name: "volume_1",
used_size: "4000000000",
total_size: "10000000000",
status: "normal",
},
{
name: "volume_2",
used_size: "5000000000",
total_size: "20000000000",
status: "attention",
},
],
},
},
});
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toEqual([
{
deviceName: "volume_1",
used: "4000000000",
available: "6000000000",
percentage: 40,
},
{
deviceName: "volume_2",
used: "5000000000",
available: "15000000000",
percentage: 25,
},
]);
const legacyStorageRequests = mockFetch.mock.calls.filter(([url]) => {
const urlString = toUrlString(url);
return (
getApiFromUrl(urlString) === "SYNO.Core.System" &&
getMethodFromUrl(urlString) === "info" &&
getTypeFromUrl(urlString) === "storage"
);
});
expect(legacyStorageRequests).toHaveLength(0);
});
test("continues when optional tier-2 calls fail", async () => { test("continues when optional tier-2 calls fail", async () => {
mockSuccessfulFlow({ skipOptional: true }); mockSuccessfulFlow({ skipOptional: true });
const integration = createIntegration(); const integration = createIntegration();
@@ -59,10 +59,12 @@ const ws = vi.hoisted(() => {
} }
const result = responder(payload.method as string, (payload.params as unknown[] | undefined) ?? []); const result = responder(payload.method as string, (payload.params as unknown[] | undefined) ?? []);
// A responder returning an Error mimics a method the server rejects, e.g. an unsupported filter.
const body = result instanceof Error ? { error: { message: result.message } } : { result };
const envelope = const envelope =
payload.jsonrpc === "2.0" payload.jsonrpc === "2.0"
? { jsonrpc: "2.0", id: payload.id, result } ? { jsonrpc: "2.0", id: payload.id, ...body }
: { msg: "result", id: payload.id, result }; : { msg: "result", id: payload.id, ...body };
this.respond(JSON.stringify(envelope)); this.respond(JSON.stringify(envelope));
} }
@@ -91,7 +93,10 @@ const ws = vi.hoisted(() => {
}; };
}); });
const logs = vi.hoisted(() => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }));
vi.mock("ws", () => ({ WebSocket: ws.FakeWebSocket })); vi.mock("ws", () => ({ WebSocket: ws.FakeWebSocket }));
vi.mock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => logs }));
vi.mock("@homarr/core/infrastructure/certificates", () => ({ vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]), getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]), getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
@@ -148,6 +153,14 @@ const happyResponder = (method: string) => {
]; ];
case "pool.query": case "pool.query":
return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }]; return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }];
case "pool.dataset.query":
return [
{
id: "tank",
used: { parsed: 200 },
available: { parsed: 300 },
},
];
case "interface.query": case "interface.query":
return [{ id: "eth0", name: "eth0" }]; return [{ id: "eth0", name: "eth0" }];
case "reporting.netdata_get_data": case "reporting.netdata_get_data":
@@ -159,6 +172,11 @@ const happyResponder = (method: string) => {
const emptyAggregations = () => ({ min: {}, mean: {}, max: {} }); const emptyAggregations = () => ({ min: {}, mean: {}, max: {} });
// `pool.query` reports 500 allocated of a 1000 byte pool, leaving 500 free.
const physicalFallbackFileSystem = [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }];
const ERROR_MESSAGE_LIMIT = 200;
let integrationCounter = 0; let integrationCounter = 0;
const createIntegration = (decryptedSecrets: IntegrationSecret[]) => const createIntegration = (decryptedSecrets: IntegrationSecret[]) =>
new TrueNasIntegration({ new TrueNasIntegration({
@@ -180,6 +198,7 @@ describe("TrueNasIntegration", () => {
ws.constructedUrls.length = 0; ws.constructedUrls.length = 0;
ws.failures.length = 0; ws.failures.length = 0;
ws.setResponder(happyResponder); ws.setResponder(happyResponder);
logs.warn.mockClear();
}); });
test("fetches and maps system info over the JSON-RPC API", async () => { test("fetches and maps system info over the JSON-RPC API", async () => {
@@ -191,6 +210,18 @@ describe("TrueNasIntegration", () => {
// JSON-RPC does not use the legacy "connect" session handshake. // JSON-RPC does not use the legacy "connect" session handshake.
expect(ws.sentPayloads.some((payload) => payload.msg === "connect")).toBe(false); expect(ws.sentPayloads.some((payload) => payload.msg === "connect")).toBe(false);
const datasetPayload = ws.sentPayloads.find((payload) => payload.method === "pool.dataset.query");
expect(datasetPayload).toMatchObject({
params: [
[["id", "in", ["tank"]]],
{
extra: {
properties: ["used", "available"],
},
},
],
});
expect(result).toMatchObject({ expect(result).toMatchObject({
version: "TrueNAS-SCALE-24.10", version: "TrueNAS-SCALE-24.10",
cpuModelName: "Intel Xeon", cpuModelName: "Intel Xeon",
@@ -204,12 +235,131 @@ describe("TrueNasIntegration", () => {
loadAverage: null, loadAverage: null,
gpu: [], gpu: [],
network: { up: 20_000, down: 10_000 }, network: { up: 20_000, down: 10_000 },
// `available` is the free space left on the pool (free = 500), not its total size. // `available` and `used` come from the root dataset, not the physical pool.
fileSystem: [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }], fileSystem: [{ deviceName: "tank", available: "300", used: "200", percentage: 40 }],
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }], smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
}); });
}); });
test("falls back to physical pool space when the root dataset is not returned", async () => {
ws.setResponder((method) => (method === "pool.dataset.query" ? [] : happyResponder(method)));
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
// Dropping the pool would also hide its health status.
expect(result.smart).toMatchObject([{ deviceName: "tank", healthy: true, overallStatus: "ONLINE" }]);
});
test("falls back to physical pool space when the root dataset reports no usable values", async () => {
ws.setResponder((method) =>
method === "pool.dataset.query"
? [{ id: "tank", used: { parsed: null }, available: null }]
: happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
});
test("falls back to physical pool space when the root dataset omits the requested properties", async () => {
// Datasets that cannot report space, e.g. while locked, come back without `used` and `available`.
ws.setResponder((method) =>
method === "pool.dataset.query" ? [{ id: "tank", locked: true }] : happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
});
test("falls back to physical pool space when the dataset response fails schema validation", async () => {
ws.setResponder((method) =>
method === "pool.dataset.query" ? { unexpected: "not an array" } : happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
// A malformed payload must not take the rest of the system info down with it.
expect(result).toMatchObject({ cpuTemp: 45, memAvailableInBytes: 6_000_000_000, uptime: 3600 });
});
test("falls back to physical pool space when the dataset query is rejected", async () => {
ws.setResponder((method) =>
method === "pool.dataset.query"
? new Error("[EINVAL] filters: Value error, Invalid operation: in")
: happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
// A rejected dataset query must not take the rest of the system info down with it.
expect(result).toMatchObject({ cpuTemp: 45, memAvailableInBytes: 6_000_000_000, uptime: 3600 });
});
test("logs the truncated rejection reason rather than the generic wrapper message", async () => {
// TrueNAS answers a rejected method with its Python traceback, which runs to several kilobytes.
ws.setResponder((method) =>
method === "pool.dataset.query"
? new Error(`[EINVAL] query-filters ${"x".repeat(5000)}`)
: happyResponder(method),
);
const integration = createIntegration(credentials);
await integration.getSystemInfoAsync();
const logged = logs.warn.mock.calls.find(([message]) => String(message).includes("Could not retrieve"));
const reason = String((logged?.[1] as { error?: string } | undefined)?.error);
// The decorator would otherwise surface only "An unknown error occured while executing Integration method".
expect(reason).toContain("[EINVAL] query-filters");
expect(reason).toHaveLength(ERROR_MESSAGE_LIMIT + 1); // truncated, plus the ellipsis
});
test("keeps reporting a pool that is unhealthy but has no usable dataset space", async () => {
ws.setResponder((method) => {
if (method === "pool.query")
return [
{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 },
{ name: "backup", status: "DEGRADED", healthy: false, free: 10, size: 100, allocated: 90 },
];
if (method === "pool.dataset.query") return [{ id: "tank", used: { parsed: 200 }, available: { parsed: 300 } }];
return happyResponder(method);
});
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.smart).toMatchObject([
{ deviceName: "tank", healthy: true, overallStatus: "ONLINE" },
{ deviceName: "backup", healthy: false, overallStatus: "DEGRADED" },
]);
expect(result.fileSystem).toMatchObject([
{ deviceName: "tank", available: "300", used: "200", percentage: 40 },
{ deviceName: "backup", available: "10", used: "90", percentage: 90 },
]);
});
test("reports zero usage for an empty root dataset", async () => {
ws.setResponder((method) =>
method === "pool.dataset.query"
? [{ id: "tank", used: { parsed: 0 }, available: { parsed: 0 } }]
: happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem[0]?.percentage).toBe(0);
});
test("authenticates with an API key when one is configured", async () => { test("authenticates with an API key when one is configured", async () => {
const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]); const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]);
@@ -15,6 +15,20 @@ const logger = createLogger({ module: "trueNasIntegration" });
const NETWORK_MULTIPLIER = 100; const NETWORK_MULTIPLIER = 100;
const ERROR_MESSAGE_LIMIT = 200;
/**
* The error decorator replaces a rejected request with a generic message and moves the actionable
* reason to `cause`. TrueNAS puts its middleware traceback in there, several kilobytes of it, so the
* reason is truncated before it reaches a log line that repeats on every poll.
*/
const describeRequestError = (error: unknown) => {
const cause = error instanceof Error ? error.cause : undefined;
const reason = cause instanceof Error ? cause.message : error instanceof Error ? error.message : String(error);
return reason.length > ERROR_MESSAGE_LIMIT ? `${reason.slice(0, ERROR_MESSAGE_LIMIT)}…` : reason;
};
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration { export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
private client?: TrueNasClient; private client?: TrueNasClient;
@@ -49,9 +63,10 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
memUsedInBytes, memUsedInBytes,
fileSystem: datasets.map((dataset) => ({ fileSystem: datasets.map((dataset) => ({
deviceName: dataset.name, deviceName: dataset.name,
available: `${dataset.free}`, // free space left on the pool available: `${dataset.available}`,
used: `${dataset.allocated}`, used: `${dataset.used}`,
percentage: (dataset.allocated / dataset.size) * 100, percentage:
dataset.used + dataset.available === 0 ? 0 : (dataset.used / (dataset.used + dataset.available)) * 100,
})), })),
availablePkgUpdates: 0, availablePkgUpdates: 0,
network: { network: {
@@ -107,9 +122,10 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
.map((pool) => { .map((pool) => {
if (pool.allocated !== null && pool.size !== null && pool.free !== null) { if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
return { return {
...pool, name: pool.name,
status: pool.status,
healthy: pool.healthy,
allocated: pool.allocated, allocated: pool.allocated,
size: pool.size,
free: pool.free, free: pool.free,
}; };
} }
@@ -117,12 +133,76 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
return null; return null;
}) })
.filter((pool) => pool !== null); .filter((pool) => pool !== null);
logger.debug("Retrieved pools", { logger.debug("Retrieved pools", {
url: this.integration.url, url: this.integration.url,
totalCount: result.length, totalCount: result.length,
activeCount: activePools.length, activeCount: activePools.length,
}); });
return activePools;
if (activePools.length === 0) return [];
const usableSpaceByPool = await this.getUsableSpaceByPoolAsync(activePools.map((pool) => pool.name));
return activePools.map((pool) => {
const usableSpace = usableSpaceByPool.get(pool.name);
if (!usableSpace) {
logger.warn("Falling back to physical pool space, the root dataset reported no usable space", {
url: this.integration.url,
pool: pool.name,
});
}
return {
name: pool.name,
status: pool.status,
healthy: pool.healthy,
used: usableSpace?.used ?? pool.allocated,
available: usableSpace?.available ?? pool.free,
};
});
}
/**
* Returns the usable space of each pool's root ZFS dataset, keyed by pool name. Unlike the physical
* `pool.query` values, these account for RAIDZ parity overhead and dataset limits, matching `zfs list`.
* See https://github.com/homarr-labs/homarr/issues/6566
*
* A pool is left out when its usable space is unavailable, so the caller keeps reporting the pool with
* its physical values instead of dropping it, which would also hide its health status:
* - request rejected → method or `extra.properties` unsupported, or the API key lacks the dataset role
* - response rejected → the payload does not match the expected shape
* - dataset missing → the pool's root dataset was not returned by the query
* - property absent → the dataset does not report `used` / `available`, e.g. while locked
*/
private async getUsableSpaceByPoolAsync(poolNames: string[]) {
const usableSpaceByPool = new Map<string, { used: number; available: number }>();
try {
const datasets = await this.requestAsync("pool.dataset.query", [
[["id", "in", poolNames]],
{
extra: {
properties: ["used", "available"],
},
},
]);
for (const dataset of await poolDatasetSchema.parseAsync(datasets)) {
const used = dataset.used?.parsed;
const available = dataset.available?.parsed;
if (typeof used !== "number" || typeof available !== "number") continue;
usableSpaceByPool.set(dataset.id, { used, available });
}
} catch (error) {
logger.warn("Could not retrieve root dataset space, continuing with physical pool space", {
url: this.integration.url,
error: describeRequestError(error),
});
}
return usableSpaceByPool;
} }
/** /**
@@ -263,6 +343,19 @@ const poolSchema = z.array(
}), }),
); );
// Properties are absent or unparsed for datasets that do not report them, e.g. while locked,
// so they are optional here and the pool falls back to its physical values instead of failing
// the whole request, which would also drop cpu, memory and network data
const poolDatasetPropertySchema = z.object({ parsed: z.number().min(0).nullish() }).nullish();
const poolDatasetSchema = z.array(
z.object({
id: z.string(),
used: poolDatasetPropertySchema,
available: poolDatasetPropertySchema,
}),
);
const reportingNetDataSchema = z.array( const reportingNetDataSchema = z.array(
z.object({ z.object({
name: z.string(), name: z.string(),
@@ -37,9 +37,16 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
const systemInfo = await this.getSystemInformationAsync(); const systemInfo = await this.getSystemInformationAsync();
const cpuUtilization = systemInfo.metrics.cpu.cpus.reduce((acc, val) => acc + val.percentTotal, 0); const cpuUtilization = systemInfo.metrics.cpu.cpus.reduce((acc, val) => acc + val.percentTotal, 0);
const cpuCount = systemInfo.info.cpu.cores; const cpuCount = systemInfo.metrics.cpu.cpus.length;
// Each entry in cpus[] is one logical CPU (thread). Dividing the summed per-thread
// utilization by the thread count (not physical cores) gives the correct average and
// avoids over-reporting on SMT systems. Guard against zero to avoid Infinity/NaN.
let cpuUtilizationNormalized = 0;
if (cpuCount > 0) {
cpuUtilizationNormalized = cpuUtilization / cpuCount;
}
// We use "info" object instead of the stats since this is the exact amount the kernel sees, which is what Unraid displays.
const totalMemory = systemInfo.info.memory.layout.reduce((acc, layout) => layout.size + acc, 0); const totalMemory = systemInfo.info.memory.layout.reduce((acc, layout) => layout.size + acc, 0);
const usedMemory = totalMemory * (systemInfo.metrics.memory.percentTotal / 100); const usedMemory = totalMemory * (systemInfo.metrics.memory.percentTotal / 100);
const uptime = dayjs(systemInfo.info.os.uptime); const uptime = dayjs(systemInfo.info.os.uptime);
@@ -47,7 +54,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
return { return {
version: systemInfo.info.os.release, version: systemInfo.info.os.release,
cpuModelName: systemInfo.info.cpu.brand, cpuModelName: systemInfo.info.cpu.brand,
cpuUtilization: cpuUtilization / cpuCount, cpuUtilization: cpuUtilizationNormalized,
memUsedInBytes: usedMemory, memUsedInBytes: usedMemory,
memAvailableInBytes: totalMemory - usedMemory, memAvailableInBytes: totalMemory - usedMemory,
uptime: dayjs().diff(uptime, "seconds"), uptime: dayjs().diff(uptime, "seconds"),
@@ -139,7 +146,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
}, },
memory { memory {
layout { layout {
size size
} }
} }
} }
@@ -0,0 +1,269 @@
// @vitest-environment node
import { ParseError } from "@homarr/common/server";
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
});
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../../base/integration";
import { IntegrationParseError } from "../../base/errors/parse/integration-parse-error";
import type { IntegrationSecret } from "../../base/types";
import { WudIntegration } from "../wud-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_URL = "https://wud.example.com";
const TEST_USERNAME = "user";
const TEST_PASSWORD = "p@ss";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const sampleContainersResponse = [
{
id: "1",
name: "homeassistant",
displayName: "Home Assistant",
updateAvailable: true,
link: "https://github.com/home-assistant/core/releases/tag/2024.1.0",
image: { tag: { value: "2023.12.0" } },
updateKind: { remoteValue: "2024.1.0" },
},
{ id: "2", name: "traefik", updateAvailable: false },
{
id: "3",
name: "grafana",
updateAvailable: true,
image: { tag: { value: "10.0.0" } },
result: { tag: "10.1.0" },
},
];
const sampleUpdates = [
{
id: "1",
name: "Home Assistant",
currentVersion: "2023.12.0",
newVersion: "2024.1.0",
link: "https://github.com/home-assistant/core/releases/tag/2024.1.0",
},
{
id: "3",
name: "grafana",
currentVersion: "10.0.0",
newVersion: "10.1.0",
link: null,
},
];
const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) =>
new WudIntegration({
id: "test-wud",
name: "Test WUD",
url: TEST_URL,
externalUrl: null,
decryptedSecrets,
});
const createIntegrationWithBasicAuth = () =>
createIntegration([
{ kind: "username", value: TEST_USERNAME },
{ kind: "password", value: TEST_PASSWORD },
]);
beforeEach(() => {
mockFetch.mockReset();
});
describe("WudIntegration getStatsAsync", () => {
test("computes total and available-update counts from the containers list", async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify(sampleContainersResponse), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
const stats = await createIntegration().getStatsAsync();
expect(stats).toStrictEqual({ totalContainers: 3, updatesAvailable: 2, updates: sampleUpdates });
});
test("returns zero counts for an empty containers list", async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
const stats = await createIntegration().getStatsAsync();
expect(stats).toStrictEqual({ totalContainers: 0, updatesAvailable: 0, updates: [] });
});
test("maps container updates using displayName, falling back to result.tag and null link", async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify(sampleContainersResponse), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
const stats = await createIntegration().getStatsAsync();
expect(stats.updates).toStrictEqual(sampleUpdates);
});
test("throws ParseError when API response is not valid JSON", async () => {
mockFetch.mockResolvedValue(
new Response("not-json", {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
await expect(createIntegration().getStatsAsync()).rejects.toSatisfy((error) => {
if (!(error instanceof IntegrationParseError)) return false;
const cause = error.cause;
return cause instanceof ParseError && cause.message.includes("Invalid WUD containers response");
});
});
test("throws ParseError when API response does not match schema", async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify({ invalid: true }), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
await expect(createIntegration().getStatsAsync()).rejects.toSatisfy((error) => {
if (!(error instanceof IntegrationParseError)) return false;
const cause = error.cause;
return cause instanceof ParseError && cause.message.includes("Invalid WUD containers response");
});
});
test("throws when API returns an error", async () => {
mockFetch.mockResolvedValue(
new Response("Unauthorized", {
status: 401,
headers: { "content-type": "text/plain" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
await expect(createIntegration().getStatsAsync()).rejects.toThrow();
});
});
describe("WudIntegration authentication", () => {
test("sends no Authorization header when no credentials are configured", async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify(sampleContainersResponse), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
await createIntegration().getStatsAsync();
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
const headers = (requestInit?.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBeUndefined();
});
test("sends a Basic auth header when username and password are configured", async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify(sampleContainersResponse), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
await createIntegrationWithBasicAuth().getStatsAsync();
const expected = `Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`;
const [url, requestInit] = mockFetch.mock.calls[0] ?? [];
expect(String(url)).toContain("/api/containers");
expect(requestInit?.headers).toMatchObject({ Authorization: expected });
expect(requestInit?.timeout).toBe(10_000);
});
test("does not send credentials over a non-HTTPS URL", async () => {
mockFetch.mockResolvedValue(
new Response(JSON.stringify(sampleContainersResponse), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
const integration = new WudIntegration({
id: "test-wud-http",
name: "Test WUD HTTP",
url: "http://wud.example.com",
externalUrl: null,
decryptedSecrets: [
{ kind: "username", value: TEST_USERNAME },
{ kind: "password", value: TEST_PASSWORD },
],
});
await integration.getStatsAsync();
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
const headers = (requestInit?.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBeUndefined();
});
});
describe("WudIntegration testing endpoint", () => {
type TestingFn = (input: IntegrationTestingInput) => Promise<{ success: boolean }>;
const invokeTesting = (integration: WudIntegration, input: IntegrationTestingInput) => {
const testing = (integration as unknown as { testingAsync: TestingFn }).testingAsync.bind(integration);
return testing(input);
};
const makeInput = (response: Response): IntegrationTestingInput =>
({
fetchAsync: vi.fn().mockResolvedValue(response),
}) as unknown as IntegrationTestingInput;
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);
});
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);
expect(result.success).toBe(false);
});
test("passes the Basic auth header through to fetchAsync when configured", async () => {
const response = new Response(JSON.stringify({ name: "wud", version: "5.0.0" }), { status: 200 });
const input = makeInput(response);
await invokeTesting(createIntegrationWithBasicAuth(), input);
const fetchAsyncMock = vi.mocked(input.fetchAsync);
const init = fetchAsyncMock.mock.calls[0]?.[1];
const headers = (init?.headers ?? {}) as Record<string, string>;
const expected = `Basic ${Buffer.from(`${TEST_USERNAME}:${TEST_PASSWORD}`).toString("base64")}`;
expect(headers.Authorization).toBe(expected);
});
});
@@ -0,0 +1,68 @@
import { Buffer } from "node:buffer";
import { ParseError, ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { WudStats } from "./wud-types";
import { mapWudStats, parseWudContainersResponseAsync } from "./wud-types";
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/app"), {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
try {
await response.json();
} catch (error) {
return TestConnectionError.ParseResult(
new ParseError("Invalid WUD app response", {
cause: error instanceof Error ? error : new Error(String(error)),
}),
);
}
return { success: true };
}
public async getStatsAsync(): Promise<WudStats> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/containers"), {
headers: this.getAuthHeaders(),
timeout: CONTAINERS_REQUEST_TIMEOUT_MS,
});
if (!response.ok) {
throw new ResponseError(response);
}
const containers = await parseWudContainersResponseAsync(response);
return mapWudStats(containers);
}
private getAuthHeaders(): Record<string, string> | undefined {
if (!this.hasSecretValue("username") || !this.hasSecretValue("password")) {
return undefined;
}
if (this.url("/api/app").protocol !== "https:") {
return undefined;
}
const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString(
"base64",
);
return { Authorization: `Basic ${credentials}` };
}
}
@@ -0,0 +1,81 @@
import { ParseError } from "@homarr/common/server";
import { z } from "zod/v4";
const WUD_CONTAINERS_PARSE_ERROR_MESSAGE = "Invalid WUD containers response";
export const wudContainerSchema = z.object({
id: z.string(),
name: z.string(),
displayName: z.string().nullable().optional(),
updateAvailable: z.boolean(),
link: z.string().nullable().optional(),
image: z
.object({
tag: z
.object({
value: z.string().nullable().optional(),
})
.optional(),
})
.optional(),
updateKind: z
.object({
remoteValue: z.string().nullable().optional(),
})
.optional(),
result: z
.object({
tag: z.string().nullable().optional(),
})
.optional(),
});
export const wudContainersResponseSchema = z.array(wudContainerSchema);
export interface WudContainerUpdate {
id: string;
name: string;
currentVersion: string | null;
newVersion: string | null;
link: string | null;
}
export interface WudStats {
totalContainers: number;
updatesAvailable: number;
updates: WudContainerUpdate[];
}
export const parseWudContainersResponseAsync = async (response: {
json: () => Promise<unknown>;
}): Promise<z.infer<typeof wudContainersResponseSchema>> => {
let json: unknown;
try {
json = await response.json();
} catch (error) {
throw new ParseError(WUD_CONTAINERS_PARSE_ERROR_MESSAGE, {
cause: error instanceof Error ? error : new Error(String(error)),
});
}
const parseResult = await wudContainersResponseSchema.safeParseAsync(json);
if (!parseResult.success) {
throw new ParseError(WUD_CONTAINERS_PARSE_ERROR_MESSAGE, { cause: parseResult.error });
}
return parseResult.data;
};
const mapContainerUpdate = (container: z.infer<typeof wudContainerSchema>): WudContainerUpdate => ({
id: container.id,
name: container.displayName ?? container.name,
currentVersion: container.image?.tag?.value ?? null,
newVersion: container.updateKind?.remoteValue ?? container.result?.tag ?? null,
link: container.link ?? null,
});
export const mapWudStats = (containers: z.infer<typeof wudContainersResponseSchema>): WudStats => ({
totalContainers: containers.length,
updatesAvailable: containers.filter((container) => container.updateAvailable).length,
updates: containers.filter((container) => container.updateAvailable).map(mapContainerUpdate),
});
@@ -0,0 +1,48 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { HomeAssistantIntegration } from "../src";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const integration = new HomeAssistantIntegration({
id: "home-assistant",
name: "Home Assistant",
url: "http://home-assistant.local:8123",
externalUrl: null,
decryptedSecrets: [{ kind: "apiKey", value: "secret" }],
});
describe("HomeAssistantIntegration actions", () => {
beforeEach(() => {
mockFetch.mockReset();
mockFetch.mockResolvedValue({ ok: true } as never);
});
test.each([
["toggle", () => integration.triggerToggleAsync("light.desk"), "/api/services/homeassistant/toggle", "light.desk"],
[
"automation",
() => integration.triggerAutomationAsync("automation.turn_on_lights"),
"/api/services/automation/trigger",
"automation.turn_on_lights",
],
])("sends the %s service call as JSON", async (_action, trigger, path, entityId) => {
await expect(trigger()).resolves.toBe(true);
expect(mockFetch).toHaveBeenCalledWith(new URL(`http://home-assistant.local:8123${path}`), {
headers: {
Authorization: "Bearer secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ entity_id: entityId }),
method: "POST",
});
});
});
@@ -0,0 +1,39 @@
// @vitest-environment node
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { getServerStatistics, init } from "@immich/sdk";
import { describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn(async () => []),
getTrustedCertificateHostnamesAsync: vi.fn(async () => []),
}));
describe("real @immich/sdk end-to-end through fetchWithTrustedCertificatesAsync", () => {
test("x-api-key set via init() reaches the server", async () => {
let receivedXApiKey: string | string[] | undefined;
const server = createServer((req, res) => {
receivedXApiKey = req.headers["x-api-key"];
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ usage: 1000, photos: 5, videos: 2 }));
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const port = (server.address() as AddressInfo).port;
init({ baseUrl: `http://127.0.0.1:${port}/api`, apiKey: "sdk-configured-key" });
try {
const stats = await getServerStatistics({
fetch: fetchWithTrustedCertificatesAsync as unknown as typeof fetch,
});
expect(receivedXApiKey).toBe("sdk-configured-key");
expect(stats).toMatchObject({ usage: 1000, photos: 5, videos: 2 });
} finally {
server.close();
}
});
});
@@ -6,7 +6,7 @@ import { createIntegrationRequestHandler } from "./lib/integration-request-handl
export const systemInfoRequestHandler = createIntegrationRequestHandler< export const systemInfoRequestHandler = createIntegrationRequestHandler<
SystemHealthMonitoring, SystemHealthMonitoring,
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel" | "patchmon">, Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel" | "patchmon" | "wud">,
Record<string, never> Record<string, never>
>({ >({
async requestAsync(integration, _input) { async requestAsync(integration, _input) {
@@ -18,4 +18,6 @@ export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandle
return result.data.state; return result.data.state;
}, },
// The widget already polls every 30 seconds and refetches after toggles.
cacheTtlMs: 0,
}); });
+11
View File
@@ -0,0 +1,11 @@
import { createIntegrationAsync } from "@homarr/integrations";
import type { WudStats } from "@homarr/integrations";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const wudStatsRequestHandler = createIntegrationRequestHandler<WudStats, "wud", Record<string, never>>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getStatsAsync();
},
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+48 -2
View File
@@ -1344,6 +1344,10 @@
"name": "crwdns5753:0crwdne5753:0", "name": "crwdns5753:0crwdne5753:0",
"description": "crwdns5755:0crwdne5755:0" "description": "crwdns5755:0crwdne5755:0"
}, },
"wud": {
"name": "crwdns6193:0crwdne6193:0",
"description": "crwdns6195:0crwdne6195:0"
},
"paperlessNgx": { "paperlessNgx": {
"name": "crwdns5003:0crwdne5003:0", "name": "crwdns5003:0crwdne5003:0",
"description": "crwdns5005:0crwdne5005:0" "description": "crwdns5005:0crwdne5005:0"
@@ -1437,8 +1441,18 @@
"here": "crwdns552:0crwdne552:0", "here": "crwdns552:0crwdne552:0",
"iconPicker": { "iconPicker": {
"label": "crwdns554:0crwdne554:0", "label": "crwdns554:0crwdne554:0",
"header": "crwdns556:0{countIcons}crwdne556:0", "description": "crwdns6225:0crwdne6225:0",
"headerLoading": "crwdns5681:0crwdne5681:0", "header": "crwdns6227:0{countIcons}crwdne6227:0",
"headerLoading": "crwdns6229:0crwdne6229:0",
"clear": "crwdns6231:0crwdne6231:0",
"uploadImage": "crwdns6233:0crwdne6233:0",
"results": "crwdns6235:0crwdne6235:0",
"localImages": "crwdns6237:0crwdne6237:0",
"svgIcons": "crwdns6239:0crwdne6239:0",
"otherImages": "crwdns6241:0crwdne6241:0",
"urlLoading": "crwdns6243:0crwdne6243:0",
"urlReady": "crwdns6245:0crwdne6245:0",
"urlError": "crwdns6247:0crwdne6247:0",
"noResults": "crwdns5683:0{search}crwdne5683:0", "noResults": "crwdns5683:0{search}crwdne5683:0",
"error": "crwdns5685:0crwdne5685:0" "error": "crwdns5685:0crwdne5685:0"
}, },
@@ -1793,6 +1807,38 @@
} }
} }
}, },
"wud": {
"name": "crwdns6197:0crwdne6197:0",
"description": "crwdns6199:0crwdne6199:0",
"title": "crwdns6201:0crwdne6201:0",
"monitored": "crwdns6203:0crwdne6203:0",
"updatesAvailable": "crwdns6205:0count={count}crwdne6205:0",
"updateAvailable": "crwdns6207:0crwdne6207:0",
"openReleaseNotes": "crwdns6209:0crwdne6209:0",
"option": {
"showTitle": {
"label": "crwdns6211:0crwdne6211:0"
},
"showRing": {
"label": "crwdns6213:0crwdne6213:0"
},
"layout": {
"label": "crwdns6215:0crwdne6215:0",
"option": {
"horizontal": {
"label": "crwdns6217:0crwdne6217:0"
},
"vertical": {
"label": "crwdns6219:0crwdne6219:0"
}
}
},
"showUpdateList": {
"label": "crwdns6221:0crwdne6221:0",
"description": "crwdns6223:0crwdne6223:0"
}
}
},
"dnsHoleSummary": { "dnsHoleSummary": {
"name": "crwdns768:0crwdne768:0", "name": "crwdns768:0crwdne768:0",
"description": "crwdns770:0crwdne770:0", "description": "crwdns770:0crwdne770:0",
File diff suppressed because it is too large. Load diff
+49 -3
View File
@@ -1344,6 +1344,10 @@
"name": "", "name": "",
"description": "" "description": ""
}, },
"wud": {
"name": "Hvad Er Opad Docker",
"description": "Overvåg hvilke af dine Docker-containere der er tilgængelige billedopdateringer"
},
"paperlessNgx": { "paperlessNgx": {
"name": "Paperless-ngx", "name": "Paperless-ngx",
"description": "Dokumenthåndteringssystem til arkivering og organisering af scannede dokumenter" "description": "Dokumenthåndteringssystem til arkivering og organisering af scannede dokumenter"
@@ -1437,8 +1441,18 @@
"here": "her", "here": "her",
"iconPicker": { "iconPicker": {
"label": "Ikon-URL", "label": "Ikon-URL",
"header": "Indtast navn eller objekter for at filtrere for ikoner... Homarr vil søge gennem {countIcons} ikoner for dig.", "description": "Søg i biblioteket, indsæt en billed-URL, eller upload en fil.",
"headerLoading": "", "header": "Søg efter {countIcons} billeder efter navn",
"headerLoading": "Søg billeder efter navn",
"clear": "Ryd billede",
"uploadImage": "Upload billede",
"results": "Billede søgeresultater",
"localImages": "Dine billeder",
"svgIcons": "SVG ikoner",
"otherImages": "Andre billeder",
"urlLoading": "Kontrollerer billede…",
"urlReady": "Billedet er klar til brug",
"urlError": "Dette billede kunne ikke indlæses. Tjek URL'en og prøv igen.",
"noResults": "", "noResults": "",
"error": "" "error": ""
}, },
@@ -1793,6 +1807,38 @@
} }
} }
}, },
"wud": {
"name": "Hvad Er Opad Docker",
"description": "Vis hvor mange overvågede containere der er tilgængelige opdateringer.",
"title": "Hvad Er Opad Docker",
"monitored": "Overvåget",
"updatesAvailable": "{count, plural, one {# opdatering tilgængelig} other {# opdateringer tilgængelige}}",
"updateAvailable": "Opdatering tilgængelig",
"openReleaseNotes": "Åbn udgivelsesnoter",
"option": {
"showTitle": {
"label": "Vis titel"
},
"showRing": {
"label": "Vis opdateringsring"
},
"layout": {
"label": "Layout",
"option": {
"horizontal": {
"label": "Horisontal"
},
"vertical": {
"label": "Vertikal"
}
}
},
"showUpdateList": {
"label": "Vis liste over container-opdatering",
"description": "Lister hver overvåget beholder, der har en billedopdatering tilgængelig"
}
}
},
"dnsHoleSummary": { "dnsHoleSummary": {
"name": "DNS Hole Oversigt", "name": "DNS Hole Oversigt",
"description": "Viser oversigten over dit DNS-hul", "description": "Viser oversigten over dit DNS-hul",
@@ -3047,7 +3093,7 @@
"option": { "option": {
"albumId": { "albumId": {
"label": "Album ID", "label": "Album ID",
"description": "Dette er et unikt ID som refererer til dit Immich album. Du kan finde dette id ved at åbne et album og kopiere den unikke streng fra URL, for eksempel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'" "description": "Dette er et unikt ID som refererer til dit Immich album. Du kan finde dette id ved at åbne et album og kopiere den unikke streng fra URL, for eksempel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
}, },
"rotationIntervalSeconds": { "rotationIntervalSeconds": {
"label": "Interval i sekunder", "label": "Interval i sekunder",
File diff suppressed because it is too large. Load diff
+120 -74
View File
@@ -1344,6 +1344,10 @@
"name": "Traefik", "name": "Traefik",
"description": "Cloud-nativer Reverse-Proxy und Lastverteiler" "description": "Cloud-nativer Reverse-Proxy und Lastverteiler"
}, },
"wud": {
"name": "Was ist Docker hoch",
"description": "Überwachen Sie, welche Docker-Container Bildupdates verfügbar sind"
},
"paperlessNgx": { "paperlessNgx": {
"name": "Paperless-ngx", "name": "Paperless-ngx",
"description": "Dokumentenverwaltungssystem zum Archivieren und Organisieren gescannter Dokumente" "description": "Dokumentenverwaltungssystem zum Archivieren und Organisieren gescannter Dokumente"
@@ -1437,8 +1441,18 @@
"here": "Hier", "here": "Hier",
"iconPicker": { "iconPicker": {
"label": "Icon URL", "label": "Icon URL",
"header": "Geben Sie Namen oder Objekte ein, um nach Symbolen zu filtern... Homarr sucht nach {countIcons} Icons für Sie.", "description": "Suchen Sie die Bibliothek, fügen Sie eine Bild-URL ein oder laden Sie eine Datei hoch.",
"headerLoading": "Tippe um die Symbole zu durchsuchen…", "header": "Suche {countIcons} Bilder nach Namen",
"headerLoading": "Bilder nach Namen suchen",
"clear": "Bild löschen",
"uploadImage": "Bild hochladen",
"results": "Bildsuchergebnisse",
"localImages": "Ihre Bilder",
"svgIcons": "SVG-Symbole",
"otherImages": "Andere Bilder",
"urlLoading": "Bild wird überprüft…",
"urlReady": "Bild ist bereit zur Verwendung",
"urlError": "Dieses Bild konnte nicht geladen werden. Überprüfen Sie die URL und versuchen Sie es erneut.",
"noResults": "Keine Symbole stimmen mit \"{search}\" überein.", "noResults": "Keine Symbole stimmen mit \"{search}\" überein.",
"error": "Konnte Symbole nicht laden." "error": "Konnte Symbole nicht laden."
}, },
@@ -1793,6 +1807,38 @@
} }
} }
}, },
"wud": {
"name": "Was ist Docker hoch",
"description": "Zeigt an, wie viele überwachte Container über Updates verfügen.",
"title": "Was ist Docker hoch",
"monitored": "Überwacht",
"updatesAvailable": "{count, plural, one {# Update verfügbar} other {# Updates verfügbar}}",
"updateAvailable": "Update verfügbar",
"openReleaseNotes": "Versionshinweise öffnen",
"option": {
"showTitle": {
"label": "Titel anzeigen"
},
"showRing": {
"label": "Update-Ring anzeigen"
},
"layout": {
"label": "Ansicht",
"option": {
"horizontal": {
"label": "Horizontal"
},
"vertical": {
"label": "Vertikal"
}
}
},
"showUpdateList": {
"label": "Container-Update-Liste anzeigen",
"description": "Listet jeden überwachten Container auf, der eine Bildaktualisierung zur Verfügung hat"
}
}
},
"dnsHoleSummary": { "dnsHoleSummary": {
"name": "DNS Hole Zusammenfassung", "name": "DNS Hole Zusammenfassung",
"description": "Zeigt die Zusammenfassung deines DNS Holes an", "description": "Zeigt die Zusammenfassung deines DNS Holes an",
@@ -2329,7 +2375,7 @@
}, },
"visibleStorageVolumes": { "visibleStorageVolumes": {
"label": "Sichtbare Speichervolumes", "label": "Sichtbare Speichervolumes",
"description": "Nur verfügbar, wenn alle Integrationen Synology betreffen. Nicht ausfüllen, um alle erkannten Speichervolumes zu zeigen. ", "description": "Nur verfügbar, wenn alle ausgewählten Integrationen Synology betreffen. Nicht ausfüllen, um alle erkannten Speichervolumes anzuzeigen.",
"placeholder": "Speichervolumen auswählen" "placeholder": "Speichervolumen auswählen"
}, },
"defaultTab": { "defaultTab": {
@@ -2439,7 +2485,7 @@
"error": { "error": {
"internalServerError": "Fehler beim Abrufen der Container Statistiken", "internalServerError": "Fehler beim Abrufen der Container Statistiken",
"layoutSaveTitle": "Tabellenlayout nicht gespeichert", "layoutSaveTitle": "Tabellenlayout nicht gespeichert",
"layoutSaveMessage": "Die Docker-Tabelle konnte nicht gespeichert werden. Versuche die Spalte zu verschieben oder eine andere Spaltengröße zu verwenden. " "layoutSaveMessage": "Das Docker-Tabellenlayout konnte nicht gespeichert werden. Versuche die Spalte zu verschieben oder eine andere Spaltengröße zu verwenden."
}, },
"empty": { "empty": {
"noContainers": "Keine Docker-Container gefunden" "noContainers": "Keine Docker-Container gefunden"
@@ -2739,10 +2785,10 @@
}, },
"showBitrate": { "showBitrate": {
"label": "Zeigt die Bitrate an", "label": "Zeigt die Bitrate an",
"description": "" "description": "Zeige die aktuelle Wiedergabe-Bitrate neben dem Ort an"
}, },
"showLocation": { "showLocation": {
"label": "", "label": "Standort anzeigen",
"description": "Zeigt an, ob der Stream über das LAN oder WAN läuft" "description": "Zeigt an, ob der Stream über das LAN oder WAN läuft"
} }
}, },
@@ -2758,8 +2804,8 @@
"status": "Status", "status": "Status",
"directPlay": "Direkte Wiedergabe", "directPlay": "Direkte Wiedergabe",
"directStream": "Direkter Stream", "directStream": "Direkter Stream",
"transcodeVideo": "", "transcodeVideo": "Video wird transkodiert",
"transcodeAudio": "", "transcodeAudio": "Audio wird transkodiert",
"transcoding": "Transkodierung", "transcoding": "Transkodierung",
"paused": "Pausiert", "paused": "Pausiert",
"remaining": "{minutes} Min. verbleibend", "remaining": "{minutes} Min. verbleibend",
@@ -2966,8 +3012,8 @@
} }
}, },
"showOsVersion": { "showOsVersion": {
"label": "", "label": "Betriebssystem-Version anzeigen",
"description": "" "description": "Füge die Betriebssystemversion jedem Verteilungsetikett hinzu, sofern verfügbar"
}, },
"showComplianceHero": { "showComplianceHero": {
"label": "", "label": "",
@@ -3047,7 +3093,7 @@
"option": { "option": {
"albumId": { "albumId": {
"label": "Album ID", "label": "Album ID",
"description": "Dies ist eine eindeutige ID, die sich auf Ihr Immich Album bezieht. Sie können diese ID finden, indem Sie ein Album öffnen und die einzigartige Zeichenkette von der URL kopieren, zum Beispiel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'" "description": "Dies ist eine eindeutige ID, die sich auf Ihr Immich Album bezieht. Sie können diese ID finden, indem Sie ein Album öffnen und die einzigartige Zeichenkette von der URL kopieren, zum Beispiel 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
}, },
"rotationIntervalSeconds": { "rotationIntervalSeconds": {
"label": "Intervall in Sekunden", "label": "Intervall in Sekunden",
@@ -3117,8 +3163,8 @@
"noColumns": "Spalten in Elementen auswählen", "noColumns": "Spalten in Elementen auswählen",
"noCommunications": "Kann keine Daten von der Integration laden", "noCommunications": "Kann keine Daten von der Integration laden",
"noItems": "", "noItems": "",
"actionFailed": "", "actionFailed": "Aktion fehlgeschlagen",
"actionFailedMessage": "" "actionFailedMessage": "Diese Aktion konnte nicht abgeschlossen werden. Bitte versuche es erneut."
}, },
"items": { "items": {
"actions": { "actions": {
@@ -3406,7 +3452,7 @@
"queued": "" "queued": ""
}, },
"type": { "type": {
"movie": "", "movie": "Film",
"episode": "" "episode": ""
}, },
"empty": { "empty": {
@@ -3766,7 +3812,7 @@
}, },
"resource": { "resource": {
"routers": "", "routers": "",
"services": "", "services": "Dienste",
"middlewares": "" "middlewares": ""
}, },
"status": { "status": {
@@ -4052,16 +4098,16 @@
"temp": "", "temp": "",
"battery": "", "battery": "",
"services": "Dienste", "services": "Dienste",
"uptime": "", "uptime": "Verfügbarkeit",
"agent": "Agent" "agent": "Agent"
} }
}, },
"beszelAlerts": { "beszelAlerts": {
"name": "", "name": "Beszel-Meldungen",
"description": "", "description": "Zeige Beszels Benachrichtigungseinstellungen und -verlauf",
"option": { "option": {
"showHistory": { "showHistory": {
"label": "" "label": "Alarmverlauf anzeigen"
}, },
"maxHistoryItems": { "maxHistoryItems": {
"label": "" "label": ""
@@ -4072,9 +4118,9 @@
"internalServerError": "" "internalServerError": ""
}, },
"history": "Verlauf", "history": "Verlauf",
"resolved": "", "resolved": "Gelöst",
"status": { "status": {
"triggered": "", "triggered": "Ausgelöst",
"ok": "Ok" "ok": "Ok"
} }
}, },
@@ -4083,43 +4129,43 @@
"description": "", "description": "",
"option": { "option": {
"systemId": { "systemId": {
"label": "", "label": "System",
"description": "" "description": ""
}, },
"timePeriod": { "timePeriod": {
"label": "" "label": "Zeitraum"
}, },
"showCpu": { "showCpu": {
"label": "" "label": "CPU-Diagramm anzeigen"
}, },
"showMemory": { "showMemory": {
"label": "" "label": "RAM-Diagramm anzeigen"
}, },
"showDisk": { "showDisk": {
"label": "" "label": "Festplatten-Diagramm anzeigen"
}, },
"showDiskIO": { "showDiskIO": {
"label": "" "label": "Festplatten-E/A-Diagramm anzeigen"
}, },
"showNetwork": { "showNetwork": {
"label": "" "label": "Netzwerk-Diagramm anzeigen"
}, },
"showDockerCpu": { "showDockerCpu": {
"label": "" "label": "Docker-CPU-Diagramm anzeigen"
}, },
"showDockerMemory": { "showDockerMemory": {
"label": "" "label": "Docker-RAM-Diagramm anzeigen"
}, },
"showDockerNetwork": { "showDockerNetwork": {
"label": "" "label": "Docker-Netzwerk-Diagramm anzeigen"
} }
}, },
"selectSystem": "System auswählen", "selectSystem": "System auswählen",
"empty": { "empty": {
"noSystems": "" "noSystems": "Keine Systeme gefunden"
}, },
"error": { "error": {
"internalServerError": "", "internalServerError": "Keine Verbindung zu Beszel möglich. Überprüfe deine Integrations-Einstellungen.",
"liveConnectionFailed": "", "liveConnectionFailed": "",
"switchToHistorical": "", "switchToHistorical": "",
"systemNotFound": "" "systemNotFound": ""
@@ -4127,48 +4173,48 @@
"chart": { "chart": {
"cpu": { "cpu": {
"title": "CPU Auslastung", "title": "CPU Auslastung",
"subtitle": "", "subtitle": "Durchschnittliche Systemweite CPU-Auslastung",
"series": "CPU" "series": "CPU"
}, },
"memory": { "memory": {
"title": "", "title": "Speicherverbrauch",
"subtitle": "", "subtitle": "Genaue Auslastung zum erfassten Zeitpunkt",
"series": "Belegt", "series": "Belegt",
"cache": "" "cache": ""
}, },
"disk": { "disk": {
"title": "Festplattenbelegung", "title": "Festplattenbelegung",
"subtitle": "", "subtitle": "",
"series": "" "series": "Root"
}, },
"diskIO": { "diskIO": {
"title": "", "title": "Festplatten-E/A",
"subtitle": "", "subtitle": "Durchsatz des Root-Dateisystems",
"read": "", "read": "Lesen",
"write": "" "write": "Schreiben"
}, },
"storage": { "storage": {
"title": "", "title": "Speicher",
"subtitle": "", "subtitle": "Festplattenverbrauch pro Dateisystem",
"series": "" "series": "Belegt"
}, },
"network": { "network": {
"title": "", "title": "Bandbreite",
"subtitle": "", "subtitle": "Netzwerkverkehr öffentlicher Schnittstellen",
"sent": "", "sent": "Gesendet",
"recv": "" "recv": "Empfangen"
}, },
"dockerCpu": { "dockerCpu": {
"title": "", "title": "Docker-CPU-Nutzung",
"subtitle": "" "subtitle": "Durchschnittliche CPU-Nutzung der Container"
}, },
"dockerMemory": { "dockerMemory": {
"title": "", "title": "Docker-RAM-Nutzung",
"subtitle": "" "subtitle": "RAM-Nutzung der Docker-Container"
}, },
"dockerNetwork": { "dockerNetwork": {
"title": "", "title": "Docker-Netzwerk-E/A",
"subtitle": "" "subtitle": "Netzwerkverkehr der Docker-Container"
} }
} }
} }
@@ -4545,7 +4591,7 @@
"text": "Konnte die angeforderte Ressource nicht finden" "text": "Konnte die angeforderte Ressource nicht finden"
}, },
"error": { "error": {
"title": "", "title": "Diese Seite konnte nicht geladen werden",
"text": "", "text": "",
"digest": "" "digest": ""
}, },
@@ -5110,7 +5156,7 @@
"copy": "", "copy": "",
"oneClick": { "oneClick": {
"title": "", "title": "",
"subtitle": "", "subtitle": "OAuth wird verwendet. Authentifiziere dich bei der ersten Nutzung im Browser",
"vsCode": "In VS Code installieren", "vsCode": "In VS Code installieren",
"cursor": "In Cursor installieren", "cursor": "In Cursor installieren",
"copyUrl": "MCP URL kopieren", "copyUrl": "MCP URL kopieren",
@@ -5130,7 +5176,7 @@
"button": "Sicherung herunterladen", "button": "Sicherung herunterladen",
"error": "Export fehlgeschlagen", "error": "Export fehlgeschlagen",
"confirm": { "confirm": {
"title": "", "title": "Export sensibler Daten",
"warningTitle": "Dieser Export enthält Ihre gesamte Datenbank.", "warningTitle": "Dieser Export enthält Ihre gesamte Datenbank.",
"warningBody": "Das Backup umfasst alle Benutzer, Integrationsgeheimnisse (verschlüsselt), API-Keys, Board-Konfigurationen und App-Daten. Speichern Sie die exportierte Datei sicher ab und teilen Sie diese nicht.", "warningBody": "Das Backup umfasst alle Benutzer, Integrationsgeheimnisse (verschlüsselt), API-Keys, Board-Konfigurationen und App-Daten. Speichern Sie die exportierte Datei sicher ab und teilen Sie diese nicht.",
"typePrompt": "Geben Sie \"{phrase}\" ein, um fortzufahren", "typePrompt": "Geben Sie \"{phrase}\" ein, um fortzufahren",
@@ -5140,8 +5186,8 @@
}, },
"import": { "import": {
"title": "Datenbank wiederherstellen", "title": "Datenbank wiederherstellen",
"description": "", "description": "Lade ein zuvor exportiertes Backup-ZIP‑File hoch, um deine Datenbank wiederherzustellen. Damit ersetzt du deine gesamte jetzige Datenbank. Falls das Backup mit einem anderen Verschlüsselungs-Key erstellt wurde, werden vertrauliche Daten (Secrets) automatisch neu verschlüsselt.",
"warning": "", "warning": "Ein Backup wiederherzustellen, wird deine jetzige Datenbank komplett ersetzen und das Programm neu starten. Diese Aktion kann nicht rückgängig gemacht werden.",
"dropzone": { "dropzone": {
"title": "Ziehen Sie Ihre Backup-ZIP hierher oder klicken Sie zum Durchsuchen", "title": "Ziehen Sie Ihre Backup-ZIP hierher oder klicken Sie zum Durchsuchen",
"description": "Nur .zip Dateien aus einer Homarr SQLite-Sicherung werden akzeptiert" "description": "Nur .zip Dateien aus einer Homarr SQLite-Sicherung werden akzeptiert"
@@ -6050,7 +6096,7 @@
} }
}, },
"availability": { "availability": {
"available": "", "available": "Verfügbar",
"partiallyAvailable": "", "partiallyAvailable": "",
"processing": "", "processing": "",
"requested": "", "requested": "",
@@ -6332,10 +6378,10 @@
"header": { "header": {
"season": "Staffel", "season": "Staffel",
"episodes": "Episoden", "episodes": "Episoden",
"status": "" "status": "Status"
}, },
"status": { "status": {
"requested": "" "requested": "Angefragt"
} }
}, },
"button": { "button": {
@@ -6448,18 +6494,18 @@
} }
}, },
"onboardingTour": { "onboardingTour": {
"learnMore": "", "learnMore": "Lese die Dokumentation",
"next": "", "next": "Weiter",
"prev": "", "prev": "Zurück",
"done": "", "done": "Fertig",
"skip": "", "skip": "Überspringe die Einführung",
"manage": { "manage": {
"welcome": { "welcome": {
"title": "", "title": "Willkommen bei Homarr!",
"description": "" "description": "Das ist dein Kontrollzentrum. Von hier steuerst du Boards, Apps, Integrationen, Benutzer und die Servereinstellungen. Lass uns eine Einführung in jeden Bereich machen."
}, },
"boardsList": { "boardsList": {
"title": "", "title": "Deine Boards",
"description": "" "description": ""
}, },
"boardsCreate": { "boardsCreate": {
@@ -6493,15 +6539,15 @@
}, },
"board": { "board": {
"welcome": { "welcome": {
"title": "", "title": "Das ist dein Board",
"description": "" "description": "Ein Drag-and-drop-Dashboard, wo du Apps und Widgets anordnen kannst. Du kannst jederzeit Strg+K drücken, um nach Apps, Boards und Aktionen zu suchen."
}, },
"editMode": { "editMode": {
"title": "Bearbeitungsmodus", "title": "Bearbeitungsmodus",
"description": "" "description": "Drücke hier, um in den Bearbeitungsmodus zu kommen. Dort kannst du Widgets, Apps und Abschnitte hinzufügen, welche du durch anschließendes Ziehen neu anordnen und an den Ecken in der Größe anpassen kannst."
}, },
"settings": { "settings": {
"title": "", "title": "Board-Einstellungen",
"description": "" "description": ""
}, },
"switcher": { "switcher": {
File diff suppressed because it is too large. Load diff
+112 -66
View File
@@ -91,7 +91,7 @@
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",
"subtitle": "Configure server settings" "subtitle": "Configure server settings."
}, },
"integrations": { "integrations": {
"title": "Integrations", "title": "Integrations",
@@ -351,7 +351,7 @@
"delete": { "delete": {
"label": "Permanently delete user", "label": "Permanently delete user",
"description": "This deletes this user, including their preferences. This will not delete any boards. The user will not be notified.", "description": "This deletes this user, including their preferences. This will not delete any boards. The user will not be notified.",
"confirm": "Are you sure that you want to delete the user {username}, as well as their preferences" "confirm": "Are you sure that you want to delete the user {username}, as well as their preferences?"
}, },
"select": { "select": {
"label": "Select a user", "label": "Select a user",
@@ -517,7 +517,7 @@
"transfer": { "transfer": {
"label": "Transfer group ownership", "label": "Transfer group ownership",
"description": "Transfer ownership of this group to another user.", "description": "Transfer ownership of this group to another user.",
"confirm": "Are you want to transfer the ownership of the group, {name} to {username}", "confirm": "Are you want to transfer the ownership of the group, {name} to {username}?",
"notification": { "notification": {
"success": { "success": {
"message": "Successfully transferred group {group} to {user}" "message": "Successfully transferred group {group} to {user}"
@@ -536,7 +536,7 @@
}, },
"delete": { "delete": {
"label": "Delete group", "label": "Delete group",
"description": "Once you delete this group, you can not revert the change. Please be careful", "description": "Once you delete this group, you can not revert the change. Please be careful.",
"confirm": "Are you sure you want to delete the group, {name}?", "confirm": "Are you sure you want to delete the group, {name}?",
"notification": { "notification": {
"success": { "success": {
@@ -1344,6 +1344,10 @@
"name": "Traefik", "name": "Traefik",
"description": "Cloud-native reverse proxy and load balancer" "description": "Cloud-native reverse proxy and load balancer"
}, },
"wud": {
"name": "What's Up Docker",
"description": "Monitor which of your Docker containers have image updates available"
},
"paperlessNgx": { "paperlessNgx": {
"name": "Paperless-ngx", "name": "Paperless-ngx",
"description": "Document management system for archiving and organizing scanned documents" "description": "Document management system for archiving and organizing scanned documents"
@@ -1437,8 +1441,18 @@
"here": "here", "here": "here",
"iconPicker": { "iconPicker": {
"label": "Icon URL", "label": "Icon URL",
"header": "Please enter a name or object to filter for the icon. Homarr will search throughout {countIcons} different icons to find the right one.", "description": "Search the library, paste an image URL, or upload a file.",
"headerLoading": "Type to search icons…", "header": "Please enter a name or object to filter for the icon. Homarr will search throughout {countIcons} different icons to find the right one",
"headerLoading": "Type to search icons",
"clear": "Clear image",
"uploadImage": "Upload image",
"results": "Image search results",
"localImages": "Your images",
"svgIcons": "SVG icons",
"otherImages": "Other images",
"urlLoading": "Checking image…",
"urlReady": "Image is ready to use",
"urlError": "This image could not be loaded. Check the URL and try again.",
"noResults": "No icons match \"{search}\".", "noResults": "No icons match \"{search}\".",
"error": "Couldn't load icons." "error": "Couldn't load icons."
}, },
@@ -1498,7 +1512,7 @@
"login": "Log-in", "login": "Log-in",
"homeBoard": "Your home board", "homeBoard": "Your home board",
"loggedOut": "Logged out", "loggedOut": "Logged out",
"updateAvailable": "New update available: {tag}" "updateAvailable": "{countUpdates} updates available: {tag}"
} }
}, },
"dangerZone": "Danger zone", "dangerZone": "Danger zone",
@@ -1793,6 +1807,38 @@
} }
} }
}, },
"wud": {
"name": "What's Up Docker",
"description": "Show how many watched containers have updates available.",
"title": "What's Up Docker",
"monitored": "Monitored",
"updatesAvailable": "{count, plural, one {# update available} other {# updates available}}",
"updateAvailable": "Update available",
"openReleaseNotes": "Open release notes",
"option": {
"showTitle": {
"label": "Show title"
},
"showRing": {
"label": "Show update ring"
},
"layout": {
"label": "Layout",
"option": {
"horizontal": {
"label": "Horizontal"
},
"vertical": {
"label": "Vertical"
}
}
},
"showUpdateList": {
"label": "Show container update list",
"description": "Lists each monitored container that has an image update available"
}
}
},
"dnsHoleSummary": { "dnsHoleSummary": {
"name": "DNS Hole Summary", "name": "DNS Hole Summary",
"description": "Displays the summary of your DNS Hole", "description": "Displays the summary of your DNS Hole",
@@ -1885,7 +1931,7 @@
"label": "Display seconds" "label": "Display seconds"
}, },
"useCustomTimezone": { "useCustomTimezone": {
"label": " " "label": "Use a fixed timezone"
}, },
"timezone": { "timezone": {
"label": "Time zone", "label": "Time zone",
@@ -2430,19 +2476,19 @@
"label": "Invert sorting" "label": "Invert sorting"
}, },
"columnOrder": { "columnOrder": {
"label": "" "label": "Column order"
}, },
"columnWidths": { "columnWidths": {
"label": "" "label": "Column widths"
} }
}, },
"error": { "error": {
"internalServerError": "Failed to fetch containers stats", "internalServerError": "Failed to fetch containers stats",
"layoutSaveTitle": "", "layoutSaveTitle": "Table layout not saved",
"layoutSaveMessage": "" "layoutSaveMessage": "The Docker table layout could not be saved. Try moving or resizing the column again."
}, },
"empty": { "empty": {
"noContainers": "" "noContainers": "No containers found"
} }
}, },
"coolify": { "coolify": {
@@ -2738,17 +2784,17 @@
"description": "Disabling this will not work for Plex" "description": "Disabling this will not work for Plex"
}, },
"showBitrate": { "showBitrate": {
"label": "", "label": "Show bitrate",
"description": "" "description": "Show the current playback bitrate next to the location"
}, },
"showLocation": { "showLocation": {
"label": "", "label": "Show location",
"description": "" "description": "Show whether the stream is on your LAN or WAN"
} }
}, },
"footer": { "footer": {
"streams": "", "streams": "{count, plural, one {# active stream} other {# active streams}}",
"totalBitrate": "" "totalBitrate": "Total: {bitrate}"
}, },
"items": { "items": {
"currentlyPlaying": "Currently playing", "currentlyPlaying": "Currently playing",
@@ -2757,9 +2803,9 @@
"id": "Id", "id": "Id",
"status": "Status", "status": "Status",
"directPlay": "Direct play", "directPlay": "Direct play",
"directStream": "", "directStream": "Direct stream",
"transcodeVideo": "", "transcodeVideo": "Transcoding video",
"transcodeAudio": "", "transcodeAudio": "Transcoding audio",
"transcoding": "Transcoding", "transcoding": "Transcoding",
"paused": "Paused", "paused": "Paused",
"remaining": "{minutes} min left", "remaining": "{minutes} min left",
@@ -3047,7 +3093,7 @@
"option": { "option": {
"albumId": { "albumId": {
"label": "Album ID", "label": "Album ID",
"description": "This is a unique ID which refers to your Immich album. You can find this id by opening an album and copying the unique string from the URL, for example 'a21aca09-9f05-47dc-b1a1-7b53c547226e'" "description": "This is a unique ID which refers to your Immich album. You can find this id by opening an album and copying the unique string from the URL, for example 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
}, },
"rotationIntervalSeconds": { "rotationIntervalSeconds": {
"label": "Interval in seconds", "label": "Interval in seconds",
@@ -3107,18 +3153,18 @@
"description": "" "description": ""
}, },
"columnOrder": { "columnOrder": {
"label": "" "label": "Column order"
}, },
"columnWidths": { "columnWidths": {
"label": "" "label": "Column widths"
} }
}, },
"errors": { "errors": {
"noColumns": "", "noColumns": "",
"noCommunications": "", "noCommunications": "",
"noItems": "", "noItems": "No downloads found",
"actionFailed": "", "actionFailed": "Action failed",
"actionFailedMessage": "" "actionFailedMessage": "The action could not be completed. Please try again."
}, },
"items": { "items": {
"actions": { "actions": {
@@ -3133,7 +3179,7 @@
"detailsTitle": "" "detailsTitle": ""
}, },
"downSpeed": { "downSpeed": {
"columnTitle": "", "columnTitle": "Down",
"detailsTitle": "" "detailsTitle": ""
}, },
"index": { "index": {
@@ -3142,16 +3188,16 @@
}, },
"id": { "id": {
"columnTitle": "", "columnTitle": "",
"detailsTitle": "" "detailsTitle": "Item ID"
}, },
"integration": { "integration": {
"columnTitle": "" "columnTitle": ""
}, },
"name": { "name": {
"columnTitle": "" "columnTitle": "Job name"
}, },
"progress": { "progress": {
"columnTitle": "", "columnTitle": "Progress",
"detailsTitle": "" "detailsTitle": ""
}, },
"ratio": { "ratio": {
@@ -3171,13 +3217,13 @@
"detailsTitle": "" "detailsTitle": ""
}, },
"state": { "state": {
"columnTitle": "", "columnTitle": "State",
"detailsTitle": "" "detailsTitle": ""
}, },
"time": { "time": {
"columnTitle": "", "columnTitle": "",
"detailsTitle": "", "detailsTitle": "",
"eta": "" "eta": "ETA"
}, },
"type": { "type": {
"columnTitle": "", "columnTitle": "",
@@ -3223,21 +3269,21 @@
}, },
"globalRatio": "", "globalRatio": "",
"contextMenu": { "contextMenu": {
"copyName": "", "copyName": "Copy name",
"copyId": "", "copyId": "Copy ID",
"clearFilters": "" "clearFilters": "Clear filters"
}, },
"footer": { "footer": {
"filter": "" "filter": "Filter downloads"
}, },
"stats": { "stats": {
"show": "", "show": "Show global stats",
"hide": "", "hide": "Hide global stats",
"overallProgress": "", "overallProgress": "Overall queue progress",
"totalSize": "", "totalSize": "Total queue size",
"clients": "" "clients": "{count, plural, one {# client} other {# clients}}"
}, },
"disconnected": "" "disconnected": "(disconnected)"
}, },
"mediaReleases": { "mediaReleases": {
"name": "", "name": "",
@@ -3269,7 +3315,7 @@
} }
}, },
"mediaRequests-requestList": { "mediaRequests-requestList": {
"name": "", "name": "Media Requests List",
"description": "", "description": "",
"option": { "option": {
"linksTargetNewTab": { "linksTargetNewTab": {
@@ -3291,15 +3337,15 @@
"unknown": "", "unknown": "",
"pending": "", "pending": "",
"processing": "", "processing": "",
"requested": "", "requested": "Requested",
"partiallyAvailable": "", "partiallyAvailable": "",
"available": "", "available": "",
"blacklisted": "", "blacklisted": "Blacklisted",
"deleted": "" "deleted": ""
}, },
"status": { "status": {
"pending": "", "pending": "",
"approved": "", "approved": "Approved",
"declined": "", "declined": "",
"failed": "", "failed": "",
"completed": "" "completed": ""
@@ -3312,7 +3358,7 @@
"option": {}, "option": {},
"titles": { "titles": {
"stats": { "stats": {
"main": "", "main": "Media Stats",
"approved": "", "approved": "",
"pending": "", "pending": "",
"processing": "", "processing": "",
@@ -3356,15 +3402,15 @@
"panel": { "panel": {
"statistics": { "statistics": {
"empty": "", "empty": "",
"transcodes": "", "transcodes": "Transcodes",
"transcodesCount": "", "transcodesCount": "",
"healthChecksCount": "", "healthChecksCount": "",
"filesCount": "", "filesCount": "",
"savedSpace": "", "savedSpace": "",
"healthChecks": "", "healthChecks": "Health checks",
"videoCodecs": "", "videoCodecs": "Codecs",
"videoContainers": "", "videoContainers": "Containers",
"videoResolutions": "" "videoResolutions": "Resolutions"
}, },
"workers": { "workers": {
"empty": "", "empty": "",
@@ -3574,7 +3620,7 @@
"option": {}, "option": {},
"card": { "card": {
"vpn": { "vpn": {
"countConnected": "" "countConnected": "{count} connected"
} }
}, },
"error": { "error": {
@@ -3591,14 +3637,14 @@
"name": "" "name": ""
}, },
"wifi": { "wifi": {
"name": "" "name": "Wi-Fi"
} }
}, },
"users": { "users": {
"label": "" "label": ""
}, },
"guests": { "guests": {
"label": "" "label": "Guests"
} }
}, },
"option": { "option": {
@@ -3844,7 +3890,7 @@
"option": { "option": {
"cpu": "", "cpu": "",
"memory": "", "memory": "",
"gpu": "", "gpu": "GPU",
"network": "" "network": ""
} }
}, },
@@ -3862,7 +3908,7 @@
"cpu": "", "cpu": "",
"memory": "", "memory": "",
"gpu": "", "gpu": "",
"network": "", "network": "NET",
"up": "", "up": "",
"down": "" "down": ""
} }
@@ -3968,17 +4014,17 @@
"label": "Show agent version" "label": "Show agent version"
}, },
"columnOrder": { "columnOrder": {
"label": "" "label": "Column order"
}, },
"columnWidths": { "columnWidths": {
"label": "" "label": "Column widths"
} }
}, },
"noRecords": "No systems found", "noRecords": "No systems found",
"error": { "error": {
"internalServerError": "Unable to connect to Beszel. Check your integration settings.", "internalServerError": "Unable to connect to Beszel. Check your integration settings.",
"layoutSaveTitle": "", "layoutSaveTitle": "Unable to save table layout",
"layoutSaveMessage": "" "layoutSaveMessage": "Your column order or widths could not be saved. Please try again."
}, },
"column": { "column": {
"system": "System", "system": "System",
@@ -4910,7 +4956,7 @@
"title": "", "title": "",
"status": { "status": {
"idle": "", "idle": "",
"running": "", "running": "Running",
"error": "", "error": "",
"disabled": "" "disabled": ""
}, },
@@ -5376,7 +5422,7 @@
}, },
"remove": { "remove": {
"label": "", "label": "",
"confirm": "", "confirm": "Click again to remove permanently",
"notification": { "notification": {
"success": { "success": {
"title": "", "title": "",
@@ -5812,7 +5858,7 @@
} }
}, },
"search": { "search": {
"placeholder": "", "placeholder": "Search for anything",
"nothingFound": "", "nothingFound": "",
"error": { "error": {
"fetch": "" "fetch": ""
+48 -2
View File
@@ -1344,6 +1344,10 @@
"name": "Traefik", "name": "Traefik",
"description": "Cloud-native reverse proxy and load balancer" "description": "Cloud-native reverse proxy and load balancer"
}, },
"wud": {
"name": "What's Up Docker",
"description": "Monitor which of your Docker containers have image updates available"
},
"paperlessNgx": { "paperlessNgx": {
"name": "Paperless-ngx", "name": "Paperless-ngx",
"description": "Document management system for archiving and organizing scanned documents" "description": "Document management system for archiving and organizing scanned documents"
@@ -1437,8 +1441,18 @@
"here": "here", "here": "here",
"iconPicker": { "iconPicker": {
"label": "Icon URL", "label": "Icon URL",
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.", "description": "Search the library, paste an image URL, or upload a file.",
"headerLoading": "Type to search icons…", "header": "Search {countIcons} images by name",
"headerLoading": "Search images by name",
"clear": "Clear image",
"uploadImage": "Upload image",
"results": "Image search results",
"localImages": "Your images",
"svgIcons": "SVG icons",
"otherImages": "Other images",
"urlLoading": "Checking image…",
"urlReady": "Image is ready to use",
"urlError": "This image could not be loaded. Check the URL and try again.",
"noResults": "No icons match \"{search}\".", "noResults": "No icons match \"{search}\".",
"error": "Couldn't load icons." "error": "Couldn't load icons."
}, },
@@ -1793,6 +1807,38 @@
} }
} }
}, },
"wud": {
"name": "What's Up Docker",
"description": "Show how many watched containers have updates available.",
"title": "What's Up Docker",
"monitored": "Monitored",
"updatesAvailable": "{count, plural, one {# update available} other {# updates available}}",
"updateAvailable": "Update available",
"openReleaseNotes": "Open release notes",
"option": {
"showTitle": {
"label": "Show title"
},
"showRing": {
"label": "Show update ring"
},
"layout": {
"label": "Layout",
"option": {
"horizontal": {
"label": "Horizontal"
},
"vertical": {
"label": "Vertical"
}
}
},
"showUpdateList": {
"label": "Show container update list",
"description": "Lists each monitored container that has an image update available"
}
}
},
"dnsHoleSummary": { "dnsHoleSummary": {
"name": "DNS Hole Summary", "name": "DNS Hole Summary",
"description": "Displays the summary of your DNS Hole", "description": "Displays the summary of your DNS Hole",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+110 -64
View File
@@ -91,7 +91,7 @@
}, },
"settings": { "settings": {
"title": "Asetukset", "title": "Asetukset",
"subtitle": "Määritä palvelinasetukset." "subtitle": "Määritä palvelimen asetukset."
}, },
"integrations": { "integrations": {
"title": "Integraatiot", "title": "Integraatiot",
@@ -110,7 +110,7 @@
"message": "Jotain meni pieleen kojelautaasi määritettäessä. Yritä uudelleen." "message": "Jotain meni pieleen kojelautaasi määritettäessä. Yritä uudelleen."
}, },
"helpers": { "helpers": {
"tooltip": "URL-apurit", "tooltip": "URL-avustajat",
"description": "Nämä apurit pyrkivät arvaamaan palveluidesi URL-osoitteet. Jos tämä ei toimi, kirjoita osoite manuaalisesti." "description": "Nämä apurit pyrkivät arvaamaan palveluidesi URL-osoitteet. Jos tämä ei toimi, kirjoita osoite manuaalisesti."
} }
}, },
@@ -131,7 +131,7 @@
"summary": "Homarr havaitse {apps} sovellusta, joista {integrations} löytyy sisäänrakennettu integraatio", "summary": "Homarr havaitse {apps} sovellusta, joista {integrations} löytyy sisäänrakennettu integraatio",
"appBadge": "Sovellus", "appBadge": "Sovellus",
"appSectionLabel": "Ohjauspaneeliin lisättävät sovellukset ({count} valittu)", "appSectionLabel": "Ohjauspaneeliin lisättävät sovellukset ({count} valittu)",
"scanning": "Dockeria tutkitaan...", "scanning": "Tutkitaan Dockeria...",
"scanningDescription": "Tunnistetaan järjestelmässäsi suoritettavia kontteja" "scanningDescription": "Tunnistetaan järjestelmässäsi suoritettavia kontteja"
} }
}, },
@@ -159,7 +159,7 @@
}, },
"invite": { "invite": {
"title": "Liity Homarriin", "title": "Liity Homarriin",
"subtitle": "Tervetuloa Homarriin! Luo itsellesi käyttäjätunnus", "subtitle": "Tervetuloa Homarriin! Luo käyttäjätunnuksesi",
"description": "Sinut kutsui {username}" "description": "Sinut kutsui {username}"
}, },
"init": { "init": {
@@ -218,7 +218,7 @@
"action": { "action": {
"login": { "login": {
"label": "Kirjaudu", "label": "Kirjaudu",
"labelWith": "Kirjaudu {{provider}}-tunnuksella", "labelWith": "Kirjaudu palvelulla: {provider}",
"notification": { "notification": {
"success": { "success": {
"title": "Kirjautuminen onnistui", "title": "Kirjautuminen onnistui",
@@ -302,7 +302,7 @@
"changeDdgBangs": { "changeDdgBangs": {
"notification": { "notification": {
"success": { "success": {
"message": "DuckDuckGo Bangs -komentojen tila vaihdettiin" "message": "DuckDuckGon Bangs-komentojen tila vaihdettiin"
}, },
"error": { "error": {
"message": "DuckDuckGo Bangs -komentojen tilaa ei voitu vaihtaa" "message": "DuckDuckGo Bangs -komentojen tilaa ei voitu vaihtaa"
@@ -397,7 +397,7 @@
}, },
"use-all": { "use-all": {
"label": "Sovellusten käyttö", "label": "Sovellusten käyttö",
"description": "Sallii jäsenten lisätä kaikkia sovelluksia paneeleihinsa." "description": "Sallii jäsenten lisätä kaikkia sovelluksia paneeleihinsa"
}, },
"modify-all": { "modify-all": {
"label": "Sovellusten muokkaus", "label": "Sovellusten muokkaus",
@@ -414,19 +414,19 @@
"item": { "item": {
"create": { "create": {
"label": "Paneelien luonti", "label": "Paneelien luonti",
"description": "Sallii jäsenten luoda paneeleita." "description": "Sallii jäsenten luoda paneeleita"
}, },
"view-all": { "view-all": {
"label": "Paneelien tarkastelu", "label": "Paneelien tarkastelu",
"description": "Sallii jäsenten tarkastella kaikkia paneeleita." "description": "Sallii jäsenten tarkastella kaikkia paneeleita"
}, },
"modify-all": { "modify-all": {
"label": "Paneelien muokkaus", "label": "Paneelien muokkaus",
"description": "Sallii jäsenten muokata kaikkia paneeleita (pois lukien käyttöoikeudet ja vaaravyöhyke)." "description": "Sallii jäsenten muokata kaikkia paneeleita (pois lukien käyttöoikeudet ja vaaravyöhyke)"
}, },
"full-all": { "full-all": {
"label": "Paneelien hallinta", "label": "Paneelien hallinta",
"description": "Sallii jäsenten tarkastella, muokata ja poistaa kaikkia paneeleita (mukaan lukien käyttöoikeudet ja vaaravyöhyke)." "description": "Sallii jäsenten tarkastella, muokata ja poistaa kaikkia paneeleita (mukaan lukien käyttöoikeudet ja vaaravyöhyke)"
} }
} }
}, },
@@ -996,7 +996,7 @@
"grid": { "grid": {
"widgetCount": "{count} widgettiä", "widgetCount": "{count} widgettiä",
"noWidgets": "Widgettejä ei ole", "noWidgets": "Widgettejä ei ole",
"detected": "Havaittu Dockerin kautta" "detected": "Havaittu Dockerilla"
}, },
"category": { "category": {
"dnsHole": "DNS", "dnsHole": "DNS",
@@ -1228,7 +1228,7 @@
}, },
"tooManyRequests": { "tooManyRequests": {
"title": "", "title": "",
"message": "Pyyntöjä oli liikaa. Pyyntöjäsi todennäköisesti rajoitetaan tai sinut estetään kohdejärjestelmässä." "message": "Pyyntöjä oli liikaa. Pyyntöjäsi todennäköisesti rajoitetaan tai sinut estetään kohdejärjestelmässä"
} }
} }
}, },
@@ -1254,8 +1254,8 @@
"newLabel": "Uusi käyttäjätunnus" "newLabel": "Uusi käyttäjätunnus"
}, },
"apiKey": { "apiKey": {
"label": "Rajapinnan avain", "label": "API-avain",
"newLabel": "Uusi rajapinnan avain" "newLabel": "Uusi API-avain"
}, },
"password": { "password": {
"label": "Salasana", "label": "Salasana",
@@ -1344,6 +1344,10 @@
"name": "Traefik", "name": "Traefik",
"description": "Pilvinatiivi käänteinen välityspalvelin ja kuormantasaaja" "description": "Pilvinatiivi käänteinen välityspalvelin ja kuormantasaaja"
}, },
"wud": {
"name": "Mikä Ylös Docker",
"description": "Tarkkaile missä Docker-säiliöissä on saatavilla kuvapäivityksiä"
},
"paperlessNgx": { "paperlessNgx": {
"name": "Paperless-ngx", "name": "Paperless-ngx",
"description": "Asiakirjojen hallintajärjestelmä skannattujen dokumenttien arkistointiin ja järjestelyyn" "description": "Asiakirjojen hallintajärjestelmä skannattujen dokumenttien arkistointiin ja järjestelyyn"
@@ -1437,8 +1441,18 @@
"here": "", "here": "",
"iconPicker": { "iconPicker": {
"label": "Kuvakkeen URL-osoite", "label": "Kuvakkeen URL-osoite",
"header": "Suodata kuvakkeita syöttämällä nimi tai objekti... Homarr etsii {countIcons} kuvakkeesta.", "description": "Etsi kirjastosta, liitä kuvan URL-osoite tai lataa tiedosto.",
"headerLoading": "Kirjoita hakeaksesi kuvakkeita…", "header": "Etsi {countIcons} kuvaa nimen mukaan",
"headerLoading": "Hae kuvia nimen mukaan",
"clear": "Tyhjennä kuva",
"uploadImage": "Lataa kuva",
"results": "Haun tulokset",
"localImages": "Omat kuvat",
"svgIcons": "SVG kuvakkeet",
"otherImages": "Muut kuvat",
"urlLoading": "Tarkistetaan kuvaa…",
"urlReady": "Kuva on valmis käytettäväksi",
"urlError": "Kuvaa ei voitu ladata. Tarkista URL-osoite ja yritä uudelleen.",
"noResults": "Ei kuvakkeita haulla \"{search}\".", "noResults": "Ei kuvakkeita haulla \"{search}\".",
"error": "Kuvakkeiden lataaminen epäonnistui." "error": "Kuvakkeiden lataaminen epäonnistui."
}, },
@@ -1793,6 +1807,38 @@
} }
} }
}, },
"wud": {
"name": "Mikä Ylös Docker",
"description": "Näytä kuinka monta katsottua astiaa on saatavilla päivityksiä.",
"title": "Mikä Ylös Docker",
"monitored": "Valvottu",
"updatesAvailable": "{count, plural, one {# päivitys saatavilla} other {# päivitystä saatavilla}}",
"updateAvailable": "Päivitys saatavilla",
"openReleaseNotes": "Avaa julkaisutiedot",
"option": {
"showTitle": {
"label": "Näytä otsikko"
},
"showRing": {
"label": "Näytä päivitysrengas"
},
"layout": {
"label": "Asettelu",
"option": {
"horizontal": {
"label": "Vaaka"
},
"vertical": {
"label": "Pysty"
}
}
},
"showUpdateList": {
"label": "Näytä kontin päivityslista",
"description": "Luettele jokaisen seuratun säiliön joka on saatavilla päivityksessä"
}
}
},
"dnsHoleSummary": { "dnsHoleSummary": {
"name": "", "name": "",
"description": "", "description": "",
@@ -2239,7 +2285,7 @@
}, },
"showCurrentWindSpeed": { "showCurrentWindSpeed": {
"label": "Näytä nykyinen tuulen nopeus", "label": "Näytä nykyinen tuulen nopeus",
"description": "Vain nykyiselle säälle." "description": "Vain nykyiselle säälle"
}, },
"useImperialSpeed": { "useImperialSpeed": {
"label": "Käytä tuulen nopeudelle mph-yksikköä" "label": "Käytä tuulen nopeudelle mph-yksikköä"
@@ -2430,19 +2476,19 @@
"label": "Käänteinen järjestys" "label": "Käänteinen järjestys"
}, },
"columnOrder": { "columnOrder": {
"label": "" "label": "Sarakkeiden järjestys"
}, },
"columnWidths": { "columnWidths": {
"label": "" "label": "Sarakkeiden leveydet"
} }
}, },
"error": { "error": {
"internalServerError": "", "internalServerError": "",
"layoutSaveTitle": "", "layoutSaveTitle": "Taulukon asettelua ei tallennettu",
"layoutSaveMessage": "" "layoutSaveMessage": "Docker-taulukon asettelua ei voitu tallentaa. Yritä siirtää tai muuttaa sarakkeen kokoa uudelleen."
}, },
"empty": { "empty": {
"noContainers": "" "noContainers": "Kontteja ei löytynyt"
} }
}, },
"coolify": { "coolify": {
@@ -2738,17 +2784,17 @@
"description": "" "description": ""
}, },
"showBitrate": { "showBitrate": {
"label": "", "label": "Näytä bittinopeus",
"description": "" "description": "Näytä nykyinen toiston bittinopeus sijainnin vieressä"
}, },
"showLocation": { "showLocation": {
"label": "", "label": "Näytä sijainti",
"description": "" "description": "Näytä, onko suoratoisto LAN- vai WAN-verkossa."
} }
}, },
"footer": { "footer": {
"streams": "", "streams": "{count, plural, one {# aktiivinen suoratoisto} other {# aktiivista suoratoistoa}}",
"totalBitrate": "" "totalBitrate": "Yhteensä: {bitrate}"
}, },
"items": { "items": {
"currentlyPlaying": "", "currentlyPlaying": "",
@@ -2757,9 +2803,9 @@
"id": "", "id": "",
"status": "Tila", "status": "Tila",
"directPlay": "Muuntamaton toisto", "directPlay": "Muuntamaton toisto",
"directStream": "", "directStream": "Suora suoratoisto",
"transcodeVideo": "", "transcodeVideo": "Videon transkoodaus",
"transcodeAudio": "", "transcodeAudio": "Äänen transkoodaus",
"transcoding": "Transkoodaus", "transcoding": "Transkoodaus",
"paused": "Keskeytetty", "paused": "Keskeytetty",
"remaining": "{minutes} min jäljellä", "remaining": "{minutes} min jäljellä",
@@ -3104,21 +3150,21 @@
}, },
"limitPerIntegration": { "limitPerIntegration": {
"label": "Integraation elementtien määrärajoitus", "label": "Integraation elementtien määrärajoitus",
"description": "Tämä rajoittaa näytettävien elementtien määrää integraatiokohtaisesti, ei kokonaisvaltaisesti." "description": "Tämä rajoittaa näytettävien elementtien määrää integraatiokohtaisesti, ei kokonaisvaltaisesti"
}, },
"columnOrder": { "columnOrder": {
"label": "" "label": "Sarakkeiden järjestys"
}, },
"columnWidths": { "columnWidths": {
"label": "" "label": "Sarakkeiden leveydet"
} }
}, },
"errors": { "errors": {
"noColumns": "Valitse kohteiden sarakkeet", "noColumns": "Valitse kohteiden sarakkeet",
"noCommunications": "Integraatiolta ei voida noutaa tietoja", "noCommunications": "Integraatiolta ei voida noutaa tietoja",
"noItems": "", "noItems": "Latauksia ei löytynyt",
"actionFailed": "", "actionFailed": "Toiminto epäonnistui",
"actionFailedMessage": "" "actionFailedMessage": "Toimintoa ei voitu suorittaa. Yritä uudelleen."
}, },
"items": { "items": {
"actions": { "actions": {
@@ -3142,7 +3188,7 @@
}, },
"id": { "id": {
"columnTitle": "Tunniste", "columnTitle": "Tunniste",
"detailsTitle": "" "detailsTitle": "Kohteen ID"
}, },
"integration": { "integration": {
"columnTitle": "Integraatio" "columnTitle": "Integraatio"
@@ -3177,7 +3223,7 @@
"time": { "time": {
"columnTitle": "Valmistumisaika", "columnTitle": "Valmistumisaika",
"detailsTitle": "", "detailsTitle": "",
"eta": "" "eta": "Arvioitu aika"
}, },
"type": { "type": {
"columnTitle": "Tyyppi", "columnTitle": "Tyyppi",
@@ -3223,21 +3269,21 @@
}, },
"globalRatio": "Kokonaissuhde", "globalRatio": "Kokonaissuhde",
"contextMenu": { "contextMenu": {
"copyName": "", "copyName": "Kopioi nimi",
"copyId": "", "copyId": "Kopioi ID",
"clearFilters": "" "clearFilters": "Tyhjennä suodattimet"
}, },
"footer": { "footer": {
"filter": "" "filter": "Suodata latauksia"
}, },
"stats": { "stats": {
"show": "", "show": "Näytä globaalit tilastot",
"hide": "", "hide": "Piilota globaalit tilastot",
"overallProgress": "", "overallProgress": "Jonon kokonaisedistyminen",
"totalSize": "", "totalSize": "Jonon kokonaiskoko",
"clients": "" "clients": "{count, plural, one {# asiakas} other {# asiakasta}}"
}, },
"disconnected": "" "disconnected": "(ei yhteydessä)"
}, },
"mediaReleases": { "mediaReleases": {
"name": "", "name": "",
@@ -3699,7 +3745,7 @@
"unhealthy": "", "unhealthy": "",
"ok": "", "ok": "",
"fail": "", "fail": "",
"noSectionsEnabled": "Aktivoi ainakin yksi osio widgetin asetuksista.", "noSectionsEnabled": "Ota ainakin yksi osio käyttöön widgetin asetuksista.",
"error": { "error": {
"internalServerError": "" "internalServerError": ""
} }
@@ -3968,17 +4014,17 @@
"label": "Näytä agentin versio" "label": "Näytä agentin versio"
}, },
"columnOrder": { "columnOrder": {
"label": "" "label": "Sarakkeiden järjestys"
}, },
"columnWidths": { "columnWidths": {
"label": "" "label": "Sarakkeiden leveydet"
} }
}, },
"noRecords": "Järjestelmiä ei löytynyt", "noRecords": "Järjestelmiä ei löytynyt",
"error": { "error": {
"internalServerError": "Beszel-yhteyden muodostaminen epäonnistui. Tarkista integraatioasetuksesi.", "internalServerError": "Beszel-yhteyden muodostaminen epäonnistui. Tarkista integraatioasetuksesi.",
"layoutSaveTitle": "", "layoutSaveTitle": "Taulukon asettelua ei voitu tallentaa",
"layoutSaveMessage": "" "layoutSaveMessage": "Sarakkeiden järjestystä tai leveyksiä ei voitu tallentaa. Yritä uudelleen."
}, },
"column": { "column": {
"system": "Järjestelmä", "system": "Järjestelmä",
@@ -4377,7 +4423,7 @@
}, },
"itemRadius": { "itemRadius": {
"label": "Kohteen säde", "label": "Kohteen säde",
"description": "Muuttaa paneelin korttien kulmien pyöristystä.", "description": "Muuttaa paneelin korttien pyöreyttä",
"option": { "option": {
"xs": "Erittäin pieni", "xs": "Erittäin pieni",
"sm": "Pieni", "sm": "Pieni",
@@ -4922,7 +4968,7 @@
"label": "Analytiikka" "label": "Analytiikka"
}, },
"ping": { "ping": {
"label": "Ping-koestukset" "label": "Pingit"
} }
}, },
"interval": { "interval": {
@@ -5287,7 +5333,7 @@
"refresh": { "refresh": {
"lastUpdated": "Viimeisin päivitys {when}" "lastUpdated": "Viimeisin päivitys {when}"
}, },
"search": "Suodata {count} konttia...", "search": "Suodata {count} konttia",
"selected": "{selectCount}/{totalCount} kontista valittu", "selected": "{selectCount}/{totalCount} kontista valittu",
"footer": "Yhteensä {count} konttia", "footer": "Yhteensä {count} konttia",
"totalCpu": "CPU yhteensä: {cpu}%", "totalCpu": "CPU yhteensä: {cpu}%",
@@ -5376,7 +5422,7 @@
}, },
"remove": { "remove": {
"label": "Poista", "label": "Poista",
"confirm": "", "confirm": "Napsauta uudelleen poistaaksesi pysyvästi",
"notification": { "notification": {
"success": { "success": {
"title": "", "title": "",
@@ -5918,14 +5964,14 @@
"title": "Asetukset", "title": "Asetukset",
"children": { "children": {
"detail": { "detail": {
"title": "Valitse muutettava asetus.", "title": "Valitse muutettava asetus",
"backHint": "Palaa takaisin askelpalauttimella tai painamalla Takaisin-painiketta", "backHint": "Palaa takaisin askelpalauttimella tai painamalla Takaisin-painiketta",
"backAction": "Palaa takaisin" "backAction": "Palaa takaisin"
} }
}, },
"notification": { "notification": {
"error": { "error": {
"message": "Asetusta ei voitu päivittää." "message": "Asetusta ei voitu päivittää"
} }
}, },
"option": { "option": {
@@ -6009,10 +6055,10 @@
} }
}, },
"pingIconsEnabled": { "pingIconsEnabled": {
"label": "Käytä ping-kuvakkeita", "label": "Käytä ping-ikoneita",
"children": { "children": {
"detail": { "detail": {
"title": "Käytä ping-kuvakkeita" "title": "Käytä ping-ikoneita"
} }
} }
}, },
@@ -6191,7 +6237,7 @@
"label": "Hallinnoi Dockeria" "label": "Hallinnoi Dockeria"
}, },
"manageApi": { "manageApi": {
"label": "Swagger-rajapinta" "label": "Swagger API"
}, },
"manageLog": { "manageLog": {
"label": "Tarkastele lokeja" "label": "Tarkastele lokeja"
+251 -205
View File
@@ -4,7 +4,7 @@
"start": { "start": {
"title": "Bienvenue sur Homarr", "title": "Bienvenue sur Homarr",
"subtitle": "Commençons par configurer votre instance Homarr.", "subtitle": "Commençons par configurer votre instance Homarr.",
"description": "Pour commencer, veuillez selectionner comment vous souhaitez configurer votre instance Homarr.", "description": "Pour commencer, veuillez sélectionner comment vous souhaitez configurer votre instance Homarr.",
"action": { "action": {
"scratch": "Partir de zéro", "scratch": "Partir de zéro",
"importOldmarr": "Importer depuis Homarr avant la 1.0", "importOldmarr": "Importer depuis Homarr avant la 1.0",
@@ -129,7 +129,7 @@
"detected": "Docker a détecté {count} services", "detected": "Docker a détecté {count} services",
"detectedBreakdown": "Intégrations {integrations} · Applications {apps}", "detectedBreakdown": "Intégrations {integrations} · Applications {apps}",
"summary": "Les applications {apps} ont été détectées par Homarr, dont {integrations} ont des intégrations intégrées", "summary": "Les applications {apps} ont été détectées par Homarr, dont {integrations} ont des intégrations intégrées",
"appBadge": "App", "appBadge": "Application",
"appSectionLabel": "Applications à ajouter au tableau de bord ({count} sélectionné)", "appSectionLabel": "Applications à ajouter au tableau de bord ({count} sélectionné)",
"scanning": "Analyse de Docker...", "scanning": "Analyse de Docker...",
"scanningDescription": "Recherche de conteneurs en cours d'exécution sur votre système" "scanningDescription": "Recherche de conteneurs en cours d'exécution sur votre système"
@@ -604,7 +604,7 @@
} }
}, },
"app": { "app": {
"search": "Trouver une application", "search": "Trouver une app",
"page": { "page": {
"list": { "list": {
"title": "Applications", "title": "Applications",
@@ -673,7 +673,7 @@
}, },
"action": { "action": {
"select": { "select": {
"label": "Sélectionner l'app", "label": "Sélectionner une app",
"notFound": "Aucune app trouvée", "notFound": "Aucune app trouvée",
"search": "Rechercher une application", "search": "Rechercher une application",
"noResults": "Pas de résultats", "noResults": "Pas de résultats",
@@ -739,37 +739,37 @@
"label": "Étiquette", "label": "Étiquette",
"unit": "Unité", "unit": "Unité",
"mappings": "Correspondances", "mappings": "Correspondances",
"tablePath": "", "tablePath": "Chemin du tableau JSON",
"tablePathHint": "", "tablePathHint": "Chemin vers le tableau dans la réponse, par exemple $.data.items",
"columns": "Colonnes", "columns": "Colonnes",
"header": "En-tête", "header": "En-tête",
"items": "Objets", "items": "Objets",
"gridColumns": "Nombre de colonnes", "gridColumns": "Nombre de colonnes",
"cardStyle": "Style des cartes", "cardStyle": "Style des cartes",
"bars": "Barres", "bars": "Barres",
"valuePath": "", "valuePath": "Chemin de la valeur JSON",
"maxPath": "", "maxPath": "Chemin de la valeur max JSON",
"showPercentage": "Afficher le pourcentage", "showPercentage": "Afficher le pourcentage",
"barSize": "Taille de la barre", "barSize": "Taille de la barre",
"goodValues": "Bonnes valeurs (séparées par des virgules)", "goodValues": "Bonnes valeurs (séparées par des virgules)",
"dotSize": "Taille des points", "dotSize": "Taille des points",
"valueSize": "Taille", "valueSize": "Taille",
"labelPosition": "Position du libellé", "labelPosition": "Position du libellé",
"kvLayout": "", "kvLayout": "Mise en page",
"striped": "", "striped": "Lignes en couleur alternées",
"compact": "Mode Compact", "compact": "Mode Compact",
"maxHeight": "", "maxHeight": "Hauteur maximale (px)",
"color": "Couleur", "color": "Couleur",
"buttonLabel": "", "buttonLabel": "Libellé du bouton",
"buttonColor": "", "buttonColor": "Couleur du bouton",
"confirmText": "Texte de confirmation", "confirmText": "Texte de confirmation",
"confirmTextHint": "", "confirmTextHint": "Si défini, une popup de confirmation apparaîtra avant d'exécuter la requête",
"successMessage": "Message de réussite", "successMessage": "Message de réussite",
"successMessageHint": "", "successMessageHint": "Message affiché après une exécution réussie",
"enabled": "Activé", "enabled": "Activé",
"template": { "template": {
"label": "Template JSX", "label": "Template JSX",
"description": "" "description": "Modèle JSX Mantine. Accédez aux champs de réponse de l'API via les liaisons de données (par ex. data.name). Utilisez uniquement les composants autorisés. Interdit : constructor, __proto__, eval, Function, window, document."
} }
}, },
"placeholder": { "placeholder": {
@@ -779,23 +779,23 @@
"exampleUnit": "°C", "exampleUnit": "°C",
"iconUrl": "https://...", "iconUrl": "https://...",
"url": "https://api.example.com/api/v1/status", "url": "https://api.example.com/api/v1/status",
"headerName": "", "headerName": "X-API-Key",
"jsonPath": "$.data.value", "jsonPath": "$.data.value",
"tablePath": "", "tablePath": "$.data.items",
"columnJsonPath": "", "columnJsonPath": "$.name",
"valuePath": "", "valuePath": "$.data.used",
"maxPath": "", "maxPath": "$.data.total",
"goodValues": "", "goodValues": "en ligne, vrai,",
"color": "" "color": "bleu"
}, },
"cardStyleOption": { "cardStyleOption": {
"filled": "", "filled": "Rempli",
"outline": "", "outline": "Contour",
"subtle": "" "subtle": "Discret"
}, },
"layoutOption": { "layoutOption": {
"list": "", "list": "Liste",
"grid": "" "grid": "Grille"
}, },
"sizeOption": { "sizeOption": {
"sm": "Petit", "sm": "Petit",
@@ -815,7 +815,7 @@
"displayTypeDescription": { "displayTypeDescription": {
"singleValue": "Afficher une valeur extraite avec une étiquette et une unité", "singleValue": "Afficher une valeur extraite avec une étiquette et une unité",
"keyValue": "Afficher plusieurs valeurs étiquetées côte à côte", "keyValue": "Afficher plusieurs valeurs étiquetées côte à côte",
"table": "", "table": "Afficher les lignes de données dans un format de tableau",
"statGrid": "", "statGrid": "",
"progressBars": "", "progressBars": "",
"statusIndicator": "Points d'état colorés montrant en ligne/hors ligne ou l'état de santé", "statusIndicator": "Points d'état colorés montrant en ligne/hors ligne ou l'état de santé",
@@ -851,23 +851,23 @@
"customJsx": "JSX personnalisé" "customJsx": "JSX personnalisé"
}, },
"secret": { "secret": {
"apiKey": "", "apiKey": "Clé API / jeton",
"username": "Nom d'utilisateur", "username": "Nom d'utilisateur",
"password": "Mot de passe", "password": "Mot de passe",
"savedPlaceholder": "" "savedPlaceholder": "••••••• (sauvegardé, laisser vide pour garder)"
}, },
"preview": { "preview": {
"title": "Aperçu", "title": "Aperçu",
"test": "Tester", "test": "Tester",
"hint": "Cliquez sur Test pour prévisualiser la réponse API avec votre configuration actuelle.", "hint": "Cliquez sur Test pour prévisualiser la réponse API avec votre configuration actuelle.",
"rawResponse": "" "rawResponse": "Réponse de JSON brute"
}, },
"notification": { "notification": {
"created": "Widget \"{name}\" créé avec succès.", "created": "Widget \"{name}\" créé avec succès.",
"updated": "Widget \"{name}\" mis à jour avec succès.", "updated": "Widget \"{name}\" mis à jour avec succès.",
"deleted": "Widget \"{name}\" supprimé.", "deleted": "Widget \"{name}\" supprimé.",
"imported": "Widget importé avec succès.", "imported": "Widget importé avec succès.",
"aiPromptCopied": "", "aiPromptCopied": "Prompt IA a été copiée dans le presse-papiers",
"aiPromptDescription": "", "aiPromptDescription": "",
"aiPromptCopy": "Copier dans le presse-papiers", "aiPromptCopy": "Copier dans le presse-papiers",
"aiPromptNoResponse": "", "aiPromptNoResponse": "",
@@ -1168,15 +1168,15 @@
}, },
"invalidUrl": { "invalidUrl": {
"title": "URL invalide", "title": "URL invalide",
"message": "L'URL est invalide." "message": "L'URL est invalide"
}, },
"secretNotDefined": { "secretNotDefined": {
"title": "Identifiants manquants", "title": "Identifiants manquants",
"message": "Les informations d'identification n'ont pas toutes été fournies" "message": "Tous les identifiants n'ont pas été fournis"
}, },
"invalidCredentials": { "invalidCredentials": {
"title": "Informations d'identification incorrectes.", "title": "Les identifiants sont invalides",
"message": "Les informations d'identification ne sont pas valides" "message": "Les identifiants sont invalides"
}, },
"commonError": { "commonError": {
"title": "Échec de la connexion", "title": "Échec de la connexion",
@@ -1290,12 +1290,12 @@
"newLabel": "Nouvelle clé API (secrète)" "newLabel": "Nouvelle clé API (secrète)"
}, },
"patchmonApiKey": { "patchmonApiKey": {
"label": "", "label": "Clé API",
"newLabel": "" "newLabel": "Nouvelle clé API"
}, },
"patchmonApiSecret": { "patchmonApiSecret": {
"label": "", "label": "Clé secrète d'API",
"newLabel": "" "newLabel": "Nouveau secret API"
}, },
"githubAppId": { "githubAppId": {
"label": "Identifiant de l'application", "label": "Identifiant de l'application",
@@ -1321,15 +1321,15 @@
"full": "Accès intégral à l'intégration" "full": "Accès intégral à l'intégration"
}, },
"audiobookshelf": { "audiobookshelf": {
"name": "", "name": "Étagère de livres audio",
"description": "" "description": "Serveur de livres audio et de podcasts auto-hébergés"
}, },
"navidrome": { "navidrome": {
"name": "Navidrome", "name": "Navidrome",
"description": "" "description": "Serveur de streaming de musique auto-hébergé avec compatibilité avec l'API Subsonic"
}, },
"uptimeKuma": { "uptimeKuma": {
"name": "", "name": "Uptime Kuma",
"description": "", "description": "",
"secret": { "secret": {
"slug": { "slug": {
@@ -1341,19 +1341,23 @@
} }
}, },
"traefik": { "traefik": {
"name": "", "name": "Traefik",
"description": "" "description": ""
}, },
"wud": {
"name": "Qu'est ce que Docker",
"description": "Surveiller lequel de vos conteneurs Docker a des mises à jour d'images disponibles"
},
"paperlessNgx": { "paperlessNgx": {
"name": "", "name": "Paperless-ngx",
"description": "" "description": ""
}, },
"patchmon": { "patchmon": {
"name": "", "name": "PatchMon",
"description": "" "description": "Gestion et surveillance des correctifs pour les hôtes Linux"
}, },
"bazarr": { "bazarr": {
"name": "", "name": "Bazarr",
"description": "" "description": ""
} }
}, },
@@ -1436,9 +1440,19 @@
}, },
"here": "ici", "here": "ici",
"iconPicker": { "iconPicker": {
"label": "URL de l'icône :", "label": "URL de l'icône ",
"header": "Tapez le nom ou les objets pour filtrer les icônes... Homarr recherchera les icônes {countIcons} pour vous.", "description": "Recherchez dans la bibliothèque, collez une URL d'image, ou téléchargez un fichier.",
"headerLoading": "Tapez pour rechercher les icônes…", "header": "Rechercher les images {countIcons} par nom",
"headerLoading": "Rechercher des images par nom",
"clear": "Effacer l'image",
"uploadImage": "Charger une image",
"results": "Résultats de la recherche d'images",
"localImages": "Vos images",
"svgIcons": "Icônes SVG",
"otherImages": "Autres images",
"urlLoading": "Vérification de l'image…",
"urlReady": "L'image est prête à être utilisée",
"urlError": "Cette image n'a pas pu être chargée. Vérifiez l'URL et réessayez.",
"noResults": "Aucune icône ne correspond à \"{search}\".", "noResults": "Aucune icône ne correspond à \"{search}\".",
"error": "Impossible de charger les icônes." "error": "Impossible de charger les icônes."
}, },
@@ -1628,10 +1642,10 @@
"actions": "Actions" "actions": "Actions"
}, },
"status": { "status": {
"success": "", "success": "OK",
"error": "", "error": "Erreur",
"loading": "", "loading": "Chargement",
"idle": "" "idle": "Inactif"
} }
}, },
"create": { "create": {
@@ -1785,7 +1799,7 @@
} }
}, },
"archiveTeamWarrior": { "archiveTeamWarrior": {
"name": "", "name": "ArchiveTeam Warrior",
"description": "", "description": "",
"option": { "option": {
"showBroadcastMessage": { "showBroadcastMessage": {
@@ -1793,6 +1807,38 @@
} }
} }
}, },
"wud": {
"name": "Qu'est ce que Docker",
"description": "Afficher combien de conteneurs surveillés ont des mises à jour disponibles.",
"title": "Qu'est ce que Docker",
"monitored": "Surveillé",
"updatesAvailable": "{count, plural, one {# mise à jour disponible} other {# mises à jour disponibles}}",
"updateAvailable": "Mise à jour disponible",
"openReleaseNotes": "Ouvrir les notes de version",
"option": {
"showTitle": {
"label": "Afficher le titre"
},
"showRing": {
"label": "Afficher l'anneau de mise à jour"
},
"layout": {
"label": "Mise en page",
"option": {
"horizontal": {
"label": "Horizontale"
},
"vertical": {
"label": "Vertical"
}
}
},
"showUpdateList": {
"label": "Afficher la liste de mise à jour du conteneur",
"description": "Liste chaque conteneur surveillé qui a une mise à jour d'image disponible"
}
}
},
"dnsHoleSummary": { "dnsHoleSummary": {
"name": "Résumé du puit DNS", "name": "Résumé du puit DNS",
"description": "Affiche le résumé de votre puit DNS", "description": "Affiche le résumé de votre puit DNS",
@@ -1907,14 +1953,14 @@
"description": "Utiliser ISO 8601 pour formater la date (cela remplacera les autres options)" "description": "Utiliser ISO 8601 pour formater la date (cela remplacera les autres options)"
}, },
"showWeather": { "showWeather": {
"label": "", "label": "Afficher la météo",
"description": "" "description": "Afficher une petite icône météo animée et la température dans le coin de la carte"
}, },
"weatherLocation": { "weatherLocation": {
"label": "" "label": "Lieu de la météo"
}, },
"isWeatherFormatFahrenheit": { "isWeatherFormatFahrenheit": {
"label": "" "label": "Utiliser les degrés Fahrenheit"
} }
} }
}, },
@@ -1944,7 +1990,7 @@
"noStation": "Aucune station sélectionnée", "noStation": "Aucune station sélectionnée",
"option": { "option": {
"baseUrl": { "baseUrl": {
"label": "" "label": "URL Search.ch"
}, },
"station": { "station": {
"label": "Station" "label": "Station"
@@ -1976,8 +2022,8 @@
"clear": "Effacer la mise en forme", "clear": "Effacer la mise en forme",
"heading": "Titre {level}", "heading": "Titre {level}",
"align": "Aligner le texte : {position}", "align": "Aligner le texte : {position}",
"directionLtr": "", "directionLtr": "Définir la direction du texte de gauche à droite",
"directionRtl": "", "directionRtl": "Définir la direction du texte de droite à gauche",
"blockquote": "Citation", "blockquote": "Citation",
"horizontalLine": "Ligne horizontale", "horizontalLine": "Ligne horizontale",
"bulletList": "Liste à puces", "bulletList": "Liste à puces",
@@ -2047,7 +2093,7 @@
"edit": "Modifier", "edit": "Modifier",
"save": "Sauvegarder", "save": "Sauvegarder",
"cancel": "Annuler", "cancel": "Annuler",
"readOnlyViewer": "", "readOnlyViewer": "Vous avez un accès en lecture seule à cette note. La modification est désactivée.",
"saveForbidden": "Vous n'êtes pas autorisé à modifier cette note.", "saveForbidden": "Vous n'êtes pas autorisé à modifier cette note.",
"saveFailed": "Impossible d'enregistrer cette note. Veuillez réessayer." "saveFailed": "Impossible d'enregistrer cette note. Veuillez réessayer."
}, },
@@ -2262,13 +2308,13 @@
"description": "À quoi devrait ressembler la date" "description": "À quoi devrait ressembler la date"
} }
}, },
"currentWindSpeed": "{currentWindSpeed} km/h", "currentWindSpeed": "{currentWindSpeed} {unit}",
"dailyForecast": { "dailyForecast": {
"sunrise": "Lever du Soleil", "sunrise": "Lever du Soleil",
"sunset": "Coucher du soleil", "sunset": "Coucher du soleil",
"maxWindSpeed": "Vitesse maximale du vent : {maxWindSpeed} km/h", "maxWindSpeed": "Vitesse maximale du vent : {maxWindSpeed} {unit}",
"maxWindGusts": "Rafales de vent maximales : {maxWindGusts} km/h", "maxWindGusts": "Rafales de vent maximales : {maxWindGusts} {unit}",
"humidity": "" "humidity": "Humidité : {humidity}%"
}, },
"kind": { "kind": {
"clear": "Clair", "clear": "Clair",
@@ -2328,9 +2374,9 @@
"label": "Afficher les infos sur le système de fichiers" "label": "Afficher les infos sur le système de fichiers"
}, },
"visibleStorageVolumes": { "visibleStorageVolumes": {
"label": "", "label": "Volumes de stockage visibles",
"description": "", "description": "Uniquement disponible lorsque toutes les intégrations sélectionnées sont Synology. Laissez vide pour afficher tous les volumes de stockage détectés.",
"placeholder": "" "placeholder": "Sélectionnez les volumes de stockage"
}, },
"defaultTab": { "defaultTab": {
"label": "Onglet par défaut" "label": "Onglet par défaut"
@@ -2352,7 +2398,7 @@
"loadAverage": "Charge moyenne :", "loadAverage": "Charge moyenne :",
"minute": "1 minute", "minute": "1 minute",
"minutes": "{count} minutes", "minutes": "{count} minutes",
"used": "Utilisé ", "used": "Utilisé",
"available": "Disponible", "available": "Disponible",
"lastSeen": "Dernière mise à jour de statut : {lastSeen}" "lastSeen": "Dernière mise à jour de statut : {lastSeen}"
}, },
@@ -2389,7 +2435,7 @@
"cpu": "Coeurs", "cpu": "Coeurs",
"memory": "Mémoire", "memory": "Mémoire",
"storage": "Stockage", "storage": "Stockage",
"uptime": "Disponibilité", "uptime": "Disponible",
"haState": "État HA", "haState": "État HA",
"storageType": { "storageType": {
"local": "Stockage local", "local": "Stockage local",
@@ -2412,7 +2458,7 @@
"description": "Statistiques de vos conteneurs (Ce widget ne peut être ajouté qu'avec les privilèges d'administrateur)", "description": "Statistiques de vos conteneurs (Ce widget ne peut être ajouté qu'avec les privilèges d'administrateur)",
"option": { "option": {
"columns": { "columns": {
"label": "" "label": "Colonnes à afficher"
}, },
"enableRowSorting": { "enableRowSorting": {
"label": "Activer le tri des éléments" "label": "Activer le tri des éléments"
@@ -2430,19 +2476,19 @@
"label": "Inverser le tri" "label": "Inverser le tri"
}, },
"columnOrder": { "columnOrder": {
"label": "" "label": "Ordre des colonnes"
}, },
"columnWidths": { "columnWidths": {
"label": "" "label": "Largeurs des colonnes"
} }
}, },
"error": { "error": {
"internalServerError": "Impossible de récupérer les statistiques des conteneurs", "internalServerError": "Impossible de récupérer les statistiques des conteneurs",
"layoutSaveTitle": "", "layoutSaveTitle": "Disposition du tableau non sauvegardée",
"layoutSaveMessage": "" "layoutSaveMessage": ""
}, },
"empty": { "empty": {
"noContainers": "" "noContainers": "Aucun conteneur trouvé"
} }
}, },
"coolify": { "coolify": {
@@ -2479,9 +2525,9 @@
} }
}, },
"vpn": { "vpn": {
"name": "", "name": "VPN",
"description": "", "description": "Aperçu de vos connexions VPN",
"serviceUnavailable": "", "serviceUnavailable": "VPN indisponible",
"option": {} "option": {}
}, },
"ups": { "ups": {
@@ -2738,31 +2784,31 @@
"description": "Désactiver cette option ne fonctionnera pas pour Plex" "description": "Désactiver cette option ne fonctionnera pas pour Plex"
}, },
"showBitrate": { "showBitrate": {
"label": "", "label": "Afficher le débit",
"description": "" "description": ""
}, },
"showLocation": { "showLocation": {
"label": "", "label": "Afficher la localisation",
"description": "" "description": ""
} }
}, },
"footer": { "footer": {
"streams": "", "streams": "",
"totalBitrate": "" "totalBitrate": "Total : {bitrate}"
}, },
"items": { "items": {
"currentlyPlaying": "En cours de lecture", "currentlyPlaying": "En cours de lecture",
"user": "Utilisateur", "user": "Utilisateur",
"name": "Nom", "name": "Nom",
"id": "Id", "id": "Id",
"status": "", "status": "Statut",
"directPlay": "", "directPlay": "Lecture directe",
"directStream": "", "directStream": "Flux direct",
"transcodeVideo": "", "transcodeVideo": "Transcodage vidéo",
"transcodeAudio": "", "transcodeAudio": "Transcodage audio",
"transcoding": "", "transcoding": "Transcodage",
"paused": "", "paused": "En pause",
"remaining": "", "remaining": "{minutes} min restantes",
"metadata": { "metadata": {
"title": "Statistiques détaillées", "title": "Statistiques détaillées",
"video": { "video": {
@@ -2788,111 +2834,111 @@
"description": "Aperçu de votre archive de documents Paperless-ngx", "description": "Aperçu de votre archive de documents Paperless-ngx",
"documentsTotal": "Nombre total de documents", "documentsTotal": "Nombre total de documents",
"documentsInbox": "Boîte de réception", "documentsInbox": "Boîte de réception",
"correspondents": "", "correspondents": "Correspondants",
"tags": "", "tags": "Tags",
"documentTypes": "", "documentTypes": "Types de documents",
"inboxRatio": "", "inboxRatio": "Ratio de la boîte de réception",
"inbox": "", "inbox": "boîte de réception",
"total": "", "total": "total",
"option": { "option": {
"showInboxRatio": { "showInboxRatio": {
"label": "Afficher le ratio de la boîte de réception", "label": "Afficher le ratio de la boîte de réception",
"description": "" "description": "Afficher le rapport inbox/total des documents dans la section Héros"
}, },
"showInboxRing": { "showInboxRing": {
"label": "", "label": "Afficher l'anneau de la boîte de réception",
"description": "" "description": "Afficher un anneau de progression à côté du ratio de la boîte de réception"
}, },
"showDocumentsTotal": { "showDocumentsTotal": {
"label": "", "label": "Afficher le total des documents",
"description": "" "description": "Afficher le nombre total de documents dans votre archive"
}, },
"showDocumentsInbox": { "showDocumentsInbox": {
"label": "", "label": "Afficher le compteur de la boîte de réception",
"description": "" "description": "Afficher le nombre de documents non traités dans la boîte de réception"
}, },
"showCorrespondents": { "showCorrespondents": {
"label": "", "label": "Afficher les correspondants",
"description": "" "description": "Afficher le nombre total de correspondants"
}, },
"showTags": { "showTags": {
"label": "", "label": "Afficher les tags",
"description": "" "description": "Afficher le nombre total de tags"
}, },
"showDocumentTypes": { "showDocumentTypes": {
"label": "", "label": "Afficher les types de documents",
"description": "" "description": "Afficher le nombre total de types de documents"
} }
}, },
"error": { "error": {
"internalServerError": "" "internalServerError": "Échec de la récupération des données Paperless-ngx"
} }
}, },
"patchmon": { "patchmon": {
"name": "", "name": "PatchMon",
"description": "", "description": "Aperçu de l'état des correctifs des hôtes depuis PatchMon",
"title": "", "title": "Statistiques PatchMon",
"totalHosts": "", "totalHosts": "Total des hôtes",
"hostsNeedingUpdates": "", "hostsNeedingUpdates": "Mises à jour requises",
"securityUpdates": "", "securityUpdates": "Mises à jour de sécurité",
"upToDateHosts": "", "upToDateHosts": "Hôtes à jour",
"hostsWithSecurityUpdates": "", "hostsWithSecurityUpdates": "Hôtes avec mises à jour de sécurité",
"recentUpdates24h": "", "recentUpdates24h": "Mises à jour (24h)",
"totalOutdatedPackages": "", "totalOutdatedPackages": "Paquets obsolètes",
"totalRepos": "", "totalRepos": "Dépôts",
"osDistribution": "", "osDistribution": "Répartition des OS",
"compliance": "", "compliance": "Conformité des correctifs",
"complianceRatio": "", "complianceRatio": "{upToDate} / {total}",
"lastUpdated": "", "lastUpdated": "Mis à jour {time}",
"option": { "option": {
"showTotalHosts": { "showTotalHosts": {
"label": "", "label": "Afficher le nombre total d'hôtes",
"description": "" "description": "Afficher le nombre total d'hôtes surveillés"
}, },
"showHostsNeedingUpdates": { "showHostsNeedingUpdates": {
"label": "", "label": "Afficher les hôtes ayant besoin de mises à jour",
"description": "" "description": "Afficher les hôtes avec au moins un paquet obsolète"
}, },
"showSecurityUpdates": { "showSecurityUpdates": {
"label": "", "label": "Afficher les mises à jour de sécurité",
"description": "" "description": "Afficher le nombre total de mises à jour de sécurité disponibles"
}, },
"showUpToDateHosts": { "showUpToDateHosts": {
"label": "", "label": "Afficher les hôtes à jour",
"description": "" "description": "Afficher les hôtes avec aucun paquet obsolète"
}, },
"showHostsWithSecurityUpdates": { "showHostsWithSecurityUpdates": {
"label": "", "label": "Afficher les hôtes avec les mises à jour de sécurité",
"description": "" "description": "Afficher les hôtes nécessitant des correctifs de sécurité"
}, },
"showRecentUpdates24h": { "showRecentUpdates24h": {
"label": "", "label": "Afficher les mises à jour récentes (24h)",
"description": "" "description": "Afficher le nombre de mises à jour appliquées au cours des dernières 24 heures"
}, },
"showTotalOutdatedPackages": { "showTotalOutdatedPackages": {
"label": "", "label": "Afficher les paquets obsolètes",
"description": "" "description": ""
}, },
"showTotalRepos": { "showTotalRepos": {
"label": "", "label": "Afficher les dépôts",
"description": "" "description": "Afficher le nombre de dépôts actifs en cours de surveillance"
}, },
"enableThresholdColors": { "enableThresholdColors": {
"label": "", "label": "Activer les couleurs de seuil",
"description": "" "description": ""
}, },
"useCustomThresholds": { "useCustomThresholds": {
"label": "", "label": "Seuils avancés",
"description": "" "description": ""
}, },
"thresholdMode": { "thresholdMode": {
"option": { "option": {
"absolute": "", "absolute": "",
"percent": "" "percent": "Pourcentage du total des hôtes"
} }
}, },
"hostsNeedingUpdatesThresholdMode": { "hostsNeedingUpdatesThresholdMode": {
"label": "", "label": "Nécessite le mode de mise à jour de seuil",
"description": "" "description": ""
}, },
"hostsNeedingUpdatesWarningAt": { "hostsNeedingUpdatesWarningAt": {
@@ -2912,7 +2958,7 @@
"description": "" "description": ""
}, },
"securityUpdatesCriticalAt": { "securityUpdatesCriticalAt": {
"label": "", "label": "Seuil critique de mise à jour de sécurité",
"description": "" "description": ""
}, },
"hostsWithSecurityUpdatesThresholdMode": { "hostsWithSecurityUpdatesThresholdMode": {
@@ -2952,33 +2998,33 @@
"description": "" "description": ""
}, },
"showOsDistribution": { "showOsDistribution": {
"label": "", "label": "Afficher la répartition des OS",
"description": "" "description": ""
}, },
"osDistributionLimit": { "osDistributionLimit": {
"label": "", "label": "",
"description": "", "description": "",
"option": { "option": {
"3": "", "3": "Top 3",
"5": "", "5": "Top 5",
"10": "", "10": "Top 10",
"all": "" "all": "Tout"
} }
}, },
"showOsVersion": { "showOsVersion": {
"label": "", "label": "Afficher la version de l'OS",
"description": "" "description": "Ajouter la version de l'OS à chaque étiquette de distribution lorsque disponible"
}, },
"showComplianceHero": { "showComplianceHero": {
"label": "", "label": "Afficher la jauge de conformité",
"description": "" "description": ""
}, },
"osDisplayMode": { "osDisplayMode": {
"label": "", "label": "Mode d'affichage de l'OS",
"description": "", "description": "Choisir comment afficher la répartition des systèmes d'exploitation",
"option": { "option": {
"bars": "", "bars": "Barres horizontales",
"donut": "" "donut": "Graphique en anneau"
} }
} }
}, },
@@ -2987,19 +3033,19 @@
} }
}, },
"bazarr": { "bazarr": {
"name": "", "name": "Bazarr",
"description": "", "description": "",
"episodes": "", "episodes": "Épisodes manquants",
"movies": "", "movies": "Films manquants",
"providers": "", "providers": "Problèmes de fournisseurs",
"status": "", "status": "",
"option": { "option": {
"showMissingEpisodes": { "showMissingEpisodes": {
"label": "", "label": "Afficher les épisodes manquants",
"description": "" "description": "Afficher le nombre d'épisodes avec les sous-titres manquants"
}, },
"showMissingMovies": { "showMissingMovies": {
"label": "", "label": "Afficher les films manquants",
"description": "" "description": ""
}, },
"showProviderIssues": { "showProviderIssues": {
@@ -3047,7 +3093,7 @@
"option": { "option": {
"albumId": { "albumId": {
"label": "Identifiant de l'album", "label": "Identifiant de l'album",
"description": "C'est l'identifiant unique qui correspond à l'album Immich. Vous pouvez le trouver en ouvrant un album et copiant l'identifiant unique depuis l'URL, par exemple 'a21aca09-9f05-47dc-b1a1-7b53c547226e'" "description": "C'est l'identifiant unique qui correspond à l'album Immich. Vous pouvez le trouver en ouvrant un album et copiant l'identifiant unique depuis l'URL, par exemple 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
}, },
"rotationIntervalSeconds": { "rotationIntervalSeconds": {
"label": "Intervalle en secondes", "label": "Intervalle en secondes",
@@ -3107,18 +3153,18 @@
"description": "Cela limitera le nombre d'éléments affichés par intégration, pas globalement" "description": "Cela limitera le nombre d'éléments affichés par intégration, pas globalement"
}, },
"columnOrder": { "columnOrder": {
"label": "" "label": "Ordre des colonnes"
}, },
"columnWidths": { "columnWidths": {
"label": "" "label": "Largeur des colonnes"
} }
}, },
"errors": { "errors": {
"noColumns": "Sélectionner les colonnes dans les éléments", "noColumns": "Sélectionner les colonnes dans les éléments",
"noCommunications": "Impossible de charger les données à partir de l'intégration", "noCommunications": "Impossible de charger les données à partir de l'intégration",
"noItems": "", "noItems": "Aucun téléchargement trouvé",
"actionFailed": "", "actionFailed": "Action échouée",
"actionFailedMessage": "" "actionFailedMessage": "Cette action n'a pas pu être complétée. Veuillez ressayer."
}, },
"items": { "items": {
"actions": { "actions": {
@@ -3312,7 +3358,7 @@
"option": {}, "option": {},
"titles": { "titles": {
"stats": { "stats": {
"main": "Statistiques des médias", "main": "Stats des médias",
"approved": "Déjà approuvé", "approved": "Déjà approuvé",
"pending": "En attente de validation", "pending": "En attente de validation",
"processing": "En cours de traitement", "processing": "En cours de traitement",
@@ -3382,7 +3428,7 @@
"file": "Fichier", "file": "Fichier",
"size": "Taille", "size": "Taille",
"transcode": "Transcodeur", "transcode": "Transcodeur",
"healthCheck": "Bilan de santé" "healthCheck": "Contrôle de santé"
} }
} }
} }
@@ -3395,26 +3441,26 @@
"label": "" "label": ""
}, },
"showQueued": { "showQueued": {
"label": "" "label": "Afficher les éléments en attente"
}, },
"pageSize": { "pageSize": {
"label": "" "label": "Éléments par page"
} }
}, },
"tab": { "tab": {
"missing": "", "missing": "",
"queued": "" "queued": "En file d’attente"
}, },
"type": { "type": {
"movie": "", "movie": "Film",
"episode": "" "episode": "Épisode"
}, },
"empty": { "empty": {
"missing": "", "missing": "Aucun élément manquant",
"queued": "", "queued": "Aucun élément en file d'attente",
"noTabsEnabled": "" "noTabsEnabled": "Activer au moins un onglet dans les paramètres du widget"
}, },
"timeLeft": "" "timeLeft": "Temps restant"
}, },
"rssFeed": { "rssFeed": {
"name": "Flux RSS", "name": "Flux RSS",
@@ -3548,9 +3594,9 @@
"not-found": "Non trouvé", "not-found": "Non trouvé",
"pre-release": "Pré-publication", "pre-release": "Pré-publication",
"archived": "Archivé", "archived": "Archivé",
"forked": "", "forked": "Forké",
"starsCount": "Étoiles", "starsCount": "Étoiles",
"forksCount": "", "forksCount": "Forks",
"issuesCount": "Problèmes ouverts", "issuesCount": "Problèmes ouverts",
"markViewed": "Marquer comme vu(s)", "markViewed": "Marquer comme vu(s)",
"openProjectPage": "Ouvrir la page du projet", "openProjectPage": "Ouvrir la page du projet",
@@ -3661,7 +3707,7 @@
"noItems": "Aucune notification à afficher.", "noItems": "Aucune notification à afficher.",
"option": { "option": {
"hideLogos": { "hideLogos": {
"label": "" "label": "Masquer les logos"
} }
} }
}, },
@@ -3705,7 +3751,7 @@
} }
}, },
"uptimeKuma": { "uptimeKuma": {
"name": "", "name": "Uptime Kuma",
"description": "", "description": "",
"option": { "option": {
"showAverageUptime": { "showAverageUptime": {
@@ -3766,7 +3812,7 @@
}, },
"resource": { "resource": {
"routers": "", "routers": "",
"services": "", "services": "Services",
"middlewares": "" "middlewares": ""
}, },
"status": { "status": {
@@ -3984,15 +4030,15 @@
"system": "", "system": "",
"cpu": "", "cpu": "",
"memory": "", "memory": "",
"disk": "", "disk": "Disque",
"gpu": "", "gpu": "",
"loadAvg": "", "loadAvg": "",
"net": "", "net": "",
"temp": "", "temp": "Temp",
"battery": "", "battery": "",
"services": "", "services": "",
"uptime": "", "uptime": "",
"agent": "" "agent": "Agent"
} }
}, },
"beszelSystemGrid": { "beszelSystemGrid": {
@@ -4044,7 +4090,7 @@
}, },
"metric": { "metric": {
"cpu": "", "cpu": "",
"memory": "", "memory": "Mémoire",
"disk": "", "disk": "",
"gpu": "", "gpu": "",
"loadAvg": "", "loadAvg": "",
@@ -4071,8 +4117,8 @@
"error": { "error": {
"internalServerError": "" "internalServerError": ""
}, },
"history": "", "history": "Historique",
"resolved": "", "resolved": "Résolu",
"status": { "status": {
"triggered": "", "triggered": "",
"ok": "" "ok": ""
@@ -4127,12 +4173,12 @@
"chart": { "chart": {
"cpu": { "cpu": {
"title": "", "title": "",
"subtitle": "", "subtitle": "Utilisation moyenne du CPU à l'échelle du système",
"series": "" "series": "CPU"
}, },
"memory": { "memory": {
"title": "", "title": "Utilisation Mémoire",
"subtitle": "", "subtitle": "Utilisation précise au moment enregistré",
"series": "", "series": "",
"cache": "" "cache": ""
}, },
@@ -4143,7 +4189,7 @@
}, },
"diskIO": { "diskIO": {
"title": "", "title": "",
"subtitle": "", "subtitle": "Débit du système de fichiers root",
"read": "", "read": "",
"write": "" "write": ""
}, },
@@ -5434,7 +5480,7 @@
"title": "Capacité", "title": "Capacité",
"resource": { "resource": {
"reserved": "Réservé", "reserved": "Réservé",
"used": "Utilisé " "used": "Utilisé"
} }
}, },
"resources": { "resources": {
@@ -5720,7 +5766,7 @@
"label": "Médias" "label": "Médias"
}, },
"apps": { "apps": {
"label": "Applications", "label": "Apps",
"new": { "new": {
"label": "Nouveau" "label": "Nouveau"
}, },
@@ -5815,7 +5861,7 @@
"placeholder": "Rechercher quelque chose", "placeholder": "Rechercher quelque chose",
"nothingFound": "Aucun résultat", "nothingFound": "Aucun résultat",
"error": { "error": {
"fetch": "Une erreur s'est produite lors de la récupération des données." "fetch": "Une erreur s'est produite lors de la récupération des données"
}, },
"mode": { "mode": {
"appIntegrationBoard": { "appIntegrationBoard": {
@@ -6498,7 +6544,7 @@
}, },
"editMode": { "editMode": {
"title": "Mode édition", "title": "Mode édition",
"description": "Cliquez ici pour entrer en mode édition. Vous pouvez ensuite ajouter des widgets, des applications et des sections — faites glisser pour repositionner, redimensionnez avec la poignée d'angle. Cliquez à nouveau pour enregistrer vos modifications." "description": "Cliquez ici pour entrer en mode édition. Vous pourrez ensuite ajouter des widgets, des applications et des sections — faites glisser pour repositionner, redimensionnez avec la poignée d'angle. Cliquez à nouveau pour enregistrer vos modifications."
}, },
"settings": { "settings": {
"title": "Paramètres du tableau de bord", "title": "Paramètres du tableau de bord",
+49 -3
View File
@@ -1344,6 +1344,10 @@
"name": "", "name": "",
"description": "" "description": ""
}, },
"wud": {
"name": "וואטס אפ דוקר",
"description": "עקוב אחר אילו מקונטיינרי הדוקר שלך כוללים עדכוני תמונה זמינים"
},
"paperlessNgx": { "paperlessNgx": {
"name": "", "name": "",
"description": "" "description": ""
@@ -1437,8 +1441,18 @@
"here": "כאן", "here": "כאן",
"iconPicker": { "iconPicker": {
"label": "קישור של הסמל", "label": "קישור של הסמל",
"header": "הקלד שם או אובייקטים לסינון עבור אייקונים... Homarr יחפש עבורך סמלים של {countIcons} .", "description": "חפש בספרייה, הדבק כתובת URL של תמונה, או העלה קובץ.",
"headerLoading": "", "header": "הקלד שם או אובייקטים לסינון עבור אייקונים... Homarr יחפש עבורך סמלים של {countIcons} ",
"headerLoading": "חפש תמונות לפי שם",
"clear": "נקה תמונה",
"uploadImage": "העלה תמונה",
"results": "תוצאות חיפוש תמונות",
"localImages": "התמונות שלך",
"svgIcons": "סמלי SVG",
"otherImages": "תמונות אחרות",
"urlLoading": "בודק תמונה…",
"urlReady": "התמונה מוכנה לשימוש",
"urlError": "לא ניתן היה לטעון את התמונה. בדוק את כתובת ה-URL ונסה שוב.",
"noResults": "", "noResults": "",
"error": "" "error": ""
}, },
@@ -1793,6 +1807,38 @@
} }
} }
}, },
"wud": {
"name": "וואטס אפ דוקר",
"description": "הצג כמה מהמכולות הנצפות עם עדכונים זמינים.",
"title": "What's Up Docker",
"monitored": "מנוטר",
"updatesAvailable": "{count, plural, one {# עדכון זמין} two {# עדכונים זמינים} many {# עדכונים זמינים} other {# עדכונים זמינים}}",
"updateAvailable": "עדכון זמין",
"openReleaseNotes": "פתח הערות שחרור",
"option": {
"showTitle": {
"label": "הצג כותרת"
},
"showRing": {
"label": "הצג טבעת עדכון"
},
"layout": {
"label": "פריסה",
"option": {
"horizontal": {
"label": "אופקי"
},
"vertical": {
"label": "אנכי"
}
}
},
"showUpdateList": {
"label": "הצג רשימת עדכוני מכולות",
"description": "מפרט כל מכולה מנוטרת שיש עדכון תמונה זמין עבורה"
}
}
},
"dnsHoleSummary": { "dnsHoleSummary": {
"name": "סיכום חור אבטחה DNS", "name": "סיכום חור אבטחה DNS",
"description": "מציג את הסיכום חור אבטחת DNS שלך", "description": "מציג את הסיכום חור אבטחת DNS שלך",
@@ -2595,7 +2641,7 @@
"today": "היום", "today": "היום",
"24h": "24 השעות האחרונות", "24h": "24 השעות האחרונות",
"7d": "7 הימים האחרונים", "7d": "7 הימים האחרונים",
"30d": "", "30d": "30 הימים האחרונים",
"month": "", "month": "",
"lastMonth": "" "lastMonth": ""
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+55 -9
View File
@@ -1344,6 +1344,10 @@
"name": "Traefik", "name": "Traefik",
"description": "Cloud-native reverse proxy e bilanciatore del carico" "description": "Cloud-native reverse proxy e bilanciatore del carico"
}, },
"wud": {
"name": "Cos'è Il Docker",
"description": "Monitora quali dei tuoi contenitori Docker sono disponibili aggiornamenti delle immagini"
},
"paperlessNgx": { "paperlessNgx": {
"name": "Paperless-ngx", "name": "Paperless-ngx",
"description": "Sistema di gestione documentale per archiviare e organizzare documenti scansionati" "description": "Sistema di gestione documentale per archiviare e organizzare documenti scansionati"
@@ -1437,8 +1441,18 @@
"here": "qui", "here": "qui",
"iconPicker": { "iconPicker": {
"label": "Icona URL", "label": "Icona URL",
"header": "Digita il nome o gli oggetti da filtrare per le icone... Homarr cercherà tra le icone {countIcons} per te.", "description": "Cerca nella libreria, incolla un URL di immagine, o carica un file.",
"headerLoading": "Digita per cercare icone…", "header": "Digita il nome o gli oggetti da filtrare per le icone... Homarr cercherà tra le icone {countIcons} per te",
"headerLoading": "Digita per cercare icone",
"clear": "Cancella immagine",
"uploadImage": "Carica immagine",
"results": "Risultati della ricerca immagini",
"localImages": "Le tue immagini",
"svgIcons": "Icone SVG",
"otherImages": "Altre immagini",
"urlLoading": "Controllo immagine…",
"urlReady": "L'immagine è pronta per l'uso",
"urlError": "Questa immagine non può essere caricata. Controlla l'URL e riprova.",
"noResults": "Nessuna icona corrisponde a \"{search}\".", "noResults": "Nessuna icona corrisponde a \"{search}\".",
"error": "Impossibile caricare le icone." "error": "Impossibile caricare le icone."
}, },
@@ -1793,6 +1807,38 @@
} }
} }
}, },
"wud": {
"name": "Cos'è Il Docker",
"description": "Mostra quanti contenitori visti hanno aggiornamenti disponibili.",
"title": "Cos'è Il Docker",
"monitored": "Monitorato",
"updatesAvailable": "{count, plural, one {# aggiornamento disponibile} other {# aggiornamenti disponibili}}",
"updateAvailable": "Aggiornamento disponibile",
"openReleaseNotes": "Apri note di rilascio",
"option": {
"showTitle": {
"label": "Mostra titolo"
},
"showRing": {
"label": "Mostra anello di aggiornamento"
},
"layout": {
"label": "Layout",
"option": {
"horizontal": {
"label": "Orizzontale"
},
"vertical": {
"label": "Verticale"
}
}
},
"showUpdateList": {
"label": "Mostra elenco aggiornamenti container",
"description": "Elenca ogni contenitore monitorato che ha un aggiornamento dell'immagine disponibile"
}
}
},
"dnsHoleSummary": { "dnsHoleSummary": {
"name": "Riepilogo del filtraggio DNS", "name": "Riepilogo del filtraggio DNS",
"description": "Mostra il riepilogo del tuo filtro DNS", "description": "Mostra il riepilogo del tuo filtro DNS",
@@ -2024,7 +2070,7 @@
} }
}, },
"anchorNote": { "anchorNote": {
"name": "Nota Anchor", "name": "Ancora Nota",
"description": "Visualizza una nota selezionata da Ancoraggio", "description": "Visualizza una nota selezionata da Ancoraggio",
"option": { "option": {
"noteId": { "noteId": {
@@ -2262,12 +2308,12 @@
"description": "Come dovrebbe apparire la data" "description": "Come dovrebbe apparire la data"
} }
}, },
"currentWindSpeed": "{currentWindSpeed} km/h", "currentWindSpeed": "{currentWindSpeed} {unit}",
"dailyForecast": { "dailyForecast": {
"sunrise": "Alba", "sunrise": "Alba",
"sunset": "Tramonto", "sunset": "Tramonto",
"maxWindSpeed": "Max velocità vento: {maxWindSpeed} km/h", "maxWindSpeed": "Velocità massima del vento: {maxWindSpeed} {unit}",
"maxWindGusts": "Max raffiche di vento: {maxWindGusts} km/h", "maxWindGusts": "Raffiche di vento massime: {maxWindGusts} {unit}",
"humidity": "Umidità: {humidity}%" "humidity": "Umidità: {humidity}%"
}, },
"kind": { "kind": {
@@ -3047,7 +3093,7 @@
"option": { "option": {
"albumId": { "albumId": {
"label": "ID Album", "label": "ID Album",
"description": "Si tratta di un ID univoco che si riferisce al tuo album Immich. Puoi trovare questo id aprendo un album e copiando la stringa univoca dall'URL, ad esempio 'a21aca09-9f05-47dc-b1a1-7b53c547226e'" "description": "Si tratta di un ID univoco che si riferisce al tuo album Immich. Puoi trovare questo id aprendo un album e copiando la stringa univoca dall'URL, ad esempio 'a21aca09-9f05-47dc-b1a1-7b53c547226e'."
}, },
"rotationIntervalSeconds": { "rotationIntervalSeconds": {
"label": "Intervallo in secondi", "label": "Intervallo in secondi",
@@ -3208,7 +3254,7 @@
}, },
"client": { "client": {
"pause": "Metti in pausa il client", "pause": "Metti in pausa il client",
"resume": "Ripristina il client" "resume": "Riprendi il client"
}, },
"item": { "item": {
"pause": "Metti in pausa l'elemento", "pause": "Metti in pausa l'elemento",
@@ -5352,7 +5398,7 @@
"label": "Arresta", "label": "Arresta",
"notification": { "notification": {
"success": { "success": {
"title": "Containers arrestati", "title": "Containers fermati",
"message": "I containers sono stati arrestati con successo" "message": "I containers sono stati arrestati con successo"
}, },
"error": { "error": {
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff. Show more