Compare commits

...
Author SHA1 Message Date
Thomas 062fda642c Merge branch 'release/v2' into feat/widget-integration-dx 2026-08-30 18:31:24 +02:00
Bergasha 7d1d9062ed fix(calendar): keep date numbers aligned regardless of indicator dots (#6742) 2026-08-29 09:14:53 +02:00
Thomas 8d0b13b309 fix(board): correct widget scaling and edit layering (#6741) 2026-08-29 09:12:16 +02:00
Bergasha ea250d418d Revert "feat(header): add opt-in auto-hide-on-scroll for the header (#6738)" (#6740)
This reverts commit e58c229664.
2026-08-29 15:19:20 +10:00
Bergasha e58c229664 feat(header): add opt-in auto-hide-on-scroll for the header (#6738)
* feat(header): add opt-in auto-hide-on-scroll for the header

New per-user header preference (default off): when enabled, the
header slides away while scrolling down and reappears on scroll-up or
near the top of the page. Disabled while in board edit mode, and
skipped entirely while focus is inside the header. Only applies when
the header is set to visible - the existing hover-to-reveal floating
controls already cover the hidden-header case.

* chore(api): regenerate the checked-in OpenAPI schema

check:openapi was failing in CI - the schema on disk hadn't been
regenerated after the header preferences gained autoHideOnScroll.

Add scripts/generate-openapi-schema.mts (and a generate:openapi
package script) alongside the existing check script so this is a
one-command fix next time the schema drifts.

* docs(users): document the header studio's visibility and auto-hide options

Header customization (drag-to-arrange controls, hide/show, hover-reveal
and pin, auto-hide-on-scroll) had no documentation on the user general
settings page.
2026-08-29 14:56:02 +10:00
Thomas ee08beef59 fix(widgets): use readable onboarding sizes (#6739) 2026-08-29 06:55:31 +02:00
Bergasha 10ba298233 fix(board): beszel & calendar scale fix (#6737)
* fix(calendar): add cell spacing and scale day content with widget size

* fix(beszel): stop chart panels from leaving blank grid columns and size charts to fit the widget

* fix(widgets): address CodeRabbit review on calendar/beszel scaling PR

- beszel: account for the SimpleGrid row gap and the Stack gap between
  the controls row and the grid in the available-height budget, so a
  two-row layout no longer overflows the widget.
- calendar: memoize the day-cell scale lookup instead of recomputing
  it on every render.
- calendar: don't render the notification indicator row when none of
  a day's events have an indicatorColor, which was shifting the date
  number up with no visible dot to show for it.

Skipped one finding: the claim that .day's absolute inset:0 covers
monthCell's padding. It doesn't - the containing block for an
absolutely positioned element is the padding edge of its positioned
ancestor per the CSS spec, so the padding correctly insets .day. This
was also visually verified in the browser while building the fix.

* fix(beszel): don't treat availableHeight=0 as omitted in computeChartHeight

A falsy check on availableHeight also caught the legitimate value 0
(widget shorter than its own padding+controls), falling back to the
full 180px height instead of clamping to MIN_CHART_HEIGHT - the exact
overflow this height budget was meant to prevent.
2026-08-29 11:02:10 +10:00
Bergasha 7855278ed1 fix(board): give AppShell root a viewport-height floor (#6736)
* fix(board): give AppShell root a viewport-height floor when a background is set

The board canvas sizes itself to its content, and AppShell runs in
'static' mode so <main> doesn't force a 100dvh minimum either. That
means a board's background image only covers as much height as the
content needs - collapsing a container (or any board with short
content) leaves flat page background showing below it instead of the
background continuing to the bottom of the viewport, looking like a
big empty void.

Root-caused via a local repro: the grid/reflow system was already
shrinking the collapsed container's own space correctly (verified the
live-measured canvas height matched exactly, not the stale debug
attribute) - the void was purely a background-sizing issue on the
AppShell root, unrelated to the collapse/layout logic itself.

* docs(boards): document background minimum-height behavior
2026-08-29 09:26:15 +10:00
Thomas aa179f1ec0 fix: decouple responsive layout roles from breakpoints
* fix: rework responsive layout breakpoints

* fix: keep widget edit overlays visible

* fix: inset widget edit action

* fix: use valid widget inset token
2026-08-28 17:11:51 +02:00
Thomas b53042f055 Merge branch 'release/v2' into feat/widget-integration-dx 2026-08-28 14:08:34 +02:00
Bergasha f455e71be5 fix(board): remove per-widget refresh indicator (#6731)
The small refresh-circle overlay that appeared in a widget's top-right
corner during a background refetch wasn't adding useful signal and
just added visual noise. Drop the isWidgetRefreshing detection, the
indicator markup/CSS, and the now-dead query-key/runtime-query
plumbing that only existed to power it.

Updated the lazy-widgets e2e test to drop its assertions on the now
-removed [data-widget-refreshing] marker; the behavior it was really
verifying (cached content stays visible during a background refetch,
which completes cleanly) is still covered by the existing network
-level checks in the same test.
2026-08-28 17:36:41 +10:00
Thomas f221a5b4d3 fix: respect mobile header and widget interactions (#6730) 2026-08-28 08:31:41 +02:00
Bergasha c18fa8cf77 fix(widgets): replace system-resources chart tooltip with Mantine (#6729)
* fix(widgets): replace system-resources chart tooltip with Mantine Tooltip.Floating
2026-08-28 15:08:22 +10:00
Bergasha 5f572f1b92 fix(board): reserve and scale space for collapsible container header (#6727) 2026-08-28 10:59:48 +10:00
Bergasha a7aa89fafe feat(board+clock): fix item radius zoom, add clock day/night weather colour (#6726) 2026-08-28 10:22:48 +10:00
Thomas e723cbe6dd Merge pull request #6725 from homarr-labs/codex/fix-demo-read-only
fix(cli): make demo mode writable
2026-08-27 22:45:49 +02:00
ajnart bf0eed7607 fix: preserve typed media translations 2026-08-27 14:50:06 +02:00
ajnart 8e29f7aa18 merge: sync release/v2 2026-08-27 14:47:01 +02:00
ajnart 5ac4a4cffc fix: address review feedback 2026-08-26 17:36:02 +02:00
ajnart ac7e3f2ef4 Merge remote-tracking branch 'origin/release/v2' into feat/pr-6691-hardening
# Conflicts:
#	apps/nextjs/src/app/[locale]/layout.tsx
#	packages/api/src/router/widgets/anchor-notes.ts
#	packages/api/src/router/widgets/archive-team-warrior.ts
#	packages/api/src/router/widgets/audio-stats.ts
#	packages/api/src/router/widgets/bazarr.ts
#	packages/api/src/router/widgets/coolify.ts
#	packages/api/src/router/widgets/firewall.ts
#	packages/api/src/router/widgets/immich.ts
#	packages/api/src/router/widgets/paperless-ngx.ts
#	packages/api/src/router/widgets/patchmon.ts
#	packages/api/src/router/widgets/speedtest-tracker.ts
#	packages/api/src/router/widgets/tracearr.ts
#	packages/api/src/router/widgets/traefik.ts
#	packages/api/src/router/widgets/umami.ts
#	packages/api/src/router/widgets/uptime-kuma.ts
#	packages/api/src/router/widgets/vpn.ts
#	packages/api/src/router/widgets/wud.ts
#	packages/definitions/src/widget-integration-map.ts
#	packages/widgets/src/anchor-note/index.ts
#	packages/widgets/src/archive-team-warrior/index.ts
#	packages/widgets/src/audio-stats/index.ts
#	packages/widgets/src/bazarr/index.ts
#	packages/widgets/src/coolify/index.ts
#	packages/widgets/src/firewall/index.ts
#	packages/widgets/src/immich/album-carousel/index.ts
#	packages/widgets/src/immich/server-stats/index.ts
#	packages/widgets/src/paperless-ngx/index.ts
#	packages/widgets/src/patchmon/index.ts
#	packages/widgets/src/speedtest-tracker/index.ts
#	packages/widgets/src/system-disks/index.ts
#	packages/widgets/src/system-resources/index.ts
#	packages/widgets/src/tracearr/index.ts
#	packages/widgets/src/traefik/index.ts
#	packages/widgets/src/umami/index.ts
#	packages/widgets/src/uptime-kuma/index.ts
#	packages/widgets/src/vpn/index.ts
#	packages/widgets/src/wud/index.ts
2026-08-26 15:12:49 +02:00
ajnart 47724f44eb fix: harden widget request caching 2026-08-26 14:57:53 +02:00
ajnart c81088d876 Merge remote-tracking branch 'origin/release/v2' into feat/widget-integration-dx
# Conflicts:
#	packages/api/src/router/board.ts
2026-08-25 10:09:03 +02:00
ajnart 9bf266935b refactor: address adversarial widget architecture review 2026-08-25 10:07:32 +02:00
Thomas Camlong 0ca62f1232 fix(request-handler): prevent shared-cache stampede while a refresh lock is held
Waiters that lost the refresh lock polled the shared cache for only 750ms
before falling through to the upstream, but the refresh lock lives for 15s.
On a cold cache with an upstream slower than 750ms, every waiting pod gave up
and stampeded the origin (each also writing L2). Extend the poll window to
match the lock TTL so waiters return the entry the holder publishes instead.

Adds a regression test that fails without the fix.
2026-08-24 15:26:30 +02:00
ajnart 4ca17d8e8b merge: sync release/v2 2026-08-24 09:53:48 +02:00
ajnart 24e6e4a47d refactor: tighten TypeScript boundaries 2026-08-24 09:38:30 +02:00
ajnart ad0120fe01 fix: guard service example timestamp 2026-08-24 03:15:23 +02:00
ajnart 17c47ac9d1 fix: harden custom widget AI authoring 2026-08-24 02:59:41 +02:00
ajnart 97e0adbb3a fix: separate timetable address families 2026-08-24 01:52:55 +02:00
ajnart addf1c7335 test: update Beszel Redis mocks 2026-08-24 01:36:29 +02:00
ajnart 8f9efd0074 refactor: simplify widget and integration architecture 2026-08-24 00:57:36 +02:00
ajnart c988c35b29 refactor: finish feature module tooling 2026-08-23 22:39:06 +02:00
ajnart 425cb653a1 refactor: streamline widget and integration architecture 2026-08-23 22:33:29 +02:00
257 changed files with 4695 additions and 2525 deletions

No files matched your search

+10 -6
View File
@@ -40,7 +40,7 @@ homarr/
│ ├── db/ # Drizzle schema (3 DB drivers), migrations, queries │ ├── db/ # Drizzle schema (3 DB drivers), migrations, queries
│ ├── core/ # Env validation, DB/Redis driver factories, logging │ ├── core/ # Env validation, DB/Redis driver factories, logging
│ ├── definitions/ # Domain enums: WidgetKind, IntegrationKind, permissions │ ├── definitions/ # Domain enums: WidgetKind, IntegrationKind, permissions
│ ├── widgets/ # All 39 dashboard widgets (definitions + components) │ ├── widgets/ # Dashboard widget definitions, components, and lazy registry
│ ├── integrations/ # Integration classes (HTTP clients to external apps) │ ├── integrations/ # Integration classes (HTTP clients to external apps)
│ ├── redis/ # Redis pub/sub channels, caching abstractions │ ├── redis/ # Redis pub/sub channels, caching abstractions
│ ├── translation/ # next-intl setup, locale configs, lang JSON files │ ├── translation/ # next-intl setup, locale configs, lang JSON files
@@ -118,11 +118,15 @@ 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: Widget kinds live in `packages/definitions/src/widget.ts`. Integration selection and authorization metadata lives in
- Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })` `packages/definitions/src/widget-integration-map.ts`. Each widget module exports a typed `definition` and
- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options `componentLoader` created with `createWidgetDefinition(...).withDynamicImport(...)`.
- Loads dynamically via `next/dynamic`
- Fetches data via tRPC client hooks (backed by cron job caches) Register widgets in all three explicit maps in `packages/widgets/src/registry.ts`: the type-only module map, literal
module loaders, and literal component loaders. The two runtime loader maps are deliberately separate so
`packages/widgets/src/manifest.ts` can request a widget's definition chunk and component chunk in parallel while
keeping every lazy import statically discoverable by Next.js. The manifest owns promise caching and clears rejected
entries so later loads can retry. Do not restore an eager root `widgetImports` map.
### Integration System ### Integration System
@@ -0,0 +1,102 @@
---
title: Widgets and integrations
tags:
- Developer
- Technical documentation
- Widgets
- Integrations
sidebar_position: 2
---
Homarr keeps feature registration explicit and typed. Feature registration has no generator or generated registry.
A new feature is available when its typed metadata and lazy loader are registered.
## Add a widget
Keep the UI, options, helpers, and tests together in `packages/widgets/src/<widget>/`. The usual folder only needs an
`index.ts` definition and a lazily imported `component.tsx`; complex widgets can add local files as needed.
1. Add the stable kind to `packages/definitions/src/widget.ts` and its documentation slug to the direct typed map in
`packages/definitions/src/docs/widget-doc-slugs.ts`.
2. Export `definition` and `componentLoader` from `packages/widgets/src/<widget>/index.ts` with
`createWidgetDefinition`. The local definition owns its icon, query keys, refresh interval, options, matchers, and
errors. Keep the component behind `withDynamicImport` so adding a widget does not load it on every dashboard.
3. For an integration-backed widget, add its integration kinds and selection rules once to
`packages/definitions/src/widget-integration-map.ts`, then spread `getWidgetIntegrationConfig(kind)` into the local
definition. This server-safe config is also the API authorization boundary, avoiding a package cycle without
duplicating widget metadata.
4. Register the widget in both explicit literal loader maps and the type-only module map in
`packages/widgets/src/registry.ts`, then add its icon to `packages/ui/src/widget-icons.ts`. The module and component
loaders must both use literal import paths. Keeping them separate lets a cold widget load request its definition
and component chunks in parallel; the type-only map preserves exact option and component inference. Keep all three
maps in the same order so drift is obvious in review.
5. If the widget needs server data, add its tRPC router under `packages/api/src/router/widgets/` and add its explicit
lazy import to `packages/api/src/router/widgets/index.ts`. Keep upstream requests and Redis response caching in
`@homarr/request-handler`. Use the widget integration middleware for integration-backed procedures.
6. Add canonical documentation in `apps/docs/docs/widgets/<slug>/`.
Weather is the small integration-free example. Downloads shows integration selection, polling, server caching,
mutations, partial upstream failures, and a larger options surface.
## Add an integration
Keep the HTTP client and its capability-specific types in `packages/integrations/src/<integration>/`.
1. Add the typed definition to `packages/definitions/src/integration.ts`, including credential alternatives,
categories, documentation slug, ports, Docker aliases, and onboarding metadata when relevant.
2. Add an explicit lazy creator to `packages/integrations/src/base/creator.ts`. The literal import keeps the
implementation out of the initial server bundle and makes the registration easy to find.
3. Implement only the interfaces required by the widgets that use the service.
4. Add canonical documentation in `apps/docs/docs/integrations/<slug>/`. Its `index.ts` must satisfy
`IntegrationDefinition` so invalid metadata fails type checking without widening the object. Use a type-only import
and `satisfies IntegrationDefinition`; do not cast the object.
Beszel is a compact example with several widgets sharing one integration client.
## Write user documentation
Write for technically capable self-hosters. Keep each page short and operational.
- Start with one sentence describing what the integration or widget adds to Homarr.
- Include prerequisites, credentials, permissions, destructive actions, and non-obvious limits only when they matter.
- Use numbered steps only when order matters. Prefer the exact UI labels a user must select.
- Do not add marketing, implementation history, architecture internals, or explanations of basic self-hosting concepts.
- End when the user can configure and verify the feature; do not repeat the same information in a summary.
The usual page needs only an introduction and a `Configuration` section. Add `Notes` or `Troubleshooting` only for
real caveats.
## Data and errors
- TanStack Query owns browser caching and refresh behavior. Redis owns shared server response caching for bounded key
spaces. Public handlers whose keys come from arbitrary URLs, domains, symbols, or coordinates stay in the bounded
process-local cache.
- Cache raw service responses, never authorization decisions or decrypted credentials. Integration-backed cache keys
include keyed credential fingerprints and the complete integration identity, and are invalidated after mutations.
The fingerprints change with credentials without exposing their values in Redis keys.
- Give upstream requests a finite deadline and pass the request handler's `AbortSignal` into clients that support it.
Admission is bounded per handler. Request deduplication and distributed lock ownership remain active until the
upstream settles or a bounded cleanup grace expires, while unresolved upstream work stays separately capped.
- Keep validation and authorization on the server. Client-side checks improve UX but do not replace tRPC guards.
- Use the shared logger with safe identifiers, operation names, duration, and the original error cause. Never log
secrets or full upstream payloads.
- Multi-integration reads should return successful providers when one service fails and surface a clear error when all
providers fail.
Registration files intentionally contain boring, explicit imports. This small amount of repetition preserves exact
TypeScript inference, gives Next.js discoverable lazy chunks, and keeps Ctrl+click navigation reliable.
## Registry imports
Use `@homarr/widgets/manifest` to load registered widgets. `loadWidgetDefinition` is the metadata-only path;
`loadWidgetComponent` loads only the UI module; and `loadWidgetResources` requests both paths in parallel. These
loaders cache in-flight and successful promises and remove rejected promises so a later request can retry.
The former root `widgetImports` and `loadWidgetDynamic` exports are intentionally not compatibility aliases.
`widgetImports` synchronously imported every widget module, and `loadWidgetDynamic` depended on that eager map.
Restoring them at the package root would defeat lazy definition chunks or add client-only runtime dependencies to
every root consumer. Migrate metadata consumers to `loadWidgetDefinition`, renderers to `loadWidgetResources`, and
type-only registry consumers from the removed internal `WidgetImportRecord` helper to the root `WidgetImports` type.
`@homarr/widgets/catalog` remains as a deprecated compatibility subpath for `widgetCatalogIcons`. New imports should
use `@homarr/ui/widget-icons`, which is the canonical server-safe icon catalog.
+3 -3
View File
@@ -1,9 +1,9 @@
import { IntegrationDefinition } from "@site/src/types"; import type { IntegrationDefinition } from "@site/src/types";
export const beszelIntegration: IntegrationDefinition = { export const beszelIntegration = {
name: "Beszel", name: "Beszel",
description: description:
"Beszel is a lightweight server monitoring platform with Docker stats, historical data, and alert functions.", "Beszel is a lightweight server monitoring platform with Docker stats, historical data, and alert functions.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
path: "../../integrations/beszel", path: "../../integrations/beszel",
}; } satisfies IntegrationDefinition;
+3
View File
@@ -113,6 +113,9 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
- Set desktop and mobile home boards for the current user - Set desktop and mobile home boards for the current user
- Configure global board defaults such as public home boards and status behavior - Configure global board defaults such as public home boards and status behavior
Creating, duplicating, or reconfiguring a widget validates its integration selection on the server. The caller needs
use access for each newly linked integration; unchanged links remain editable through normal board permissions.
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation. Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum. The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum.
+12 -8
View File
@@ -133,20 +133,21 @@ On larger canvases, the complete board grows together like browser zoom.
The complete canvas always fits the available width and never adds horizontal board scrolling. The complete canvas always fits the available width and never adds horizontal board scrolling.
- **Mobile** starts at `0px` and uses three columns on new boards. - **Mobile** starts at `0px` and uses three columns on new boards.
- **Base** is the widest layout and starts at `768px` on new boards. - **Base** is the main layout and starts at `768px` on new boards.
Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. Mobile has no fixed Mobile and Base can be renamed and their column counts can be changed, but they cannot be removed. Mobile has no fixed
rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts between rails; rail configuration is available only on Base and custom layouts. You can add removable custom layouts at any
their breakpoints. unused breakpoint above Mobile.
Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in
Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses a Base or any custom layout. New custom layouts start from Base's current column count and rail widths; Homarr chooses an
free breakpoint between Mobile and Base, and you can then change the breakpoint, columns, and rails. available breakpoint, and you can then change the breakpoint, columns, and rails.
Older or incomplete boards are repaired automatically. A board with no layouts receives Mobile at `0px` with three Older or incomplete boards are repaired automatically. A board with no layouts receives Mobile at `0px` with three
columns and Base at `768px` with ten columns. A board with one layout keeps it as Base and receives a generated Mobile columns and Base at `768px` with ten columns. A board with one layout keeps it as Base and receives a generated Mobile
layout. With multiple layouts, the narrowest becomes Mobile, the widest becomes Base, and intermediate layouts remain layout. When protected roles are missing from a board with multiple layouts, the narrowest becomes Mobile, the widest
custom. Duplicate or out-of-order breakpoints are normalized without discarding saved positions. becomes Base, and intermediate layouts become custom. Once roles exist, breakpoint changes do not reassign them.
Duplicate breakpoints are normalized without discarding saved positions.
Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept. Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept.
@@ -182,7 +183,8 @@ Apps, widgets, and Containers inside them still use the same fixed `200 × 200`
#### Breakpoint #### Breakpoint
The minimum screen width where the layout is used; a layout is eligible at its breakpoint and above. Mobile is fixed at The minimum screen width where the layout is used; a layout is eligible at its breakpoint and above. Mobile is fixed at
`0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint. `0px`. Every other breakpoint must be unique, but Base and custom layouts can be ordered at any width. Homarr uses the
eligible layout with the highest breakpoint, regardless of its role.
When the initial viewport is not available, Homarr uses its remembered viewport information, client hints, or User-Agent When the initial viewport is not available, Homarr uses its remembered viewport information, client hints, or User-Agent
fallback to select a layout. fallback to select a layout.
@@ -271,6 +273,8 @@ Expanding it smoothly restores those bounds and moves following items out of the
### Background ### Background
When a background image is set, it always covers at least the full browser window height, even if the board's content is shorter (for example when a Container is collapsed).
#### Background image URL #### Background image URL
Homarr can display a background image behind apps and widgets. Homarr can display a background image behind apps and widgets.
@@ -16,6 +16,7 @@ import TabItem from "@theme/TabItem";
import { mediaRequestListWidget } from "."; import { mediaRequestListWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr"; import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr"; import { overseerrIntegration } from "@site/docs/integrations/overseerr";
import { seerrIntegration } from "@site/docs/integrations/seerr";
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} /> <WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
@@ -39,6 +40,9 @@ import mediaRequestList from "./img/list.png";
{ {
integration: overseerrIntegration, integration: overseerrIntegration,
}, },
{
integration: seerrIntegration,
},
]} ]}
/> />
@@ -46,6 +50,7 @@ import mediaRequestList from "./img/list.png";
- Approve / decline a request by clicking on the thumbs up or down icon. - Approve / decline a request by clicking on the thumbs up or down icon.
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations. - Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
- When no requests exist yet, the widget offers the same media search action instead of reporting an integration error.
- A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations. - A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations.
<WidgetInteractionGuide advancedView={false} /> <WidgetInteractionGuide advancedView={false} />
@@ -16,6 +16,7 @@ import TabItem from "@theme/TabItem";
import { mediaRequestStatsWidget } from "."; import { mediaRequestStatsWidget } from ".";
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr"; import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from "@site/docs/integrations/overseerr"; import { overseerrIntegration } from "@site/docs/integrations/overseerr";
import { seerrIntegration } from "@site/docs/integrations/seerr";
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} /> <WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
@@ -31,6 +32,9 @@ Provides an overview of media requests, including totals, availability, approval
{ {
integration: overseerrIntegration, integration: overseerrIntegration,
}, },
{
integration: seerrIntegration,
},
]} ]}
/> />
@@ -43,6 +47,7 @@ import mediaRequestStats from "./img/stats.png";
### Interactions ### Interactions
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations. - Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
- When no requests exist yet, the widget offers media search and explains when statistics will appear.
- A warning indicator identifies unavailable integrations while totals from healthy integrations remain visible. - A warning indicator identifies unavailable integrations while totals from healthy integrations remain visible.
<WidgetInteractionGuide advancedView={false} /> <WidgetInteractionGuide advancedView={false} />
@@ -18,7 +18,7 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance, This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
including the latest result, historical averages, and a live chart of the last 12 hours. including the latest result, historical averages, and a live chart of the last 12 hours.
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps every configured overview section in its familiar card layout and names its healthy sources when multiple integrations are selected. The widget keeps every configured overview section in a compact card layout and names its healthy sources when multiple integrations are selected.
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown. When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
+11 -7
View File
@@ -2,13 +2,17 @@ import type { LoadContext, Plugin } from "@docusaurus/types";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { integrationDocSlugs } from "@homarr/definitions/docs/integration-doc-slugs";
import { widgetDocSlugs } from "@homarr/definitions/docs/widget-doc-slugs"; import { widgetDocSlugs } from "@homarr/definitions/docs/widget-doc-slugs";
import { getIntegrationDocumentationSlug, integrationKinds } from "@homarr/definitions/integration";
const slugMaps = { const documentationEntries = [
Integration: { slugs: integrationDocSlugs, folder: "integrations" }, {
Widget: { slugs: widgetDocSlugs, folder: "widgets" }, label: "Integration",
} as const; folder: "integrations",
entries: integrationKinds.map((kind) => [kind, getIntegrationDocumentationSlug(kind)] as const),
},
{ label: "Widget", folder: "widgets", entries: Object.entries(widgetDocSlugs) },
] as const;
export default function validateDocsCoveragePlugin(context: LoadContext): Plugin { export default function validateDocsCoveragePlugin(context: LoadContext): Plugin {
return { return {
@@ -17,8 +21,8 @@ export default function validateDocsCoveragePlugin(context: LoadContext): Plugin
const docsDir = path.resolve(context.siteDir, "docs"); const docsDir = path.resolve(context.siteDir, "docs");
const missing: string[] = []; const missing: string[] = [];
for (const [label, { slugs, folder }] of Object.entries(slugMaps)) { for (const { label, folder, entries } of documentationEntries) {
for (const [kind, slug] of Object.entries(slugs)) { for (const [kind, slug] of entries) {
if (!slug) continue; if (!slug) continue;
if (!fs.existsSync(path.join(docsDir, folder, slug))) { if (!fs.existsSync(path.join(docsDir, folder, slug))) {
missing.push(`${label} "${kind}" -> docs/${folder}/${slug}/`); missing.push(`${label} "${kind}" -> docs/${folder}/${slug}/`);
+2 -2
View File
@@ -1,5 +1,5 @@
import { TablerIcon } from "@tabler/icons-react"; import type { TablerIcon } from "@tabler/icons-react";
import { ReactNode } from "react"; import type { ReactNode } from "react";
export interface IntegrationDefinition { export interface IntegrationDefinition {
name: string; name: string;
@@ -145,10 +145,14 @@ const ScopedTRPCReactProvider = ({
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
refetchOnReconnect: false, refetchOnReconnect: false,
}); });
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) { for (const queryDefaults of widgetQueryRefetchIntervals) {
client.setQueryDefaults(queryKey, { const policy: { refetchInterval?: number | false; staleTime?: number } = {};
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000, if (queryDefaults.intervalSeconds === null) policy.refetchInterval = false;
}); if (typeof queryDefaults.intervalSeconds === "number") {
policy.refetchInterval = queryDefaults.intervalSeconds * 1000;
}
if ("staleTimeSeconds" in queryDefaults) policy.staleTime = queryDefaults.staleTimeSeconds * 1000;
client.setQueryDefaults(queryDefaults.queryKey, policy);
} }
for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) { for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) {
client.setQueryDefaults(queryKey, policy); client.setQueryDefaults(queryKey, policy);
@@ -9,7 +9,7 @@ import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions"; import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui"; import { MaskedOrNormalImage } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog"; import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import type { Board } from "../../_types"; import type { Board } from "../../_types";
import classes from "./_layout-preview.module.css"; import classes from "./_layout-preview.module.css";
@@ -40,6 +40,8 @@ interface Props {
saveSettingsAsync: () => Promise<FormValues | null>; saveSettingsAsync: () => Promise<FormValues | null>;
} }
const layoutRoleOrder = { base: 0, custom: 1, mobile: 2 } as const;
export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => { export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync }: Props) => {
const tBoard = useI18n("board"); const tBoard = useI18n("board");
const tLayout = useI18n("layout"); const tLayout = useI18n("layout");
@@ -101,6 +103,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts); const nextBreakpoint = getNextCustomBreakpoint(form.values.layouts);
const baseLayout = form.values.layouts.find((layout) => layout.role === "base"); const baseLayout = form.values.layouts.find((layout) => layout.role === "base");
const displayedLayouts = form.values.layouts
.map((layout, index) => ({ layout, index }))
.toSorted((first, second) => layoutRoleOrder[first.layout.role] - layoutRoleOrder[second.layout.role]);
return ( return (
<SectionCard title={tBoard("setting.section.layout.title")}> <SectionCard title={tBoard("setting.section.layout.title")}>
@@ -134,13 +139,9 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
</Button> </Button>
</Group> </Group>
{form.values.layouts.map((layout, index) => { {displayedLayouts.map(({ layout, index }) => {
const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id); const persistedLayout = board.layouts.find((candidate) => candidate.id === layout.id);
const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base"); const sourceLayout = persistedLayout ?? board.layouts.find((candidate) => candidate.role === "base");
const customBreakpoints = form.values.layouts
.filter((candidate) => candidate.role === "custom" && candidate.id !== layout.id)
.map((candidate) => candidate.breakpoint);
const baseBreakpoint = form.values.layouts.find((candidate) => candidate.role === "base")?.breakpoint ?? 768;
return ( return (
<Fieldset <Fieldset
@@ -190,12 +191,8 @@ export const LayoutSettingsContent = ({ board, form, isSaving, saveSettingsAsync
} }
disabled={layout.role === "mobile"} disabled={layout.role === "mobile"}
styles={{ description: { color: "var(--mantine-color-text)" } }} styles={{ description: { color: "var(--mantine-color-text)" } }}
min={ min={layout.role === "mobile" ? 0 : 1}
layout.role === "base" max={32767}
? Math.max(1, ...customBreakpoints.map((breakpoint) => breakpoint + 1))
: 1
}
max={layout.role === "custom" ? baseBreakpoint - 1 : 32767}
/> />
</Stack> </Stack>
</Grid.Col> </Grid.Col>
+8 -5
View File
@@ -114,12 +114,15 @@ export default async function Layout(props: {
}) })
: null, : null,
); );
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) => { const assistantAvailabilityPromise = sessionPromise.then(async (session): Promise<AssistantAvailability> => {
if (!session) return "unauthenticated"; if (!session) return "unauthenticated";
return api.assistant
.getAvailability() try {
.then((availability) => (availability.enabled ? "enabled" : "unconfigured")) const availability = await api.assistant.getAvailability();
.catch(() => "error"); return availability.enabled ? "enabled" : "unconfigured";
} catch {
return "error";
}
}); });
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([ const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
sessionPromise, sessionPromise,
@@ -50,7 +50,8 @@ function CustomWidgetManifestEditor({ form }: { form: CustomWidgetWorkbenchForm
onChange={(next) => { onChange={(next) => {
setDraft(next); setDraft(next);
try { try {
const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown); const value: unknown = JSON.parse(next);
const parsed = customWidgetDefinitionSchema.safeParse(value);
if (!parsed.success) { if (!parsed.success) {
setError(parsed.error.issues[0]?.message ?? t("invalidWidget")); setError(parsed.error.issues[0]?.message ?? t("invalidWidget"));
return; return;
@@ -76,7 +76,8 @@ export function parseSources(value: string): Array<CustomWidgetSource & { id: st
export function parseJson(value: string): unknown { export function parseJson(value: string): unknown {
try { try {
return JSON.parse(value) as unknown; const parsed: unknown = JSON.parse(value);
return parsed;
} catch { } catch {
return null; return null;
} }
@@ -129,7 +129,9 @@ const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWid
className={classes.paneSwitcher} className={classes.paneSwitcher}
fullWidth fullWidth
value={props.mobilePane} value={props.mobilePane}
onChange={(value) => props.setMobilePane(value as MobilePane)} onChange={(value) => {
if (value === "configure" || value === "preview") props.setMobilePane(value);
}}
data={[ data={[
{ value: "configure", label: w("configure") }, { value: "configure", label: w("configure") },
{ value: "preview", label: w("section.preview") }, { value: "preview", label: w("section.preview") },
@@ -173,7 +173,9 @@ export function RequestBodyEditor({
onChange={(next) => { onChange={(next) => {
setDraft(next); setDraft(next);
try { try {
onChange(next.trim() ? (JSON.parse(next) as unknown) : undefined); let parsed: unknown;
if (next.trim()) parsed = JSON.parse(next);
onChange(parsed);
setError(undefined); setError(undefined);
} catch (cause) { } catch (cause) {
setError(cause instanceof Error ? cause.message : t("invalidJson")); setError(cause instanceof Error ? cause.message : t("invalidJson"));
@@ -2,7 +2,6 @@ import { headers } from "next/headers";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Stack } from "@mantine/core"; import { Stack } from "@mantine/core";
import { mcpRouter } from "@homarr/api/mcp";
import { openApiDocument } from "@homarr/api/open-api"; import { openApiDocument } from "@homarr/api/open-api";
import { api } from "@homarr/api/server"; import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next"; import { auth } from "@homarr/auth/next";
@@ -16,40 +15,24 @@ import { ScalarApiReference } from "./components/scalar-api-reference";
import type { McpToolGroup } from "./components/api-page-tabs"; import type { McpToolGroup } from "./components/api-page-tabs";
let cachedToolGroups: McpToolGroup[] | null = null;
function getMcpToolGroups(): McpToolGroup[] { function getMcpToolGroups(): McpToolGroup[] {
if (cachedToolGroups) return cachedToolGroups;
const tools = extractMcpTools(); const tools = extractMcpTools();
const procedures = (mcpRouter as any)._def.procedures as Record<string, { _def?: { type?: string } }>;
const procedureTypeMap = new Map<string, "query" | "mutation">();
for (const [key, proc] of Object.entries(procedures)) {
const type = proc?._def?.type;
if (type === "query" || type === "mutation") {
procedureTypeMap.set(key, type);
}
}
const groups = new Map<string, McpToolGroup["tools"]>(); const groups = new Map<string, McpToolGroup["tools"]>();
for (const tool of tools) { for (const tool of tools) {
const namespace = tool.pathInRouter[0] ?? "other"; const namespace = tool.pathInRouter[0] ?? "other";
if (!groups.has(namespace)) { if (!groups.has(namespace)) {
groups.set(namespace, []); groups.set(namespace, []);
} }
const procedureKey = tool.pathInRouter.join(".");
groups.get(namespace)?.push({ groups.get(namespace)?.push({
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
type: procedureTypeMap.get(procedureKey) ?? "query", type: tool.type,
}); });
} }
cachedToolGroups = Array.from(groups.entries()).map(([namespace, items]) => ({ return Array.from(groups.entries()).map(([namespace, items]) => ({
namespace, namespace,
tools: items, tools: items,
})); }));
return cachedToolGroups;
} }
export async function generateMetadata() { export async function generateMetadata() {
@@ -70,12 +53,14 @@ export default async function ApiPage() {
if (!session?.user.permissions.includes("admin")) { if (!session?.user.permissions.includes("admin")) {
notFound(); notFound();
} }
const requestHeaders = await headers(); const [requestHeaders, apiKeys, t, toolGroups] = await Promise.all([
headers(),
api.apiKeys.getAll(),
getI18n("management.page.tool.api.tab"),
getMcpToolGroups(),
]);
const baseUrl = extractBaseUrlFromHeaders(requestHeaders); const baseUrl = extractBaseUrlFromHeaders(requestHeaders);
const document = openApiDocument(baseUrl); const document = openApiDocument(baseUrl);
const apiKeys = await api.apiKeys.getAll();
const t = await getI18n("management.page.tool.api.tab");
const toolGroups = getMcpToolGroups();
return ( return (
<> <>
@@ -1,7 +1,7 @@
import type { PropsWithChildren } from "react"; import type { PropsWithChildren } from "react";
import { widgetKinds } from "@homarr/definitions"; import { widgetKinds } from "@homarr/definitions";
import { widgetCatalogIcons } from "@homarr/widgets/catalog"; import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import { MainNavigation } from "~/components/layout/navigation"; import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell"; import { ClientShell } from "~/components/layout/shell";
@@ -327,20 +327,6 @@ const sumCompleteStepMetric = (
return (values as number[]).reduce((sum, value) => sum + value, 0); return (values as number[]).reduce((sum, value) => sum + value, 0);
}; };
const getProcedureTypeMap = () => {
const procedures = (
mcpRouter as unknown as {
["_def"]: { procedures: Record<string, { ["_def"]?: { type?: string } }> };
}
)["_def"].procedures;
return new Map(
Object.entries(procedures).flatMap(([path, procedure]) => {
const type = procedure["_def"]?.type;
return type === "query" || type === "mutation" ? [[path, type] as const] : [];
}),
);
};
const waitForDemoChunkAsync = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)); const waitForDemoChunkAsync = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const createDemoAssistantResponse = (request: z.infer<typeof requestSchema>) => { const createDemoAssistantResponse = (request: z.infer<typeof requestSchema>) => {
@@ -385,7 +371,8 @@ export async function POST(request: Request) {
const parsed = requestSchema.safeParse( const parsed = requestSchema.safeParse(
(() => { (() => {
try { try {
return JSON.parse(requestBody) as unknown; const value: unknown = JSON.parse(requestBody);
return value;
} catch { } catch {
return null; return null;
} }
@@ -516,14 +503,13 @@ export async function POST(request: Request) {
const canAuthorCustomWidgets = session.user.permissions.includes("admin"); const canAuthorCustomWidgets = session.user.permissions.includes("admin");
const customWidgetAuthoringContext = getCustomWidgetAuthoringContext(incomingMessages, canAuthorCustomWidgets); const customWidgetAuthoringContext = getCustomWidgetAuthoringContext(incomingMessages, canAuthorCustomWidgets);
const caller = mcpRouter.createCaller(context); const caller = mcpRouter.createCaller(context);
const procedureTypes = getProcedureTypeMap();
const omittedCustomWidgetTools = new Set<string>(customWidgetAuthoringContext.omittedToolNames); const omittedCustomWidgetTools = new Set<string>(customWidgetAuthoringContext.omittedToolNames);
const mcpTools = extractMcpTools().filter((mcpTool) => !omittedCustomWidgetTools.has(mcpTool.name)); const mcpTools = extractMcpTools().filter((mcpTool) => !omittedCustomWidgetTools.has(mcpTool.name));
const customWidgetComponentDocumentBudget = createCustomWidgetComponentDocumentBudget(); const customWidgetComponentDocumentBudget = createCustomWidgetComponentDocumentBudget();
const homarrTools = Object.fromEntries( const homarrTools = Object.fromEntries(
mcpTools.map((mcpTool) => { mcpTools.map((mcpTool) => {
const requiresApproval = procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation"; const requiresApproval = mcpTool.type === "mutation";
return [ return [
mcpTool.name, mcpTool.name,
tool({ tool({
@@ -571,11 +557,9 @@ export async function POST(request: Request) {
}), }),
) satisfies ToolSet; ) satisfies ToolSet;
const toolApproval = Object.fromEntries( const toolApproval = Object.fromEntries(
mcpTools.flatMap((mcpTool) => mcpTools
procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation" .filter((mcpTool) => mcpTool.type === "mutation")
? [[mcpTool.name, "user-approval" as const]] .map((mcpTool) => [mcpTool.name, "user-approval" as const]),
: [],
),
); );
const frontendTools = Object.fromEntries( const frontendTools = Object.fromEntries(
@@ -46,18 +46,21 @@ vi.mock("../_extract-tools", () => ({
{ {
name: "board_getAllBoards", name: "board_getAllBoards",
description: "List boards", description: "List boards",
type: "query",
pathInRouter: ["board", "getAllBoards"], pathInRouter: ["board", "getAllBoards"],
inputSchema: { type: "object", properties: {} }, inputSchema: { type: "object", properties: {} },
}, },
{ {
name: "customWidget_list", name: "customWidget_list",
description: "List Custom Widgets", description: "List Custom Widgets",
type: "query",
pathInRouter: ["customWidget", "list"], pathInRouter: ["customWidget", "list"],
inputSchema: { type: "object", properties: {} }, inputSchema: { type: "object", properties: {} },
}, },
{ {
name: "customWidget_workshopSearch", name: "customWidget_workshopSearch",
description: "Search Workshop", description: "Search Workshop",
type: "query",
pathInRouter: ["customWidget", "workshopSearch"], pathInRouter: ["customWidget", "workshopSearch"],
inputSchema: { type: "object", properties: {} }, inputSchema: { type: "object", properties: {} },
}, },
+5 -13
View File
@@ -80,18 +80,10 @@ function clearAuthFailures(ip: string | null) {
authFailures.delete(ip ?? "unknown"); authFailures.delete(ip ?? "unknown");
} }
let toolsCache: McpTool[] | null = null; const getVisibleTools = (tools: McpTool[], isAdmin: boolean) => {
if (isAdmin) return tools;
function getTools() { return tools.filter((tool) => !tool.name.startsWith("customWidget_"));
if (!toolsCache) { };
toolsCache = extractMcpTools();
logger.info(`Extracted ${toolsCache.length} MCP tools`);
}
return toolsCache;
}
const getVisibleTools = (isAdmin: boolean) =>
isAdmin ? getTools() : getTools().filter((tool) => !tool.name.startsWith("customWidget_"));
function registerCustomWidgetAuthoring(server: McpServer) { function registerCustomWidgetAuthoring(server: McpServer) {
server.registerPrompt( server.registerPrompt(
@@ -336,7 +328,7 @@ export const handleMcpRequest = async (req: NextRequest) => {
const isAdmin = session.user.permissions.includes("admin"); const isAdmin = session.user.permissions.includes("admin");
const protocolHandler = createMcpProtocolHandler({ const protocolHandler = createMcpProtocolHandler({
caller, caller,
tools: getVisibleTools(isAdmin), tools: getVisibleTools(extractMcpTools(), isAdmin),
version: getPackageVersion(), version: getPackageVersion(),
instructions: SERVER_INSTRUCTIONS, instructions: SERVER_INSTRUCTIONS,
configureServer: isAdmin ? registerCustomWidgetAuthoring : undefined, configureServer: isAdmin ? registerCustomWidgetAuthoring : undefined,
@@ -50,6 +50,7 @@ const echoValidator = z.object({ message: z.string() });
const echoTool: McpTool = { const echoTool: McpTool = {
name: "echo", name: "echo",
description: "Echo a message", description: "Echo a message",
type: "query",
pathInRouter: ["echo"], pathInRouter: ["echo"],
inputValidator: echoValidator, inputValidator: echoValidator,
inputSchema: z.toJSONSchema(echoValidator), inputSchema: z.toJSONSchema(echoValidator),
@@ -2,25 +2,32 @@ import { describe, expect, test } from "vitest";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { widgetIntegrationSupport } from "@homarr/definitions"; import { widgetIntegrationSupport } from "@homarr/definitions";
import { widgetImports } from "@homarr/widgets"; import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest";
import { getAssistantWidgetConfiguration } from "./assistant-widget-tool"; import { getAssistantWidgetConfiguration } from "./assistant-widget-tool";
const settings = { enableStatusByDefault: true, forceDisableStatus: false }; const settings = { enableStatusByDefault: true, forceDisableStatus: false };
const widgetDefinitions = await loadAllWidgetDefinitions();
const getWidgetDefinition = (kind: WidgetKind) => {
const definition = widgetDefinitions.get(kind);
if (!definition) throw new Error(`No definition is registered for ${kind}`);
return definition;
};
describe("getAssistantWidgetConfiguration", () => { describe("getAssistantWidgetConfiguration", () => {
test("keeps server-side integration support synchronized with widget definitions", () => { test("keeps server-side integration support synchronized with widget definitions", () => {
for (const [kind, widgetImport] of Object.entries(widgetImports)) { for (const [kind, definition] of widgetDefinitions) {
const definition = widgetImport.definition;
const expected = "supportedIntegrations" in definition ? definition.supportedIntegrations : undefined; const expected = "supportedIntegrations" in definition ? definition.supportedIntegrations : undefined;
const supportedIntegrations = widgetIntegrationSupport[kind];
expect(widgetIntegrationSupport[kind as WidgetKind], kind).toEqual(expected); expect(supportedIntegrations, kind).toEqual(expected);
} }
}); });
test("preserves generated notebook content and fills its hidden defaults", () => { test("preserves generated notebook content and fills its hidden defaults", () => {
const result = getAssistantWidgetConfiguration( const result = getAssistantWidgetConfiguration(
widgetImports.notebook.definition, getWidgetDefinition("notebook"),
{ {
boardId: "board-1", boardId: "board-1",
boardName: "Home", boardName: "Home",
@@ -42,7 +49,7 @@ describe("getAssistantWidgetConfiguration", () => {
test("keeps only accessible, compatible integrations and respects the widget maximum", () => { test("keeps only accessible, compatible integrations and respects the widget maximum", () => {
const result = getAssistantWidgetConfiguration( const result = getAssistantWidgetConfiguration(
widgetImports.audioStats.definition, getWidgetDefinition("audioStats"),
{ {
boardId: "board-1", boardId: "board-1",
boardName: "Home", boardName: "Home",
@@ -90,13 +97,13 @@ describe("getAssistantWidgetConfiguration", () => {
test("treats integration-backed widgets as required unless explicitly optional", () => { test("treats integration-backed widgets as required unless explicitly optional", () => {
const mediaServer = getAssistantWidgetConfiguration( const mediaServer = getAssistantWidgetConfiguration(
widgetImports.mediaServer.definition, getWidgetDefinition("mediaServer"),
{ boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" }, { boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" },
settings, settings,
[], [],
); );
const calendar = getAssistantWidgetConfiguration( const calendar = getAssistantWidgetConfiguration(
widgetImports.calendar.definition, getWidgetDefinition("calendar"),
{ boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" }, { boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" },
settings, settings,
[], [],
@@ -4,7 +4,7 @@ import { IconLayoutGrid } from "@tabler/icons-react";
import type { BoardPreviewData } from "@homarr/boards/layout-preview"; import type { BoardPreviewData } from "@homarr/boards/layout-preview";
import { projectBoardLayout } from "@homarr/boards/layout-preview"; import { projectBoardLayout } from "@homarr/boards/layout-preview";
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions"; import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { widgetCatalogIcons } from "@homarr/widgets/catalog"; import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import classes from "./board-layout-thumbnail.module.css"; import classes from "./board-layout-thumbnail.module.css";
@@ -21,7 +21,7 @@ describe("item actions create-item", () => {
.build(); .build();
const baseLayout = board.layouts.find((layout) => layout.role === "base"); const baseLayout = board.layouts.find((layout) => layout.role === "base");
const result = createItemCallback({ kind: "mediaMissing" })(board); const result = createItemCallback({ kind: "downloads" })(board);
const createdItem = result.items.at(0); const createdItem = result.items.at(0);
expect(createdItem?.layouts.find((layout) => layout.layoutId === "mobile")?.width).toBe(3); expect(createdItem?.layouts.find((layout) => layout.layoutId === "mobile")?.width).toBe(3);
@@ -63,14 +63,14 @@ describe("item actions create-item", () => {
{ {
layoutId, layoutId,
height: 1, height: 1,
width: 1, width: 2,
...emptyPosition, ...emptyPosition,
sectionId: firstSectionId, sectionId: firstSectionId,
}, },
], ],
}), }),
); );
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 }); expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 2 });
}); });
test("should correctly pass containers and items to getFirstEmptyPosition", () => { test("should correctly pass containers and items to getFirstEmptyPosition", () => {
// Arrange // Arrange
@@ -118,14 +118,14 @@ describe("item actions create-item", () => {
expect(createdItem).toEqual( expect(createdItem).toEqual(
expect.objectContaining({ expect.objectContaining({
kind: itemKind, kind: itemKind,
layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }], layouts: [{ ...emptyPosition, height: 1, width: 2, sectionId: firstSectionId, layoutId }],
}), }),
); );
expect(spy).toHaveBeenCalledWith( expect(spy).toHaveBeenCalledWith(
[expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)], [expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)],
layout.columnCount, layout.columnCount,
undefined, undefined,
{ height: 1, width: 1 }, { height: 1, width: 2 },
); );
}); });
@@ -165,7 +165,7 @@ describe("item actions create-item", () => {
const result = createItemCallback({ id: "calendar", kind: "mediaMissing", targetSectionId: "media" })(board); const result = createItemCallback({ id: "calendar", kind: "mediaMissing", targetSectionId: "media" })(board);
expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([ expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([
expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 4 }), expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 2 }),
]); ]);
}); });
@@ -262,7 +262,7 @@ describe("item actions create-item", () => {
layoutId: layout.id, layoutId: layout.id,
sectionId: "canvas", sectionId: "canvas",
width: 1, width: 1,
height: 3, height: 2,
xOffset: 0, xOffset: 0,
yOffset: 1, yOffset: 1,
}), }),
@@ -1,5 +1,5 @@
import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { nativeFeatureCapabilities, widgetKinds } from "@homarr/definitions"; import { widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
export interface BoardRecipeRecommendation { export interface BoardRecipeRecommendation {
widgetKind: WidgetKind; widgetKind: WidgetKind;
@@ -36,10 +36,10 @@ export const getBoardRecipeRecommendations = ({
return widgetKinds return widgetKinds
.filter((widgetKind) => !existingKinds.has(widgetKind)) .filter((widgetKind) => !existingKinds.has(widgetKind))
.flatMap((widgetKind) => { .flatMap((widgetKind) => {
const capability = nativeFeatureCapabilities[widgetKind as keyof typeof nativeFeatureCapabilities]; const supportedIntegrations = widgetIntegrationSupport[widgetKind];
if (!capability) return []; if (!supportedIntegrations) return [];
const matches = capability.integrations.filter((integrationKind) => availableKinds.has(integrationKind)); const matches = supportedIntegrations.filter((integrationKind) => availableKinds.has(integrationKind));
if (matches.length === 0) return []; if (matches.length === 0) return [];
const preferredMatch = preferredIntegrationKind && matches.includes(preferredIntegrationKind); const preferredMatch = preferredIntegrationKind && matches.includes(preferredIntegrationKind);
@@ -51,10 +51,10 @@ export const getBoardRecipeRecommendations = ({
widgetKind, widgetKind,
integrationKind, integrationKind,
isNewlyAvailable: Boolean(preferredMatch), isNewlyAvailable: Boolean(preferredMatch),
score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - capability.integrations.length, score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - supportedIntegrations.length,
}, },
]; ];
}) })
.sort((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind)) .toSorted((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
.slice(0, Math.max(0, limit)); .slice(0, Math.max(0, limit));
}; };
@@ -1,21 +1,7 @@
.refreshIndicator {
position: absolute;
top: 0.25rem;
right: 0.25rem;
z-index: 3;
display: grid;
place-items: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
background: color-mix(in srgb, var(--mantine-color-body) 78%, transparent);
pointer-events: none;
}
.settingsButton { .settingsButton {
position: absolute; position: absolute;
top: -34px; top: calc(var(--mantine-spacing-sm) / 2);
right: 14px; right: calc(var(--mantine-spacing-sm) / 2);
z-index: 201; z-index: 201;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
@@ -66,7 +52,6 @@
@media (max-width: 768px) { @media (max-width: 768px) {
.settingsButton { .settingsButton {
right: 4px;
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
@@ -4,7 +4,7 @@ import { createPortal } from "react-dom";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { Box, Button, Center, Loader, Portal } from "@mantine/core"; import { Box, Button, Center, Loader, Portal } from "@mantine/core";
import { useElementSize, useIsomorphicEffect } from "@mantine/hooks"; import { useElementSize, useIsomorphicEffect } from "@mantine/hooks";
import { QueryErrorResetBoundary, useIsFetching, useQueryClient } from "@tanstack/react-query"; import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx"; import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
import type { FallbackProps } from "react-error-boundary"; import type { FallbackProps } from "react-error-boundary";
@@ -19,7 +19,6 @@ import { WidgetError } from "@homarr/widgets/errors";
import { import {
createWidgetRuntimeState, createWidgetRuntimeState,
getWidgetQueryKeys, getWidgetQueryKeys,
getWidgetRuntimeQueries,
supportsAdvancedFocus as definitionSupportsAdvancedFocus, supportsAdvancedFocus as definitionSupportsAdvancedFocus,
} from "@homarr/widgets/definition"; } from "@homarr/widgets/definition";
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition"; import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
@@ -40,7 +39,6 @@ import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css"; import itemContentClasses from "./item-content.module.css";
import { WidgetContextMenu } from "./widget-context-menu"; import { WidgetContextMenu } from "./widget-context-menu";
import { removeWidgetDataQueries } from "./widget-query-recovery"; import { removeWidgetDataQueries } from "./widget-query-recovery";
import { matchesWidgetItemQuery } from "./widget-query-scope";
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), { const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
ssr: false, ssr: false,
@@ -158,24 +156,6 @@ const LoadedBoardItemContent = ({
() => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }), () => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }),
[definition, item, settings], [definition, item, settings],
); );
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
const isWidgetRefreshing =
useIsFetching({
predicate: (query) =>
query.state.data !== undefined &&
matchesWidgetItemQuery(
query.queryKey,
widgetQueryKeys,
{
itemId: item.id,
boardId: board.id,
integrationIds: item.integrationIds,
options: menuItem.options,
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
},
definition.queryMatcher,
),
}) > 0;
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null); const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null); const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus(); const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus();
@@ -343,11 +323,6 @@ const LoadedBoardItemContent = ({
/> />
</ErrorBoundary> </ErrorBoundary>
</Box> </Box>
{isWidgetRefreshing && (
<Box className={itemContentClasses.refreshIndicator} data-widget-refreshing aria-hidden>
<Loader size="xs" />
</Box>
)}
</WidgetCardShell> </WidgetCardShell>
); );
@@ -361,6 +336,7 @@ const LoadedBoardItemContent = ({
widgetStateRef={widgetStateRef} widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef} widgetRuntimeRef={widgetRuntimeRef}
sourceRef={sourceRef} sourceRef={sourceRef}
disabled={isAdvanced}
> >
<Box <Box
ref={sourceRef} ref={sourceRef}
@@ -42,7 +42,7 @@ import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { SelectableCard } from "@homarr/ui"; import { SelectableCard } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/widgets/catalog"; import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { EmptySection } from "~/app/[locale]/boards/_types"; import type { EmptySection } from "~/app/[locale]/boards/_types";
@@ -44,6 +44,7 @@ interface WidgetContextMenuProps {
widgetStateRef: MutableRefObject<Record<string, unknown> | null>; widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef; widgetRuntimeRef: WidgetRuntimeRef;
sourceRef: RefObject<HTMLElement | null>; sourceRef: RefObject<HTMLElement | null>;
disabled?: boolean;
children: ReactNode; children: ReactNode;
} }
@@ -53,6 +54,7 @@ export const WidgetContextMenu = ({
previewDimensions, previewDimensions,
widgetRuntimeRef, widgetRuntimeRef,
sourceRef, sourceRef,
disabled = false,
children, children,
}: WidgetContextMenuProps) => { }: WidgetContextMenuProps) => {
const { data: session } = useSession(); const { data: session } = useSession();
@@ -162,6 +164,7 @@ export const WidgetContextMenu = ({
}) ?? []; }) ?? [];
const openEditModal = useCallback(() => { const openEditModal = useCallback(() => {
setMenuOpened(false);
openModal( openModal(
{ {
kind: item.kind, kind: item.kind,
@@ -230,6 +233,7 @@ export const WidgetContextMenu = ({
const handleOpenAdvancedFocus = useCallback(() => { const handleOpenAdvancedFocus = useCallback(() => {
if (!sourceRef.current) return; if (!sourceRef.current) return;
setMenuOpened(false);
openAdvancedFocus(item.id, sourceRef.current, { openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget: restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? sourceRef.current, sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? sourceRef.current,
@@ -256,7 +260,7 @@ export const WidgetContextMenu = ({
opened={menuOpened} opened={menuOpened}
onChange={setMenuOpened} onChange={setMenuOpened}
> >
<Menu.ContextMenu>{children}</Menu.ContextMenu> <Menu.ContextMenu disabled={disabled}>{children}</Menu.ContextMenu>
<WidgetContextMenuDropdown opened={menuOpened} onClose={handleCloseMenu} title={getWidgetName(item.kind, t)}> <WidgetContextMenuDropdown opened={menuOpened} onClose={handleCloseMenu} title={getWidgetName(item.kind, t)}>
{canOpenAdvancedFocus && ( {canOpenAdvancedFocus && (
<> <>
@@ -49,12 +49,14 @@
} }
.widgetIcon { .widgetIcon {
width: var(--mantine-font-size-sm);
height: var(--mantine-font-size-sm);
flex-shrink: 0; flex-shrink: 0;
color: var(--mantine-color-dimmed); color: var(--mantine-color-dimmed);
} }
.name { .name {
font-size: 11px; font-size: var(--mantine-font-size-xs);
font-weight: 600; font-weight: 600;
line-height: 1.2; line-height: 1.2;
white-space: nowrap; white-space: nowrap;
@@ -94,7 +96,7 @@
padding: 2px 6px; padding: 2px 6px;
border-radius: var(--mantine-radius-md); border-radius: var(--mantine-radius-md);
background-color: var(--chip-background); background-color: var(--chip-background);
font-size: 10px; font-size: var(--mantine-font-size-xxs);
line-height: 1.2; line-height: 1.2;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -1,12 +1,14 @@
import type { QueryKey } from "@tanstack/react-query"; import type { QueryKey } from "@tanstack/react-query";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import type { NormalizedWidgetQuery, WidgetDefinition, WidgetQueryMatcherScope } from "@homarr/widgets"; import type { WidgetKind } from "@homarr/definitions";
import { getWidgetQueryKeys, widgetImports } from "@homarr/widgets"; import type { NormalizedWidgetQuery, WidgetQueryMatcherScope } from "@homarr/widgets";
import { getWidgetQueryKeys } from "@homarr/widgets";
import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest";
import { matchesWidgetItemQuery } from "./widget-query-scope"; import { matchesWidgetItemQuery } from "./widget-query-scope";
type WidgetKind = keyof typeof widgetImports; const widgetDefinitions = await loadAllWidgetDefinitions();
const allPhotosAlbumId = "all"; const allPhotosAlbumId = "all";
const createScope = (overrides: Partial<WidgetQueryMatcherScope> = {}): WidgetQueryMatcherScope => ({ const createScope = (overrides: Partial<WidgetQueryMatcherScope> = {}): WidgetQueryMatcherScope => ({
@@ -19,7 +21,9 @@ const createScope = (overrides: Partial<WidgetQueryMatcherScope> = {}): WidgetQu
}); });
const matches = (kind: WidgetKind, path: readonly string[], input: unknown, scope: WidgetQueryMatcherScope) => { const matches = (kind: WidgetKind, path: readonly string[], input: unknown, scope: WidgetQueryMatcherScope) => {
const definition = widgetImports[kind].definition as WidgetDefinition & { kind: string }; const definition = widgetDefinitions.get(kind);
if (!definition) throw new Error(`No definition is registered for ${kind}`);
const queryKey: QueryKey = [path, { input, type: "query" }]; const queryKey: QueryKey = [path, { input, type: "query" }];
return matchesWidgetItemQuery(queryKey, getWidgetQueryKeys(definition), scope, definition.queryMatcher); return matchesWidgetItemQuery(queryKey, getWidgetQueryKeys(definition), scope, definition.queryMatcher);
}; };
@@ -2,7 +2,8 @@
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
overflow: clip; overflow-x: clip;
overflow-y: visible;
} }
.viewport[data-board-hydrated="false"] { .viewport[data-board-hydrated="false"] {
@@ -45,4 +46,13 @@
--mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1)); --mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1));
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1)); --mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1)); --mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
/* Without these, border-radius tokens (e.g. the board's "Item radius" setting) stay at their
* flat rem value while the canvas zoom shrinks the rendered card, so the chosen radius becomes
* imperceptibly small on busy/zoomed-out boards - same class of bug as the spacing/font tokens
* above, just never ported over to radius. */
--mantine-radius-xs: calc(0.25rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-sm: calc(0.25rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-md: calc(0.5rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-lg: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-radius-xl: calc(1rem * var(--board-canvas-ui-scale, 1));
} }
@@ -13,7 +13,7 @@
.board-grid-entry:has([data-board-widget-header]):hover, .board-grid-entry:has([data-board-widget-header]):hover,
.board-grid-entry:has([data-board-widget-header]):focus-within { .board-grid-entry:has([data-board-widget-header]):focus-within {
z-index: 10; z-index: calc(var(--homarr-z-index-board-header) - 1);
} }
.board-grid-entry[data-grid-preview="true"] { .board-grid-entry[data-grid-preview="true"] {
@@ -3,6 +3,22 @@
overflow: visible; overflow: visible;
} }
/* --board-grid-drag-height inherits down the DOM tree like any custom property. A capped
section's own GridPreviewLayer effect correctly avoids setting it on itself (see
grid-preview-layer.tsx), but that does nothing to stop it inheriting the value an
*uncapped* ancestor section (e.g. the board's root section) legitimately sets on itself
while growing during a drag happening anywhere else on the board - any container (not just
a scrollable one - a non-scrollable, e.g. collapsible, container is just as capped) sits
inside that section's DOM tree, so the stray value cascades straight through and silently
defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx (including
its collapsibleHeaderInset adjustment), which only kicks in when the property is entirely
unset, not merely inherited. Reset it here so every container can never inherit a stray
value from anywhere above it, no matter what any ancestor's own drag state is doing.
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
.viewport[data-section-kind="container"] {
--board-grid-drag-height: initial;
}
/* Chained with .viewport (rather than a standalone .scrollableViewport rule) so this always /* Chained with .viewport (rather than a standalone .scrollableViewport rule) so this always
wins on specificity, not source order. GridEditor loads as a separate async chunk in edit wins on specificity, not source order. GridEditor loads as a separate async chunk in edit
mode, and a board mutation can trigger its stylesheet to re-register - at equal specificity, mode, and a board mutation can trigger its stylesheet to re-register - at equal specificity,
@@ -11,17 +27,14 @@
.viewport.scrollableViewport { .viewport.scrollableViewport {
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
/* --board-grid-drag-height inherits down the DOM tree like any custom property. A capped /* The grid content inside has a fixed pixel width matching this viewport's full
section's own GridPreviewLayer effect correctly avoids setting it on itself (see outer width (needed for the drag/resize math), so there's no room to reserve
grid-preview-layer.tsx), but that does nothing to stop it inheriting the value an for a scrollbar without it eating into - or overlapping - the content on
*uncapped* ancestor section (e.g. the board's root section) legitimately sets on itself whichever side it renders. Hiding the native scrollbar track entirely (wheel/
while growing during a drag happening anywhere else on the board - a nested scrollable touch/drag scrolling still works) avoids that instead of trying to reserve
container sits inside that section's DOM tree, so the stray value cascades straight space for it. */
through and silently defeats the `var(--board-grid-drag-height, ...px)` fallback in scrollbar-width: none;
section-grid.tsx, which only kicks in when the property is entirely unset, not merely -ms-overflow-style: none;
inherited. Reset it here so this element can never inherit a stray value from anywhere
above it, no matter what any ancestor's own drag state is doing.
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
--board-grid-drag-height: initial; --board-grid-drag-height: initial;
} }
@@ -148,8 +148,25 @@ export const SectionGrid = ({
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset. // card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset.
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1; const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0; const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
// A collapsible container's toggle bar (see the `containerToggle` Button in
// container-section.tsx) is an absolutely positioned overlay sitting on top of this grid.
// Its height comes from a Mantine size prop, which - like spacing/font-size - is compensated
// by --mantine-scale to render at a constant physical size regardless of board zoom (see
// scaled-board-canvas.module.css). That makes it behave like the outerCardInset gap above,
// not like the grid's own un-compensated logical-pixel math, so it needs the same
// effectiveCanvasScale conversion - a plain, unconverted subtraction would under-reserve on
// a zoomed-out board and leave the header overlapping the content. Without reserving it at
// all, the header bar covers the first row of the container's content instead of sitting
// above it. Only the vertical axis is affected - the toggle spans the full width already.
const collapsibleHeaderInset =
section.kind === "container" && section.options.collapsible
? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale
: 0;
const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset; const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset;
const viewportHeight = getLogicalGridSize(viewportRowCount) - outerCardInset; const viewportHeight = Math.max(
1,
getLogicalGridSize(viewportRowCount) - outerCardInset - collapsibleHeaderInset,
);
// Items are positioned in a coordinate space sized to the *un-inset* column/row count // Items are positioned in a coordinate space sized to the *un-inset* column/row count
// (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are // (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are
// deliberately smaller by outerCardInset to match the container's actual visible card size. // deliberately smaller by outerCardInset to match the container's actual visible card size.
@@ -283,6 +300,7 @@ export const SectionGrid = ({
{ {
width: logicalWidth, width: logicalWidth,
height: `var(--board-grid-drag-height, ${viewportHeight}px)`, height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
marginTop: collapsibleHeaderInset || undefined,
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`, "--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
} as CSSProperties } as CSSProperties
} }
@@ -317,6 +335,9 @@ export const SectionGrid = ({
); );
}; };
// Matches the collapsible container toggle's h={24} in container-section.tsx.
const CONTAINER_HEADER_HEIGHT = 24;
const INTERACTIVE_GRID_SELECTOR = const INTERACTIVE_GRID_SELECTOR =
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]'; 'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]); const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);
@@ -22,7 +22,7 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions"; import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { widgetCatalogIcons } from "@homarr/widgets/catalog"; import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import type { EmptySection } from "~/app/[locale]/boards/_types"; import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useRegisteredGridEditors } from "~/components/board/sections/grid/grid-editor-registry"; import { useRegisteredGridEditors } from "~/components/board/sections/grid/grid-editor-registry";
@@ -2,7 +2,7 @@
overflow: hidden; overflow: hidden;
} }
.header[data-desktop-visible="false"] { .header[data-visible="false"] {
display: none; display: none;
} }
@@ -73,39 +73,38 @@
flex: 0 0 auto; flex: 0 0 auto;
} }
.userMenu {
display: flex;
padding-inline: rem(6px);
}
.mobileContent { .mobileContent {
display: none; display: none;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center; align-items: center;
justify-content: space-between;
gap: var(--mantine-spacing-xs); gap: var(--mantine-spacing-xs);
width: 100%; width: 100%;
height: 100%; height: 100%;
padding-inline: var(--mantine-spacing-xs); padding-inline: var(--mantine-spacing-xs);
} }
.mobileIdentity, .mobileZone {
.mobileActions {
min-width: 0; min-width: 0;
overflow-x: auto;
scrollbar-width: none;
} }
.mobileIdentity { .mobileZone::-webkit-scrollbar {
flex: 1 1 auto; display: none;
overflow: hidden;
} }
.mobileIdentity > :last-child { .mobileZone[data-zone="left"] {
flex: 1 1 auto; justify-content: flex-start;
min-width: 0;
overflow: hidden;
} }
.mobileIdentity h2 { .mobileZone[data-zone="right"] {
overflow: hidden; justify-content: flex-end;
text-overflow: ellipsis; max-width: 72vw;
}
.mobileActions {
flex: 0 0 auto;
} }
.floatingControls { .floatingControls {
@@ -155,21 +154,12 @@
} }
@media (max-width: $mantine-breakpoint-sm) { @media (max-width: $mantine-breakpoint-sm) {
.header[data-desktop-visible="false"] {
display: block;
}
.desktopContent { .desktopContent {
display: none; display: none;
} }
.mobileContent { .mobileContent {
display: flex; display: grid;
}
.floatingControls,
.floatingControlsCorner {
display: none;
} }
} }
@@ -3,15 +3,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { AppShellHeader, Avatar, Group, Indicator, Loader, Tooltip, useMantineColorScheme } from "@mantine/core"; import { AppShellHeader, Avatar, Group, Indicator, Loader, Tooltip, useMantineColorScheme } from "@mantine/core";
import { import { IconBrandDocker, IconHome, IconReplace, IconRobot, IconSettings, IconSunMoon } from "@tabler/icons-react";
IconBrandDocker,
IconHome,
IconLayoutDashboard,
IconReplace,
IconRobot,
IconSettings,
IconSunMoon,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
@@ -81,7 +73,10 @@ export const ConfigurableHeader = ({
return ( return (
<BoardSwitcher> <BoardSwitcher>
{(boardSwitcher) => { {(boardSwitcher) => {
const renderItems = (items: HeaderItem[]) => const renderItems = (
items: HeaderItem[],
display: { search: "input" | "icon"; logo: "logo" | "logoAndText" },
) =>
items.map((item) => ( items.map((item) => (
<HeaderItem <HeaderItem
key={getHeaderItemKey(item)} key={getHeaderItemKey(item)}
@@ -95,8 +90,8 @@ export const ConfigurableHeader = ({
isAdmin={isAdmin} isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled} isDockerEnabled={isDockerEnabled}
board={item.type === "board" ? boardsById.get(item.boardId) : undefined} board={item.type === "board" ? boardsById.get(item.boardId) : undefined}
searchDisplay={headerPreferences.searchDisplay} searchDisplay={display.search}
logoDisplay={headerPreferences.logoDisplay} logoDisplay={display.logo}
boardSwitcher={boardSwitcher} boardSwitcher={boardSwitcher}
assistant={assistant} assistant={assistant}
openDockerModal={openDockerModal} openDockerModal={openDockerModal}
@@ -106,13 +101,19 @@ export const ConfigurableHeader = ({
/> />
)); ));
const desktopDisplay = {
search: headerPreferences.searchDisplay,
logo: headerPreferences.logoDisplay,
};
const mobileDisplay = { search: "icon", logo: "logo" } as const;
return ( return (
<> <>
<AppShellHeader <AppShellHeader
maw="100vw" maw="100vw"
zIndex="var(--homarr-z-index-board-header)" zIndex="var(--homarr-z-index-board-header)"
className={classes.header} className={classes.header}
data-desktop-visible={headerPreferences.visible} data-visible={headerPreferences.visible}
data-advanced-focus-background data-advanced-focus-background
data-app-shell-header data-app-shell-header
> >
@@ -120,37 +121,26 @@ export const ConfigurableHeader = ({
<div className={classes.content}> <div className={classes.content}>
<div className={classes.zone} data-zone="left"> <div className={classes.zone} data-zone="left">
{hasNavigation ? <ClientBurger /> : null} {hasNavigation ? <ClientBurger /> : null}
{renderItems(headerPreferences.zones.left)} {renderItems(headerPreferences.zones.left, desktopDisplay)}
</div> </div>
<div className={classes.zone} data-zone="center"> <div className={classes.zone} data-zone="center">
{renderItems(headerPreferences.zones.center)} {renderItems(headerPreferences.zones.center, desktopDisplay)}
</div> </div>
<div className={classes.zone} data-zone="right"> <div className={classes.zone} data-zone="right">
{renderItems(headerPreferences.zones.right)} {renderItems(headerPreferences.zones.right, desktopDisplay)}
{actions ? <Group className={classes.contextActions}>{actions}</Group> : null} {actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
</div> </div>
</div> </div>
</div> </div>
<div className={classes.mobileContent}> <div className={classes.mobileContent}>
<Group className={classes.mobileIdentity} gap="xs" wrap="nowrap"> <Group className={classes.mobileZone} data-zone="left" gap="xs" wrap="nowrap">
{hasNavigation ? <ClientBurger /> : null} {hasNavigation ? <ClientBurger /> : null}
<HeaderLogo display="logo" logo={logo} logoWithTitle={logoWithTitle} label={t("items.logo")} /> {renderItems(headerPreferences.zones.left, mobileDisplay)}
</Group> </Group>
<Group className={classes.mobileActions} gap="xs" wrap="nowrap"> <Group className={classes.mobileZone} data-zone="right" gap="xs" wrap="nowrap">
{actions} {renderItems(headerPreferences.zones.center, mobileDisplay)}
{boardEditAction} {renderItems(headerPreferences.zones.right, mobileDisplay)}
{boardSettingsAction} {actions ? <Group className={classes.contextActions}>{actions}</Group> : null}
<TourTarget id="board-search">
<MobileSearchButton alwaysVisible />
</TourTarget>
<TourTarget id="board-user-menu">
<UserButtonClient
avatar={avatar}
isAdmin={isAdmin}
isDockerEnabled={isDockerEnabled}
boardSwitcher={boardSwitcher}
/>
</TourTarget>
</Group> </Group>
</div> </div>
</AppShellHeader> </AppShellHeader>
@@ -298,7 +288,9 @@ const HeaderItem = ({
return ( return (
<Tooltip label={boardUnavailableLabel}> <Tooltip label={boardUnavailableLabel}>
<HeaderButton aria-label={boardUnavailableLabel} disabled> <HeaderButton aria-label={boardUnavailableLabel} disabled>
<IconLayoutDashboard size={20} stroke={1.5} /> <Avatar size={22} radius="sm">
?
</Avatar>
</HeaderButton> </HeaderButton>
</Tooltip> </Tooltip>
); );
@@ -416,14 +408,19 @@ const HeaderItem = ({
return ( return (
<TourTarget id="board-user-menu"> <TourTarget id="board-user-menu">
<UserButtonClient <div className={classes.userMenu}>
avatar={avatar} <UserButtonClient
isAdmin={isAdmin} avatar={avatar}
isDockerEnabled={isDockerEnabled} isAdmin={isAdmin}
boardSwitcher={boardSwitcher} isDockerEnabled={isDockerEnabled}
/> boardSwitcher={boardSwitcher}
/>
</div>
</TourTarget> </TourTarget>
); );
}; };
const getBoardInitial = (name: string) => Array.from(name.trim())[0]?.toLocaleUpperCase() ?? "?"; const getBoardInitial = (name: string) => {
const firstCharacter = Array.from(name.trim())[0] ?? "?";
return Array.from(firstCharacter.toUpperCase())[0] ?? "?";
};
+7 -1
View File
@@ -22,11 +22,17 @@ export const ClientShell = ({
const collapsed = useAtomValue(navigationCollapsedAtom); const collapsed = useAtomValue(navigationCollapsedAtom);
const backgroundProps = useOptionalBackgroundProps(); const backgroundProps = useOptionalBackgroundProps();
const { headerPreferences } = useSettings(); const { headerPreferences } = useSettings();
const headerHeight = headerPreferences.visible ? appShellHeaderHeight : { base: appShellHeaderHeight, sm: 0 }; const headerHeight = headerPreferences.visible ? appShellHeaderHeight : 0;
return ( return (
<AppShell <AppShell
{...backgroundProps} {...backgroundProps}
// The board canvas sizes itself to its content (AppShell runs in "static" mode so
// <main> doesn't force a 100dvh minimum - see AppShell.css). Without this, a board
// background image only covers as much height as the content needs, so collapsing a
// container short enough leaves flat page background showing below it instead of the
// background continuing to the bottom of the viewport.
mih={backgroundProps.bg ? "100dvh" : undefined}
header={hasHeader ? { height: headerHeight } : undefined} header={hasHeader ? { height: headerHeight } : undefined}
navbar={ navbar={
hasNavigation hasNavigation
@@ -6,6 +6,7 @@ import { Badge, Indicator, Kbd, Loader, Menu, Text } from "@mantine/core";
import { import {
IconBrandDocker, IconBrandDocker,
IconHome, IconHome,
IconLayoutDashboard,
IconLogin, IconLogin,
IconLogout, IconLogout,
IconReplace, IconReplace,
@@ -16,6 +17,8 @@ import {
import type { RouterOutputs } from "@homarr/api"; import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client"; import { signOut, useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import { hotkeys, invariantTechnicalLabels } from "@homarr/definitions"; import { hotkeys, invariantTechnicalLabels } from "@homarr/definitions";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
@@ -46,6 +49,8 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
const t = useI18n("common.userAvatar.menu"); const t = useI18n("common.userAvatar.menu");
const tBoard = useI18n("board"); const tBoard = useI18n("board");
const session = useSession(); const session = useSession();
const board = useOptionalBoard();
const boardPermissions = board && constructBoardPermissions(board, session.data ?? null);
const { logoutUrl } = useAuthContext(); const { logoutUrl } = useAuthContext();
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal); const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
@@ -67,6 +72,15 @@ export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled, bo
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}> <Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
{t("homeBoard")} {t("homeBoard")}
</Menu.Item> </Menu.Item>
{board && boardPermissions?.hasChangeAccess && (
<Menu.Item
component={Link}
href={`/boards/${encodeURIComponent(board.name)}/settings`}
leftSection={<IconLayoutDashboard size="1rem" />}
>
{tBoard("action.settings")}
</Menu.Item>
)}
<Menu.Item <Menu.Item
leftSection={<IconReplace size="1rem" />} leftSection={<IconReplace size="1rem" />}
rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>} rightSection={<Kbd size="xs">{formatHotkeyLabel(boardSwitcher.hotkey, t("modifier"))}</Kbd>}
-2
View File
@@ -163,7 +163,6 @@ describe("lazy widget application graph", () => {
}); });
await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true); await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true);
await expect(cachedDownload).toBeVisible({ timeout: 5_000 }); await expect(cachedDownload).toBeVisible({ timeout: 5_000 });
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toBeVisible();
const refreshedDownloadsResponse = page.waitForResponse( const refreshedDownloadsResponse = page.waitForResponse(
(response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(), (response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(),
@@ -171,7 +170,6 @@ describe("lazy widget application graph", () => {
releaseRefresh(); releaseRefresh();
releaseRefresh = undefined; releaseRefresh = undefined;
await refreshedDownloadsResponse; await refreshedDownloadsResponse;
await expect(downloadsWidget.locator("[data-widget-refreshing]")).toHaveCount(0);
await page.unroute(downloadsRequestPattern, delayDownloadsRefresh); await page.unroute(downloadsRequestPattern, delayDownloadsRefresh);
expect(pageErrors).toEqual([]); expect(pageErrors).toEqual([]);
expect(hydrationErrors).toEqual([]); expect(hydrationErrors).toEqual([]);
+55 -19
View File
@@ -1,4 +1,4 @@
import type { AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import"; import type { AnyProcedure, AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
import { z } from "zod/v4"; import { z } from "zod/v4";
export interface McpMeta { export interface McpMeta {
@@ -12,6 +12,7 @@ export interface McpMeta {
export interface McpTool { export interface McpTool {
name: string; name: string;
description: string; description: string;
type: "query" | "mutation";
pathInRouter: string[]; pathInRouter: string[];
inputSchema: z.core.JSONSchema.JSONSchema; inputSchema: z.core.JSONSchema.JSONSchema;
inputValidator: z.ZodObject; inputValidator: z.ZodObject;
@@ -19,13 +20,54 @@ export interface McpTool {
const emptyInput = z.object({}); const emptyInput = z.object({});
const isProcedure = (value: AnyProcedure | RouterRecord): value is AnyProcedure => typeof value === "function";
const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput); const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput);
const getObjectInput = (input: z.ZodType) => {
if (input instanceof z.ZodObject) return input;
if (input instanceof z.ZodOptional) {
const unwrappedInput = input.unwrap();
if (unwrappedInput instanceof z.ZodObject) return unwrappedInput;
}
return null;
};
const getMcpMetadata = (meta: unknown): McpMeta["mcp"] => {
if (!meta || typeof meta !== "object" || !("mcp" in meta)) return undefined;
const metadata = meta.mcp;
if (!metadata || typeof metadata !== "object" || !("enabled" in metadata)) return undefined;
if (typeof metadata.enabled !== "boolean") return undefined;
const result: NonNullable<McpMeta["mcp"]> = { enabled: metadata.enabled };
if ("name" in metadata && typeof metadata.name === "string") result.name = metadata.name;
if ("description" in metadata && typeof metadata.description === "string") {
result.description = metadata.description;
}
return result;
};
const getProcedureInput = (inputs: z.ZodType[]) => {
if (inputs.length === 0) return emptyInput;
if (inputs.length === 1) {
const input = inputs[0] ?? emptyInput;
return getObjectInput(input) ?? input;
}
const objectInputs = inputs.map(getObjectInput);
if (objectInputs.every((input): input is z.ZodObject => input !== null)) return mergeInputs(objectInputs);
return null;
};
const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => { const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => {
const properties = (schema.properties ?? {}) as Record<string, Record<string, unknown>>; const properties = schema.properties ?? {};
const required = ((schema.required as string[] | undefined) ?? []).filter( const required = (schema.required ?? []).filter((key) => {
(key) => !("default" in (properties[key] ?? {})), const property = properties[key];
); return !property || typeof property !== "object" || !("default" in property);
});
return { return {
...schema, ...schema,
@@ -41,22 +83,15 @@ export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecor
const tools: McpTool[] = []; const tools: McpTool[] = [];
for (const [path, procedure] of Object.entries(router["_def"].procedures)) { for (const [path, procedure] of Object.entries(router["_def"].procedures)) {
const definition = procedure["_def"] as unknown as { if (!isProcedure(procedure)) continue;
inputs?: z.ZodType[]; const definition = procedure["_def"];
meta?: McpMeta; if (definition.type === "subscription") continue;
};
const metadata = definition.meta?.mcp; const metadata = getMcpMetadata(definition.meta);
if (!metadata?.enabled) continue; if (!metadata?.enabled) continue;
const inputs = (definition.inputs ?? []).filter((input): input is z.ZodType => input !== undefined); const inputs = definition.inputs.filter((input): input is z.ZodType => input instanceof z.ZodType);
const procedureInput = const procedureInput = getProcedureInput(inputs);
inputs.length === 0
? emptyInput
: inputs.length === 1
? (inputs[0] ?? emptyInput)
: inputs.every((input): input is z.ZodObject => input instanceof z.ZodObject)
? mergeInputs(inputs)
: null;
if ( if (
procedureInput === null || procedureInput === null ||
@@ -69,6 +104,7 @@ export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecor
tools.push({ tools.push({
name: metadata.name ?? path.replaceAll(".", "_"), name: metadata.name ?? path.replaceAll(".", "_"),
description: metadata.description ?? "", description: metadata.description ?? "",
type: definition.type,
pathInRouter: path.split("."), pathInRouter: path.split("."),
inputValidator, inputValidator,
inputSchema: normalizeJsonSchema( inputSchema: normalizeJsonSchema(
+180 -71
View File
@@ -9,26 +9,51 @@ import type { AtLeastOneOf } from "@homarr/common/types";
import type { Database } from "@homarr/db"; import type { Database } from "@homarr/db";
import { and, eq, inArray } from "@homarr/db"; import { and, eq, inArray } from "@homarr/db";
import { integrations } from "@homarr/db/schema"; import { integrations } from "@homarr/db/schema";
import type { IntegrationKind } from "@homarr/definitions"; import type { IntegrationKind, WidgetIntegrationKind, WidgetKindWithIntegration } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { publicProcedure } from "../trpc"; import { publicProcedure } from "../trpc";
export type IntegrationAction = "query" | "interact"; export type IntegrationAction = "query" | "interact";
/** const logger = createLogger({ module: "integrationMiddleware" });
* Creates a middleware that provides the integration in the context that is of the specified kinds
* @param action query for showing data or interact for mutating data const getIntegrationKindForLog = (integration: object) => {
* @param kinds kinds of integrations that are supported if ("kind" in integration && typeof integration.kind === "string") return integration.kind;
* @returns middleware that can be used with trpc return "unknown";
* @example publicProcedure.concat(createOneIntegrationMiddleware("query", "piHole", "homeAssistant")).query(...) };
* @throws TRPCError NOT_FOUND if the integration was not found
* @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on the specified integration interface IntegrationAccessMetadata {
*/ action: IntegrationAction;
export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>( cardinality: "one" | "many";
userId: string | undefined;
requestedIntegrationIds: readonly string[];
allowedIntegrationKinds: readonly IntegrationKind[];
}
const throwUnexpectedIntegrationKind = (kind: IntegrationKind, allowedKinds: readonly IntegrationKind[]): never => {
logger.error("Database returned an integration outside the requested kinds", {
integrationKind: kind,
allowedIntegrationKinds: allowedKinds,
});
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Integration kind validation failed" });
};
const createOneIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
action: IntegrationAction, action: IntegrationAction,
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided kinds: readonly TKind[],
) => { ) => {
return publicProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => { return publicProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => {
const accessMetadata: IntegrationAccessMetadata = {
action,
cardinality: "one",
userId: ctx.session?.user.id,
requestedIntegrationIds: [input.integrationId],
allowedIntegrationKinds: kinds,
};
logger.debug("Resolving integration access", accessMetadata);
const integration = await ctx.db.query.integrations.findFirst({ const integration = await ctx.db.query.integrations.findFirst({
where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)), where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)),
with: { with: {
@@ -45,6 +70,7 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
}); });
if (!integration) { if (!integration) {
logger.warn("Integration access target was not found", accessMetadata);
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: `Integration with id ${input.integrationId} not found or not of kinds ${kinds.join(",")}`, message: `Integration with id ${input.integrationId} not found or not of kinds ${kinds.join(",")}`,
@@ -52,22 +78,24 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
} }
await throwIfActionIsNotAllowedAsync(action, ctx.db, [integration], ctx.session); await throwIfActionIsNotAllowedAsync(action, ctx.db, [integration], ctx.session);
logger.debug("Integration access granted", {
...accessMetadata,
resolvedIntegrationKinds: [integration.kind],
});
const { const narrowedKind = kinds.find((kind) => kind === integration.kind);
secrets, if (!narrowedKind) {
kind, return throwUnexpectedIntegrationKind(integration.kind, kinds);
items: _ignore1, }
groupPermissions: _ignore2,
userPermissions: _ignore3, const { secrets, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest } = integration;
...rest
} = integration;
return next({ return next({
ctx: { ctx: {
integration: { integration: {
...rest, ...rest,
externalUrl: rest.app?.href ?? null, externalUrl: rest.app?.href ?? null,
kind: kind as TKind, kind: narrowedKind,
decryptedSecrets: secrets.map((secret) => ({ decryptedSecrets: secrets.map((secret) => ({
...secret, ...secret,
value: decryptSecret(secret.value), value: decryptSecret(secret.value),
@@ -78,6 +106,101 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
}); });
}; };
/**
* Creates a middleware that provides the integration in the context that is of the specified kinds
* @param action query for showing data or interact for mutating data
* @param kinds kinds of integrations that are supported
* @returns middleware that can be used with trpc
* @example publicProcedure.concat(createOneIntegrationMiddleware("query", "piHole", "homeAssistant")).query(...)
* @throws TRPCError NOT_FOUND if the integration was not found
* @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on the specified integration
*/
export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
action: IntegrationAction,
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
) => {
return createOneIntegrationMiddlewareForKinds(action, kinds);
};
const createManyIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
action: IntegrationAction,
kinds: readonly TKind[],
) => {
return publicProcedure.input(z.object({ integrationIds: z.array(z.string()) })).use(async ({ ctx, input, next }) => {
const accessMetadata: IntegrationAccessMetadata = {
action,
cardinality: "many",
userId: ctx.session?.user.id,
requestedIntegrationIds: input.integrationIds,
allowedIntegrationKinds: kinds,
};
logger.debug("Resolving integration access", accessMetadata);
const findIntegrations = async () => {
if (input.integrationIds.length === 0) return [];
return await ctx.db.query.integrations.findMany({
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
app: true,
secrets: true,
items: {
with: {
item: true,
},
},
userPermissions: true,
groupPermissions: true,
},
});
};
const dbIntegrations = await findIntegrations();
const offset = input.integrationIds.length - dbIntegrations.length;
if (offset !== 0) {
logger.warn("Integration access targets were not found", {
...accessMetadata,
missingIntegrationCount: offset,
resolvedIntegrationIds: dbIntegrations.map(({ id }) => id),
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
});
throw new TRPCError({
code: "NOT_FOUND",
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
});
}
if (dbIntegrations.length >= 1) {
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
}
logger.debug("Integration access granted", {
...accessMetadata,
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
});
return next({
ctx: {
integrations: dbIntegrations.map(
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => {
const narrowedKind = kinds.find((allowedKind) => allowedKind === kind);
if (!narrowedKind) return throwUnexpectedIntegrationKind(kind, kinds);
return {
...rest,
externalUrl: rest.app?.href ?? null,
kind: narrowedKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
};
},
),
},
});
});
};
/** /**
* Creates a middleware that provides the integrations in the context that are of the specified kinds and have the specified item * Creates a middleware that provides the integrations in the context that are of the specified kinds and have the specified item
* It also ensures that the user has permission to perform the specified action on the integrations * It also ensures that the user has permission to perform the specified action on the integrations
@@ -92,55 +215,27 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
action: IntegrationAction, action: IntegrationAction,
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided ...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
) => { ) => {
return publicProcedure.input(z.object({ integrationIds: z.array(z.string()) })).use(async ({ ctx, input, next }) => { return createManyIntegrationMiddlewareForKinds(action, kinds);
const dbIntegrations =
input.integrationIds.length >= 1
? await ctx.db.query.integrations.findMany({
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
app: true,
secrets: true,
items: {
with: {
item: true,
},
},
userPermissions: true,
groupPermissions: true,
},
})
: [];
const offset = input.integrationIds.length - dbIntegrations.length;
if (offset !== 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
});
}
if (dbIntegrations.length >= 1) {
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
}
return next({
ctx: {
integrations: dbIntegrations.map(
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
...rest,
externalUrl: rest.app?.href ?? null,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
}),
),
},
});
});
}; };
export const createOneWidgetIntegrationMiddleware = <TWidgetKind extends WidgetKindWithIntegration>(
action: IntegrationAction,
widgetKind: TWidgetKind,
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>> =>
createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>(
action,
getWidgetIntegrationConfig(widgetKind).supportedIntegrations,
);
export const createManyWidgetIntegrationMiddleware = <TWidgetKind extends WidgetKindWithIntegration>(
action: IntegrationAction,
widgetKind: TWidgetKind,
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>> =>
createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>(
action,
getWidgetIntegrationConfig(widgetKind).supportedIntegrations,
);
/** /**
* Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations * Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
* @param action action to perform * @param action action to perform
@@ -152,25 +247,39 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
const throwIfActionIsNotAllowedAsync = async ( const throwIfActionIsNotAllowedAsync = async (
action: IntegrationAction, action: IntegrationAction,
db: Database, db: Database,
integrations: Parameters<typeof hasQueryAccessToIntegrationsAsync>[1], integrationRecords: Parameters<typeof hasQueryAccessToIntegrationsAsync>[1],
session: Session | null, session: Session | null,
) => { ) => {
if (action === "interact") { if (action === "interact") {
const haveAllInteractAccess = integrations const haveAllInteractAccess = integrationRecords
.map((integration) => constructIntegrationPermissions(integration, session)) .map((integration) => constructIntegrationPermissions(integration, session))
.every(({ hasInteractAccess }) => hasInteractAccess); .every(({ hasInteractAccess }) => hasInteractAccess);
if (haveAllInteractAccess) return; if (haveAllInteractAccess) return;
logger.warn("Integration access denied", {
action,
userId: session?.user.id,
integrationIds: integrationRecords.map(({ id }) => id),
integrationKinds: integrationRecords.map(getIntegrationKindForLog),
});
throw new TRPCError({ throw new TRPCError({
code: "FORBIDDEN", code: "FORBIDDEN",
message: "User does not have permission to interact with at least one of the specified integrations", message: "User does not have permission to interact with at least one of the specified integrations",
}); });
} }
const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrations, session); const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrationRecords, session);
if (hasQueryAccess) return; if (hasQueryAccess) return;
logger.warn("Integration access denied", {
action,
userId: session?.user.id,
integrationIds: integrationRecords.map(({ id }) => id),
integrationKinds: integrationRecords.map(getIntegrationKindForLog),
});
throw new TRPCError({ throw new TRPCError({
code: "FORBIDDEN", code: "FORBIDDEN",
message: "User does not have permission to query at least one of the specified integration", message: "User does not have permission to query at least one of the specified integration",
+15 -1
View File
@@ -5,9 +5,10 @@ import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common"; import { createId } from "@homarr/common";
import type { Database, InferSelectModel } from "@homarr/db"; import type { Database, InferSelectModel } from "@homarr/db";
import { asc, eq, inArray, like } from "@homarr/db"; import { asc, eq, inArray, like } from "@homarr/db";
import { apps } from "@homarr/db/schema"; import { apps, integrations } from "@homarr/db/schema";
import { selectAppSchema } from "@homarr/db/validationSchemas"; import { selectAppSchema } from "@homarr/db/validationSchemas";
import { getIconForName } from "@homarr/icons"; import { getIconForName } from "@homarr/icons";
import { invalidateIntegrationResponseCacheAsync } from "@homarr/redis";
import { appCreateManySchema, appEditSchema, appManageSchema } from "@homarr/validation/app"; import { appCreateManySchema, appEditSchema, appManageSchema } from "@homarr/validation/app";
import { byIdSchema, paginatedSchema } from "@homarr/validation/common"; import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
@@ -256,6 +257,14 @@ export const appRouter = createTRPCRouter({
pingUrl: input.pingUrl === "" ? null : input.pingUrl, pingUrl: input.pingUrl === "" ? null : input.pingUrl,
}) })
.where(eq(apps.id, input.id)); .where(eq(apps.id, input.id));
if (app.href !== input.href) {
const linkedIntegrations = await ctx.db.query.integrations.findMany({
columns: { id: true },
where: eq(integrations.appId, input.id),
});
await Promise.all(linkedIntegrations.map(({ id }) => invalidateIntegrationResponseCacheAsync(id)));
}
}), }),
delete: permissionRequiredProcedure delete: permissionRequiredProcedure
.requiresPermission("app-full-all") .requiresPermission("app-full-all")
@@ -271,7 +280,12 @@ export const appRouter = createTRPCRouter({
}) })
.input(byIdSchema) .input(byIdSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const linkedIntegrations = await ctx.db.query.integrations.findMany({
columns: { id: true },
where: eq(integrations.appId, input.id),
});
await ctx.db.delete(apps).where(eq(apps.id, input.id)); await ctx.db.delete(apps).where(eq(apps.id, input.id));
await Promise.all(linkedIntegrations.map(({ id }) => invalidateIntegrationResponseCacheAsync(id)));
}), }),
}); });
+168 -133
View File
@@ -20,9 +20,7 @@ import {
groupMembers, groupMembers,
groupPermissions, groupPermissions,
groups, groups,
integrationGroupPermissions,
integrationItems, integrationItems,
integrationUserPermissions,
integrations, integrations,
apps, apps,
itemLayouts, itemLayouts,
@@ -71,7 +69,7 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access"; import { throwIfActionForbiddenAsync } from "./board/board-access";
import { throwIfActionForbiddenAsync as throwIfIntegrationActionForbiddenAsync } from "./integration/integration-access"; import { throwIfIntegrationActionsForbiddenAsync } from "./integration/integration-access";
import { import {
throwIfCustomWidgetBoardDuplicationForbidden, throwIfCustomWidgetBoardDuplicationForbidden,
throwIfCustomWidgetPlacementChangeForbidden, throwIfCustomWidgetPlacementChangeForbidden,
@@ -131,6 +129,75 @@ const findFirstAvailableBoardItemPosition = (
return null; return null;
}; };
interface WidgetConfiguration {
id: string;
kind: WidgetKind;
integrationIds: readonly string[];
}
const haveSameIntegrationIds = (left: readonly string[], right: readonly string[]) => {
if (left.length !== right.length) return false;
const sortedRight = right.toSorted();
return left.toSorted().every((integrationId, index) => integrationId === sortedRight[index]);
};
const validateWidgetConfigurationsAsync = async (
ctx: Parameters<typeof throwIfIntegrationActionsForbiddenAsync>[0],
submittedItems: readonly WidgetConfiguration[],
storedItems: readonly WidgetConfiguration[] = [],
) => {
const storedItemsById = new Map(storedItems.map((item) => [item.id, item]));
const changedItems = submittedItems.filter((item) => {
const storedItem = storedItemsById.get(item.id);
if (!storedItem || storedItem.kind !== item.kind) return true;
return !haveSameIntegrationIds(item.integrationIds, storedItem.integrationIds);
});
if (changedItems.length === 0) return;
const selectedIntegrationIds = [...new Set(changedItems.flatMap(({ integrationIds }) => integrationIds))];
let integrationRecords: { id: string; kind: IntegrationKind }[] = [];
if (selectedIntegrationIds.length > 0) {
integrationRecords = await ctx.db.query.integrations.findMany({
columns: { id: true, kind: true },
where: inArray(integrations.id, selectedIntegrationIds),
});
}
const integrationRecordsById = new Map(integrationRecords.map((integration) => [integration.id, integration]));
const invalidIntegrationIds = selectedIntegrationIds.filter(
(integrationId) => !integrationRecordsById.has(integrationId),
);
if (invalidIntegrationIds.length > 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
});
}
const addedOrReconfiguredIntegrationIds = [
...new Set(
changedItems.flatMap((item) => {
const storedItem = storedItemsById.get(item.id);
if (!storedItem || storedItem.kind !== item.kind) return item.integrationIds;
return item.integrationIds.filter((integrationId) => !storedItem.integrationIds.includes(integrationId));
}),
),
];
await throwIfIntegrationActionsForbiddenAsync(ctx, addedOrReconfiguredIntegrationIds, "use");
for (const item of changedItems) {
const selectedIntegrationKinds = item.integrationIds.flatMap(
(integrationId) => integrationRecordsById.get(integrationId)?.kind ?? [],
);
const integrationIssue = getWidgetIntegrationIssue(item.kind, selectedIntegrationKinds);
if (!integrationIssue) continue;
throw new TRPCError({
code: "BAD_REQUEST",
message: getWidgetIntegrationIssueMessage(item.kind, integrationIssue),
});
}
};
const searchAccessibleBoardsAsync = async ( const searchAccessibleBoardsAsync = async (
ctx: { db: Database; session: Session | null }, ctx: { db: Database; session: Session | null },
query: string, query: string,
@@ -169,11 +236,7 @@ const searchAccessibleBoardsAsync = async (
like(boards.name, `%${query}%`), like(boards.name, `%${query}%`),
ctx.session?.user.permissions.includes("board-view-all") ctx.session?.user.permissions.includes("board-view-all")
? undefined ? undefined
: or( : or(eq(boards.isPublic, true), eq(boards.creatorId, ctx.session?.user.id ?? ""), inArray(boards.id, boardIds)),
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
inArray(boards.id, boardIds),
),
), ),
limit, limit,
orderBy: asc(boards.name), orderBy: asc(boards.name),
@@ -659,7 +722,7 @@ export const boardRouter = createTRPCRouter({
mcp: { mcp: {
enabled: true, enabled: true,
description: description:
"Duplicate an existing board into a new board. Requires board-create permission and view permission on the source board. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }", "Duplicate an existing board into a new board. Requires board-create permission, view permission on the source board, and use permission for every linked integration. Every widget-integration configuration must be valid. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
}, },
}) })
.input(boardDuplicateSchema) .input(boardDuplicateSchema)
@@ -696,6 +759,14 @@ export const boardRouter = createTRPCRouter({
const { sections: boardSections, items: boardItems, layouts: boardLayouts, ...boardProps } = board; const { sections: boardSections, items: boardItems, layouts: boardLayouts, ...boardProps } = board;
throwIfCustomWidgetBoardDuplicationForbidden(ctx.session.user.permissions.includes("admin"), boardItems); throwIfCustomWidgetBoardDuplicationForbidden(ctx.session.user.permissions.includes("admin"), boardItems);
await validateWidgetConfigurationsAsync(
ctx,
boardItems.map((item) => ({
id: item.id,
kind: item.kind,
integrationIds: item.integrations.map(({ integrationId }) => integrationId),
})),
);
const newBoardId = createId(); const newBoardId = createId();
@@ -776,17 +847,19 @@ export const boardRouter = createTRPCRouter({
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = boardSections const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = boardSections
.flatMap((section) => .flatMap((section) =>
section.layouts.map((layoutSection): InferInsertModel<typeof sectionLayouts> => ({ section.layouts.map(
...layoutSection, (layoutSection): InferInsertModel<typeof sectionLayouts> => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion ...layoutSection,
layoutId: layoutsMap.get(layoutSection.layoutId)!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion layoutId: layoutsMap.get(layoutSection.layoutId)!,
sectionId: sectionMap.get(layoutSection.sectionId)!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
parentSectionId: layoutSection.parentSectionId sectionId: sectionMap.get(layoutSection.sectionId)!,
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion parentSectionId: layoutSection.parentSectionId
sectionMap.get(layoutSection.parentSectionId)! ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
: layoutSection.parentSectionId, sectionMap.get(layoutSection.parentSectionId)!
})), : layoutSection.parentSectionId,
}),
),
) )
.concat( .concat(
(generatedMobilePositions?.sectionLayouts ?? []).map((layoutSection) => ({ (generatedMobilePositions?.sectionLayouts ?? []).map((layoutSection) => ({
@@ -803,11 +876,13 @@ export const boardRouter = createTRPCRouter({
); );
const sectionCollapseStatesToInsert: InferInsertModel<typeof sectionCollapseStates>[] = boardSections.flatMap( const sectionCollapseStatesToInsert: InferInsertModel<typeof sectionCollapseStates>[] = boardSections.flatMap(
(section) => (section) =>
section.collapseStates.map((collapseState): InferInsertModel<typeof sectionCollapseStates> => ({ section.collapseStates.map(
...collapseState, (collapseState): InferInsertModel<typeof sectionCollapseStates> => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion ...collapseState,
sectionId: sectionMap.get(collapseState.sectionId)!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
})), sectionId: sectionMap.get(collapseState.sectionId)!,
}),
),
); );
const itemMap = new Map<string, string>(boardItems.map((item) => [item.id, createId()])); const itemMap = new Map<string, string>(boardItems.map((item) => [item.id, createId()]));
@@ -822,15 +897,17 @@ export const boardRouter = createTRPCRouter({
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = boardItems const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = boardItems
.flatMap((item) => .flatMap((item) =>
item.layouts.map((layoutSection): InferInsertModel<typeof itemLayouts> => ({ item.layouts.map(
...layoutSection, (layoutSection): InferInsertModel<typeof itemLayouts> => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion ...layoutSection,
sectionId: sectionMap.get(layoutSection.sectionId)!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion sectionId: sectionMap.get(layoutSection.sectionId)!,
itemId: itemMap.get(layoutSection.itemId)!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion itemId: itemMap.get(layoutSection.itemId)!,
layoutId: layoutsMap.get(layoutSection.layoutId)!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
})), layoutId: layoutsMap.get(layoutSection.layoutId)!,
}),
),
) )
.concat( .concat(
(generatedMobilePositions?.itemSectionLayouts ?? []).map((layoutSection) => ({ (generatedMobilePositions?.itemSectionLayouts ?? []).map((layoutSection) => ({
@@ -844,34 +921,12 @@ export const boardRouter = createTRPCRouter({
})), })),
); );
// Creates a list with all integration ids the user has access to
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
const integrationIdsWithAccess = hasAccessForAll
? []
: await ctx.db
.selectDistinct({
id: integrationGroupPermissions.integrationId,
})
.from(integrationGroupPermissions)
.leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId))
.where(eq(groupMembers.userId, ctx.session.user.id))
.union(
ctx.db
.selectDistinct({ id: integrationUserPermissions.integrationId })
.from(integrationUserPermissions)
.where(eq(integrationUserPermissions.userId, ctx.session.user.id)),
)
.then((result) => result.map((row) => row.id));
const itemIntegrationsToInsert = boardItems.flatMap((item) => const itemIntegrationsToInsert = boardItems.flatMap((item) =>
item.integrations item.integrations.map((integration) => ({
// Restrict integrations to only those the user has access to // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
.filter(({ integrationId }) => integrationIdsWithAccess.includes(integrationId) || hasAccessForAll) itemId: itemMap.get(item.id)!,
.map((integration) => ({ integrationId: integration.integrationId,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion })),
itemId: itemMap.get(item.id)!,
integrationId: integration.integrationId,
})),
); );
await handleTransactionsAsync(ctx.db, { await handleTransactionsAsync(ctx.db, {
@@ -1514,6 +1569,7 @@ export const boardRouter = createTRPCRouter({
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify"); await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id); const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
await validateWidgetConfigurationsAsync(ctx, input.items, dbBoard.items);
throwIfCustomWidgetPlacementChangeForbidden({ throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"), isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: input.items, submittedItems: input.items,
@@ -1523,10 +1579,9 @@ export const boardRouter = createTRPCRouter({
for (const item of input.items) { for (const item of input.items) {
if (item.kind !== "timetable") continue; if (item.kind !== "timetable") continue;
const previousItem = dbBoard.items.find((dbItem) => dbItem.id === item.id); const previousItem = dbBoard.items.find((dbItem) => dbItem.id === item.id);
await validateTimetableOptionsChangeAsync( let previousOptions: Record<string, unknown> | undefined;
item.options, if (previousItem?.kind === "timetable") previousOptions = previousItem.options;
previousItem?.kind === "timetable" ? previousItem.options : undefined, await validateTimetableOptionsChangeAsync(item.options, previousOptions);
);
} }
await handleTransactionsAsync(ctx.db, { await handleTransactionsAsync(ctx.db, {
@@ -1550,15 +1605,17 @@ export const boardRouter = createTRPCRouter({
const sectionLayoutsToInsert = addedSections const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container") .filter((section) => section.kind === "container")
.flatMap((section) => .flatMap((section) =>
section.layouts.map((sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({ section.layouts.map(
layoutId: sectionLayout.layoutId, (sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
sectionId: section.id, layoutId: sectionLayout.layoutId,
parentSectionId: sectionLayout.parentSectionId, sectionId: section.id,
height: sectionLayout.height, parentSectionId: sectionLayout.parentSectionId,
width: sectionLayout.width, height: sectionLayout.height,
xOffset: sectionLayout.xOffset, width: sectionLayout.width,
yOffset: sectionLayout.yOffset, xOffset: sectionLayout.xOffset,
})), yOffset: sectionLayout.yOffset,
}),
),
); );
if (sectionLayoutsToInsert.length > 0) { if (sectionLayoutsToInsert.length > 0) {
@@ -1580,15 +1637,17 @@ export const boardRouter = createTRPCRouter({
); );
await transaction.insert(schema.itemLayouts).values( await transaction.insert(schema.itemLayouts).values(
addedItems.flatMap((item) => addedItems.flatMap((item) =>
item.layouts.map((layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({ item.layouts.map(
layoutId: layoutSection.layoutId, (layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({
sectionId: layoutSection.sectionId, layoutId: layoutSection.layoutId,
itemId: item.id, sectionId: layoutSection.sectionId,
height: layoutSection.height, itemId: item.id,
width: layoutSection.width, height: layoutSection.height,
xOffset: layoutSection.xOffset, width: layoutSection.width,
yOffset: layoutSection.yOffset, xOffset: layoutSection.xOffset,
})), yOffset: layoutSection.yOffset,
}),
),
), ),
); );
} }
@@ -1746,15 +1805,17 @@ export const boardRouter = createTRPCRouter({
const sectionLayoutsToInsert = addedSections const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container") .filter((section) => section.kind === "container")
.flatMap((section) => .flatMap((section) =>
section.layouts.map((sectionLayout): InferInsertModel<typeof sectionLayouts> => ({ section.layouts.map(
layoutId: sectionLayout.layoutId, (sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
sectionId: section.id, layoutId: sectionLayout.layoutId,
parentSectionId: sectionLayout.parentSectionId, sectionId: section.id,
height: sectionLayout.height, parentSectionId: sectionLayout.parentSectionId,
width: sectionLayout.width, height: sectionLayout.height,
xOffset: sectionLayout.xOffset, width: sectionLayout.width,
yOffset: sectionLayout.yOffset, xOffset: sectionLayout.xOffset,
})), yOffset: sectionLayout.yOffset,
}),
),
); );
if (sectionLayoutsToInsert.length > 0) { if (sectionLayoutsToInsert.length > 0) {
@@ -1781,15 +1842,17 @@ export const boardRouter = createTRPCRouter({
.insert(itemLayouts) .insert(itemLayouts)
.values( .values(
addedItems.flatMap((item) => addedItems.flatMap((item) =>
item.layouts.map((layoutSection): InferInsertModel<typeof itemLayouts> => ({ item.layouts.map(
layoutId: layoutSection.layoutId, (layoutSection): InferInsertModel<typeof itemLayouts> => ({
sectionId: layoutSection.sectionId, layoutId: layoutSection.layoutId,
itemId: item.id, sectionId: layoutSection.sectionId,
height: layoutSection.height, itemId: item.id,
width: layoutSection.width, height: layoutSection.height,
xOffset: layoutSection.xOffset, width: layoutSection.width,
yOffset: layoutSection.yOffset, xOffset: layoutSection.xOffset,
})), yOffset: layoutSection.yOffset,
}),
),
), ),
) )
.run(); .run();
@@ -2092,6 +2155,10 @@ export const boardRouter = createTRPCRouter({
.output(z.object({ itemId: z.string() })) .output(z.object({ itemId: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
await validateWidgetConfigurationsAsync(ctx, [
{ id: "", kind: input.kind, integrationIds: input.integrationIds },
]);
throwIfCustomWidgetPlacementChangeForbidden({ throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"), isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: [{ id: "", kind: input.kind, options: input.options }], submittedItems: [{ id: "", kind: input.kind, options: input.options }],
@@ -2102,35 +2169,6 @@ export const boardRouter = createTRPCRouter({
await validateTimetableOptionsChangeAsync(input.options); await validateTimetableOptionsChangeAsync(input.options);
} }
let selectedIntegrationKinds: IntegrationKind[] = [];
if (input.integrationIds.length > 0) {
const existing = await ctx.db.query.integrations.findMany({
columns: { id: true, kind: true },
where: inArray(integrations.id, input.integrationIds),
});
const validIds = new Set(existing.map((row) => row.id));
const invalid = input.integrationIds.filter((id) => !validIds.has(id));
if (invalid.length > 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` });
}
selectedIntegrationKinds = existing.map((integration) => integration.kind);
await Promise.all(
input.integrationIds.map((integrationId) =>
throwIfIntegrationActionForbiddenAsync(ctx, eq(integrations.id, integrationId), "use"),
),
);
}
const integrationIssue = getWidgetIntegrationIssue(input.kind, selectedIntegrationKinds);
if (integrationIssue) {
throw new TRPCError({
code: "BAD_REQUEST",
message: getWidgetIntegrationIssueMessage(input.kind, integrationIssue),
});
}
return await serializeBoardItemPlacementAsync(input.boardId, async () => { return await serializeBoardItemPlacementAsync(input.boardId, async () => {
const board = await ctx.db.query.boards.findFirst({ const board = await ctx.db.query.boards.findFirst({
where: eq(boards.id, input.boardId), where: eq(boards.id, input.boardId),
@@ -2776,14 +2814,11 @@ export const boardLayoutsNeedRepair = (
) => { ) => {
const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile"); const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile");
const baseLayouts = boardLayouts.filter((layout) => layout.role === "base"); const baseLayouts = boardLayouts.filter((layout) => layout.role === "base");
const baseLayout = baseLayouts.at(0);
return ( return (
mobileLayouts.length !== 1 || mobileLayouts.length !== 1 ||
baseLayouts.length !== 1 || baseLayouts.length !== 1 ||
mobileLayouts.at(0)?.breakpoint !== 0 || mobileLayouts.at(0)?.breakpoint !== 0 ||
!baseLayout ||
boardLayouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint) ||
new Set(boardLayouts.map((layout) => layout.breakpoint)).size !== boardLayouts.length new Set(boardLayouts.map((layout) => layout.breakpoint)).size !== boardLayouts.length
); );
}; };
@@ -245,8 +245,8 @@ type DockerContainerAction = "start" | "stop" | "restart" | "remove";
export const performDockerContainerActionsAsync = async ( export const performDockerContainerActionsAsync = async (
targets: DockerContainerTarget[], targets: DockerContainerTarget[],
action: DockerContainerAction, action: DockerContainerAction,
) => ) => {
await Promise.all( const results = await Promise.all(
targets.map(async (target) => { targets.map(async (target) => {
try { try {
const capability = action === "remove" ? "remove" : "lifecycle"; const capability = action === "remove" ? "remove" : "lifecycle";
@@ -271,6 +271,13 @@ export const performDockerContainerActionsAsync = async (
}), }),
); );
if (results.some(({ success }) => success)) {
dockerContainersRequestHandler.invalidateCache();
}
return results;
};
const assertDockerEndpointCapability = ( const assertDockerEndpointCapability = (
endpointId: string, endpointId: string,
capability: "inventory" | "logs" | "lifecycle" | "remove", capability: "inventory" | "logs" | "lifecycle" | "remove",
@@ -1,12 +1,59 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import type { Session } from "@homarr/auth"; import type { Session } from "@homarr/auth";
import type { IntegrationPermissionsProps } from "@homarr/auth/shared";
import { constructIntegrationPermissions } from "@homarr/auth/shared"; import { constructIntegrationPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db"; import type { Database, SQL } from "@homarr/db";
import { eq, inArray } from "@homarr/db"; import { eq, inArray } from "@homarr/db";
import { groupMembers, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema"; import { groupMembers, integrationGroupPermissions, integrations, integrationUserPermissions } from "@homarr/db/schema";
import type { IntegrationPermission } from "@homarr/definitions"; import type { IntegrationPermission } from "@homarr/definitions";
const hasIntegrationPermission = (
integration: IntegrationPermissionsProps,
session: Session | null,
permission: IntegrationPermission,
) => {
const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session);
if (hasFullAccess) return true;
if (permission === "interact") return hasInteractAccess;
if (permission === "use") return hasUseAccess || hasInteractAccess;
return false;
};
const throwIfActionsForbiddenAsync = async (
ctx: { db: Database; session: Session | null },
integrationWhere: SQL<unknown>,
expectedCount: number,
permission: IntegrationPermission,
) => {
const { db, session } = ctx;
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const integrationRecords = await db.query.integrations.findMany({
where: integrationWhere,
columns: { id: true },
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map(({ groupId }) => groupId).concat(""),
),
},
},
});
if (
integrationRecords.length !== expectedCount ||
!integrationRecords.every((integration) => hasIntegrationPermission(integration, session, permission))
) {
notAllowed();
}
};
/** /**
* Throws NOT_FOUND if user is not allowed to perform action on integration * Throws NOT_FOUND if user is not allowed to perform action on integration
* @param ctx trpc router context * @param ctx trpc router context
@@ -18,47 +65,23 @@ export const throwIfActionForbiddenAsync = async (
integrationWhere: SQL<unknown>, integrationWhere: SQL<unknown>,
permission: IntegrationPermission, permission: IntegrationPermission,
) => { ) => {
const { db, session } = ctx; await throwIfActionsForbiddenAsync(ctx, integrationWhere, 1, permission);
const groupsOfCurrentUser = await db.query.groupMembers.findMany({ };
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const integration = await db.query.integrations.findFirst({
where: integrationWhere,
columns: {
id: true,
},
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
),
},
},
});
if (!integration) { export const throwIfIntegrationActionsForbiddenAsync = async (
notAllowed(); ctx: { db: Database; session: Session | null },
} integrationIds: readonly string[],
permission: IntegrationPermission,
) => {
const uniqueIntegrationIds = [...new Set(integrationIds)];
if (uniqueIntegrationIds.length === 0) return;
const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session); await throwIfActionsForbiddenAsync(
ctx,
if (hasFullAccess) { inArray(integrations.id, uniqueIntegrationIds),
return; // As full access is required and user has full access, allow uniqueIntegrationIds.length,
} permission,
);
if (["interact", "use"].includes(permission) && hasInteractAccess) {
return; // As interact access is required and user has interact access, allow
}
if (permission === "use" && hasUseAccess) {
return; // As use access is required and user has use access, allow
}
notAllowed();
}; };
/** /**
@@ -27,8 +27,10 @@ import {
integrationKinds, integrationKinds,
integrationSecretKindObject, integrationSecretKindObject,
} from "@homarr/definitions"; } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations/factory";
import { invalidateIntegrationCacheAsync } from "@homarr/redis"; import { invalidateIntegrationCacheAsync } from "@homarr/redis";
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { byIdSchema } from "@homarr/validation/common"; import { byIdSchema } from "@homarr/validation/common";
import { import {
integrationCreateSchema, integrationCreateSchema,
@@ -156,9 +158,21 @@ export const integrationRouter = createTRPCRouter({
}) })
.input(byIdSchema) .input(byIdSchema)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); const integrationWhere = eq(integrations.id, input.id);
await throwIfActionForbiddenAsync(ctx, integrationWhere, "full").catch((error) => {
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
logger.warn("Integration lookup refused", {
integrationId: input.id,
errorCode: error.code,
reason: "integration-not-found-or-forbidden",
});
}
throw error;
});
const integration = await ctx.db.query.integrations.findFirst({ const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id), where: integrationWhere,
with: { with: {
secrets: { secrets: {
columns: { columns: {
@@ -179,6 +193,11 @@ export const integrationRouter = createTRPCRouter({
}); });
if (!integration) { if (!integration) {
logger.warn("Integration lookup refused", {
integrationId: input.id,
errorCode: "NOT_FOUND",
reason: "integration-not-found",
});
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Integration not found", message: "Integration not found",
@@ -695,7 +714,17 @@ export const integrationRouter = createTRPCRouter({
.input(mediaRequestRequestSchema) .input(mediaRequestRequestSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration); const integration = await createIntegrationAsync(ctx.integration);
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons); const result = await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
try {
mediaRequestListRequestHandler.invalidateCache();
await mediaRequestStatsRequestHandler.invalidateCacheAsync([ctx.integration.id]);
} catch (error) {
logger.warn("Failed to invalidate media request caches after a successful request", {
integrationId: ctx.integration.id,
error: String(error),
});
}
return result;
}), }),
}); });
@@ -4,7 +4,7 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { Integration } from "@homarr/db/schema"; import type { Integration } from "@homarr/db/schema";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions"; import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions } from "@homarr/definitions"; import { getAllSecretKindOptions } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations/factory";
const logger = createLogger({ module: "integrationTestConnection" }); const logger = createLogger({ module: "integrationTestConnection" });
@@ -218,6 +218,7 @@ export const onboardRouter = createTRPCRouter({
const discoveredApps: DiscoveredApp[] = []; const discoveredApps: DiscoveredApp[] = [];
for (const service of discovery.services) { for (const service of discovery.services) {
const widgetKind = service.widgetKind;
if (service.integrationKind) { if (service.integrationKind) {
discoveredIntegrations.push({ discoveredIntegrations.push({
sourceId: service.sourceId, sourceId: service.sourceId,
@@ -231,7 +232,7 @@ export const onboardRouter = createTRPCRouter({
group: service.group, group: service.group,
boardName: service.boardName, boardName: service.boardName,
host: service.host, host: service.host,
widgetKind: service.widgetKind, widgetKind,
description: service.description, description: service.description,
pingUrl: service.pingUrl, pingUrl: service.pingUrl,
}); });
@@ -249,7 +250,7 @@ export const onboardRouter = createTRPCRouter({
description: service.description, description: service.description,
pingUrl: service.pingUrl, pingUrl: service.pingUrl,
host: service.host, host: service.host,
widgetKind: service.widgetKind, widgetKind,
}); });
} }
} }
+16 -7
View File
@@ -41,6 +41,14 @@ const defaultSession = {
expires: new Date().toISOString(), expires: new Date().toISOString(),
} satisfies Session; } satisfies Session;
const integrationUseSession = {
...defaultSession,
user: {
...defaultSession.user,
permissions: ["integration-use-all"],
},
} satisfies Session;
// Mock the auth module to return an empty session // Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
@@ -698,7 +706,8 @@ describe("duplicateBoard should preserve protected layout invariants", () => {
}, },
}; };
const caller = boardRouter.createCaller({ db, deviceType: undefined, session }); const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
const { boardId } = await createFullBoardAsync(db, "source"); const { boardId, integrationId } = await createFullBoardAsync(db, "source");
await db.delete(integrationItems).where(eq(integrationItems.integrationId, integrationId));
const result = await caller.duplicateBoard({ id: boardId, name: "copy" }); const result = await caller.duplicateBoard({ id: boardId, name: "copy" });
@@ -1195,7 +1204,7 @@ describe("saveBoard should save full board", () => {
it("should remove integration reference when not present in input", async () => { it("should remove integration reference when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb(); const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); const caller = boardRouter.createCaller({ db, deviceType: undefined, session: integrationUseSession });
const anotherIntegration = { const anotherIntegration = {
id: createId(), id: createId(),
kind: "adGuardHome", kind: "adGuardHome",
@@ -1219,8 +1228,8 @@ describe("saveBoard should save full board", () => {
items: [ items: [
{ {
id: itemId, id: itemId,
kind: "clock", kind: "dnsHoleSummary",
options: { is24HourFormat: true }, options: { usePiHoleColors: true, layout: "grid" },
integrationIds: [anotherIntegration.id], integrationIds: [anotherIntegration.id],
layouts: [ layouts: [
{ {
@@ -1526,7 +1535,7 @@ describe("saveBoard should save full board", () => {
it("should add integration reference when present in input", async () => { it("should add integration reference when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb(); const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); const caller = boardRouter.createCaller({ db, deviceType: undefined, session: integrationUseSession });
const integration = { const integration = {
id: createId(), id: createId(),
kind: "plex", kind: "plex",
@@ -1550,8 +1559,8 @@ describe("saveBoard should save full board", () => {
items: [ items: [
{ {
id: itemId, id: itemId,
kind: "clock", kind: "mediaServer",
options: { is24HourFormat: true }, options: { showOnlyPlaying: true, showBitrate: true, showLocation: true },
integrationIds: [integration.id], integrationIds: [integration.id],
layouts: [ layouts: [
{ {
@@ -1,7 +1,8 @@
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import * as homarrDefinitions from "@homarr/definitions"; import * as homarrDefinitions from "@homarr/definitions";
import * as homarrIntegrations from "@homarr/integrations"; import type * as homarrIntegrations from "@homarr/integrations";
import * as homarrIntegrationFactory from "@homarr/integrations/factory";
import { testConnectionAsync } from "../../integration/integration-test-connection"; import { testConnectionAsync } from "../../integration/integration-test-connection";
@@ -18,7 +19,7 @@ vi.mock("@homarr/common/server", async (importActual) => {
describe("testConnectionAsync should run test connection of integration", () => { describe("testConnectionAsync should run test connection of integration", () => {
test("with input of only form secrets matching api key kind it should use form apiKey", async () => { test("with input of only form secrets matching api key kind it should use form apiKey", async () => {
// Arrange // Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync"); const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue( factorySpy.mockReturnValue(
Promise.resolve({ Promise.resolve({
@@ -61,7 +62,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of only null form secrets and the required db secrets matching api key kind it should use db apiKey", async () => { test("with input of only null form secrets and the required db secrets matching api key kind it should use db apiKey", async () => {
// Arrange // Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync"); const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue( factorySpy.mockReturnValue(
Promise.resolve({ Promise.resolve({
@@ -111,7 +112,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of form and db secrets matching api key kind it should use form apiKey", async () => { test("with input of form and db secrets matching api key kind it should use form apiKey", async () => {
// Arrange // Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync"); const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue( factorySpy.mockReturnValue(
Promise.resolve({ Promise.resolve({
@@ -161,7 +162,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of form apiKey and db secrets for username and password it should use form apiKey when both is allowed", async () => { test("with input of form apiKey and db secrets for username and password it should use form apiKey when both is allowed", async () => {
// Arrange // Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync"); const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue( factorySpy.mockReturnValue(
Promise.resolve({ Promise.resolve({
@@ -215,7 +216,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of null form apiKey and db secrets for username and password it should use db username and password when both is allowed", async () => { test("with input of null form apiKey and db secrets for username and password it should use db username and password when both is allowed", async () => {
// Arrange // Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync"); const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue( factorySpy.mockReturnValue(
Promise.resolve({ Promise.resolve({
+1 -1
View File
@@ -280,7 +280,7 @@ describe("onboard.completeSetup", () => {
expect(mediaMissing).toBeDefined(); expect(mediaMissing).toBeDefined();
expect( expect(
await db.query.itemLayouts.findFirst({ where: eq(itemLayouts.itemId, mediaMissing?.id ?? "missing") }), await db.query.itemLayouts.findFirst({ where: eq(itemLayouts.itemId, mediaMissing?.id ?? "missing") }),
).toMatchObject({ width: 4, height: 3 }); ).toMatchObject({ width: 2, height: 2 });
}); });
it("requires an exact board id when more than one board exists", async () => { it("requires an exact board id when more than one board exists", async () => {
@@ -3,15 +3,12 @@ import { z } from "zod/v4";
import { ResponseError } from "@homarr/common/server"; import { ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { import { mockWidgetData } from "@homarr/integrations";
anchorNotesListInputSchema, import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema } from "@homarr/integrations/anchor";
anchorNoteUpdateInputSchema, import { createIntegrationAsync } from "@homarr/integrations/factory";
createIntegrationAsync,
mockWidgetData,
} from "@homarr/integrations";
import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes"; import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes";
import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const noteIdInput = z.object({ const noteIdInput = z.object({
@@ -51,7 +48,7 @@ const normalizeAnchorContent = (content: string | undefined) => {
export const anchorNotesRouter = createTRPCRouter({ export const anchorNotesRouter = createTRPCRouter({
listNotes: publicProcedure listNotes: publicProcedure
.concat(createOneIntegrationMiddleware("query", "anchor", "mock")) .concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
.input(anchorNotesListInputSchema) .input(anchorNotesListInputSchema)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") { if (ctx.integration.kind === "mock") {
@@ -73,7 +70,7 @@ export const anchorNotesRouter = createTRPCRouter({
return data; return data;
}), }),
getNote: publicProcedure getNote: publicProcedure
.concat(createOneIntegrationMiddleware("query", "anchor", "mock")) .concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
.input(noteIdInput) .input(noteIdInput)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") { if (ctx.integration.kind === "mock") {
@@ -91,7 +88,7 @@ export const anchorNotesRouter = createTRPCRouter({
return data; return data;
}), }),
updateNote: protectedProcedure updateNote: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "anchor", "mock")) .concat(createOneWidgetIntegrationMiddleware("interact", "anchorNote"))
.input(anchorNoteUpdateInputSchema) .input(anchorNoteUpdateInputSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const normalizedContent = normalizeAnchorContent(input.content); const normalizedContent = normalizeAnchorContent(input.content);
@@ -115,6 +112,8 @@ export const anchorNotesRouter = createTRPCRouter({
...(input.title !== undefined ? { title: input.title } : {}), ...(input.title !== undefined ? { title: input.title } : {}),
...(normalizedContent !== undefined ? { content: normalizedContent } : {}), ...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
}); });
anchorNotesListRequestHandler.invalidateCache();
await anchorNoteRequestHandler.invalidateCacheAsync([ctx.integration.id]);
return updatedNote; return updatedNote;
} catch (error) { } catch (error) {
@@ -1,12 +1,12 @@
import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior"; import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
import { mockWidgetData } from "@homarr/integrations"; import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const archiveTeamWarriorRouter = createTRPCRouter({ export const archiveTeamWarriorRouter = createTRPCRouter({
getStatus: publicProcedure getStatus: publicProcedure
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior", "mock")) .concat(createOneWidgetIntegrationMiddleware("query", "archiveTeamWarrior"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") { if (ctx.integration.kind === "mock") {
return { status: mockWidgetData.archiveTeamWarrior, updatedAt: new Date(mockWidgetData.timestamp) }; return { status: mockWidgetData.archiveTeamWarrior, updatedAt: new Date(mockWidgetData.timestamp) };
@@ -2,12 +2,12 @@ import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobooks
import { navidromeRequestHandler } from "@homarr/request-handler/navidrome"; import { navidromeRequestHandler } from "@homarr/request-handler/navidrome";
import { mockWidgetData } from "@homarr/integrations"; import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const audioStatsRouter = createTRPCRouter({ export const audioStatsRouter = createTRPCRouter({
getStats: publicProcedure getStats: publicProcedure
.concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf", "mock")) .concat(createOneWidgetIntegrationMiddleware("query", "audioStats"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const { kind } = ctx.integration; const { kind } = ctx.integration;
if (kind === "mock") return { kind: "navidrome" as const, data: mockWidgetData.audioStats }; if (kind === "mock") return { kind: "navidrome" as const, data: mockWidgetData.audioStats };
+2 -2
View File
@@ -1,7 +1,7 @@
import { bazarrBadgesRequestHandler } from "@homarr/request-handler/bazarr"; import { bazarrBadgesRequestHandler } from "@homarr/request-handler/bazarr";
import { mockWidgetData } from "@homarr/integrations"; import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const bazarrRouter = createTRPCRouter({ export const bazarrRouter = createTRPCRouter({
@@ -13,7 +13,7 @@ export const bazarrRouter = createTRPCRouter({
"Get missing subtitle counts, provider issues, and health warnings for a Bazarr integration. REQUIRED: integrationId from integration_all", "Get missing subtitle counts, provider issues, and health warnings for a Bazarr integration. REQUIRED: integrationId from integration_all",
}, },
}) })
.concat(createOneIntegrationMiddleware("query", "bazarr", "mock")) .concat(createOneWidgetIntegrationMiddleware("query", "bazarr"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.bazarr; if (ctx.integration.kind === "mock") return mockWidgetData.bazarr;
+6 -6
View File
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { LiveStatsEvent } from "@homarr/integrations/types"; import type { LiveStatsEvent } from "@homarr/integrations/types";
import { import {
beszelAlertsRequestHandler, beszelAlertsRequestHandler,
@@ -11,7 +11,7 @@ import {
} from "@homarr/request-handler/beszel"; } from "@homarr/request-handler/beszel";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
import { BoundedAsyncQueue } from "./bounded-async-queue"; import { BoundedAsyncQueue } from "./bounded-async-queue";
@@ -27,7 +27,7 @@ export const beszelRouter = createTRPCRouter({
"Get all Beszel-monitored systems with CPU, memory, disk, GPU, network, temperature, and status. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all)", "Get all Beszel-monitored systems with CPU, memory, disk, GPU, network, temperature, and status. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all)",
}, },
}) })
.concat(createManyIntegrationMiddleware("query", "beszel", "mock")) .concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemGrid"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const integrationIds = ctx.integrations.map((i) => i.id); const integrationIds = ctx.integrations.map((i) => i.id);
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds }); logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
@@ -71,7 +71,7 @@ export const beszelRouter = createTRPCRouter({
"Get Beszel alerts and optional alert history for all monitored systems. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all). OPTIONAL: includeHistory (default true), maxHistoryItems (default 10)", "Get Beszel alerts and optional alert history for all monitored systems. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all). OPTIONAL: includeHistory (default true), maxHistoryItems (default 10)",
}, },
}) })
.concat(createManyIntegrationMiddleware("query", "beszel", "mock")) .concat(createManyWidgetIntegrationMiddleware("query", "beszelAlerts"))
.input( .input(
z.object({ z.object({
includeHistory: z.boolean().default(true), includeHistory: z.boolean().default(true),
@@ -145,7 +145,7 @@ export const beszelRouter = createTRPCRouter({
"Get historical Beszel system metrics (CPU, memory, disk, network, temperature) and optional Docker container stats. REQUIRED: integrationIds (pass the single integrationId from the beszel_getSystems entry containing the target system — only the first ID is used), systemId (from beszel_getSystems). OPTIONAL: timePeriod (1m/1h/12h/24h/1w/30d, default 1h), includeDocker (default true)", "Get historical Beszel system metrics (CPU, memory, disk, network, temperature) and optional Docker container stats. REQUIRED: integrationIds (pass the single integrationId from the beszel_getSystems entry containing the target system — only the first ID is used), systemId (from beszel_getSystems). OPTIONAL: timePeriod (1m/1h/12h/24h/1w/30d, default 1h), includeDocker (default true)",
}, },
}) })
.concat(createManyIntegrationMiddleware("query", "beszel", "mock")) .concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats"))
.input( .input(
z.object({ z.object({
systemId: z.string(), systemId: z.string(),
@@ -196,7 +196,7 @@ export const beszelRouter = createTRPCRouter({
}), }),
subscribeSystemStats: publicProcedure subscribeSystemStats: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel", "mock")) .concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats"))
.input( .input(
z.object({ z.object({
systemId: z.string(), systemId: z.string(),
+3 -4
View File
@@ -1,10 +1,9 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { radarrReleaseTypes } from "@homarr/integrations/types"; import { radarrReleaseTypes } from "@homarr/integrations/types";
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar"; import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -25,7 +24,7 @@ export const calendarRouter = createTRPCRouter({
showUnmonitored: z.boolean(), showUnmonitored: z.boolean(),
}), }),
) )
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar"))) .concat(createManyWidgetIntegrationMiddleware("query", "calendar"))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
return await settleIntegrationQueries( return await settleIntegrationQueries(
ctx.integrations, ctx.integrations,
@@ -44,7 +43,7 @@ export const calendarRouter = createTRPCRouter({
name: integration.name, name: integration.name,
kind: integration.kind, kind: integration.kind,
}, },
error: undefined as string | undefined, error: undefined,
}; };
}, },
{ {
+2 -2
View File
@@ -2,7 +2,7 @@ import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
import { mockWidgetData } from "@homarr/integrations"; import { mockWidgetData } from "@homarr/integrations";
import { coolifyRequestHandler } from "@homarr/request-handler/coolify"; import { coolifyRequestHandler } from "@homarr/request-handler/coolify";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -17,7 +17,7 @@ interface CoolifyInstanceResult {
export const coolifyRouter = createTRPCRouter({ export const coolifyRouter = createTRPCRouter({
getInstancesInfo: publicProcedure getInstancesInfo: publicProcedure
.concat(createManyIntegrationMiddleware("query", "coolify", "mock")) .concat(createManyWidgetIntegrationMiddleware("query", "coolify"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await settleIntegrationQueries( return await settleIntegrationQueries(
ctx.integrations, ctx.integrations,
+10 -6
View File
@@ -1,12 +1,14 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions"; import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations/factory";
import { createIntegrationAsync } from "@homarr/integrations";
import type { DnsHoleSummary } from "@homarr/integrations/types"; import type { DnsHoleSummary } from "@homarr/integrations/types";
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole"; import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import {
createManyWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
@@ -32,7 +34,7 @@ export const dnsHoleRouter = createTRPCRouter({
"Get DNS blocking statistics from Pi-hole/AdGuard (queries, blocked, percentage). REQUIRED: integrationIds (array of Pi-hole/AdGuard integration IDs from integration_all)", "Get DNS blocking statistics from Pi-hole/AdGuard (queries, blocked, percentage). REQUIRED: integrationIds (array of Pi-hole/AdGuard integration IDs from integration_all)",
}, },
}) })
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole"))) .concat(createManyWidgetIntegrationMiddleware("query", "dnsHoleSummary"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DnsHoleSummaryResult>( return await settleIntegrationQueries<(typeof ctx.integrations)[number], DnsHoleSummaryResult>(
ctx.integrations, ctx.integrations,
@@ -66,10 +68,11 @@ export const dnsHoleRouter = createTRPCRouter({
"Enable DNS blocking on Pi-hole/AdGuard. REQUIRED: integrationId (single Pi-hole/AdGuard integration ID from integration_all)", "Enable DNS blocking on Pi-hole/AdGuard. REQUIRED: integrationId (single Pi-hole/AdGuard integration ID from integration_all)",
}, },
}) })
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole"))) .concat(createOneWidgetIntegrationMiddleware("interact", "dnsHoleControls"))
.mutation(async ({ ctx: { integration } }) => { .mutation(async ({ ctx: { integration } }) => {
const client = await createIntegrationAsync(integration); const client = await createIntegrationAsync(integration);
await client.enableAsync(); await client.enableAsync();
await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]);
}), }),
disable: protectedProcedure disable: protectedProcedure
@@ -85,9 +88,10 @@ export const dnsHoleRouter = createTRPCRouter({
duration: z.number().optional(), duration: z.number().optional(),
}), }),
) )
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole"))) .concat(createOneWidgetIntegrationMiddleware("interact", "dnsHoleControls"))
.mutation(async ({ ctx: { integration }, input }) => { .mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration); const client = await createIntegrationAsync(integration);
await client.disableAsync(input.duration); await client.disableAsync(input.duration);
await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]);
}), }),
}); });
+27 -23
View File
@@ -1,19 +1,15 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions"; import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations"; import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations"; import { downloadClientItemSchema } from "@homarr/integrations/downloads";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads"; import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
import type { IntegrationAction } from "../../middlewares/integration"; import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("downloadClient"));
interface DownloadClientQueryResultBase { interface DownloadClientQueryResultBase {
integrationId: string; integrationId: string;
integrationName: string; integrationName: string;
@@ -42,6 +38,14 @@ type DownloadClientQueryResult = DownloadClientQueryResultBase &
} }
); );
const runDownloadMutationsAsync = async (mutations: Promise<void>[], integrationIds: string[]) => {
const results = await Promise.allSettled(mutations);
await downloadClientRequestHandler.invalidateCacheAsync(integrationIds);
const failure = results.find((result) => result.status === "rejected");
if (failure) throw failure.reason;
};
export const downloadsRouter = createTRPCRouter({ export const downloadsRouter = createTRPCRouter({
getJobsAndStatuses: publicProcedure getJobsAndStatuses: publicProcedure
.meta({ .meta({
@@ -51,7 +55,7 @@ export const downloadsRouter = createTRPCRouter({
"Get active download jobs and queue status from connected download clients (qBittorrent, SABnzbd, Transmission, Deluge, NZBGet). REQUIRED: integrationIds (array of download client integration IDs from integration_all). OPTIONAL: limitPerIntegration (number, default 50)", "Get active download jobs and queue status from connected download clients (qBittorrent, SABnzbd, Transmission, Deluge, NZBGet). REQUIRED: integrationIds (array of download client integration IDs from integration_all). OPTIONAL: limitPerIntegration (number, default 50)",
}, },
}) })
.concat(createDownloadClientIntegrationMiddleware("query")) .concat(createManyWidgetIntegrationMiddleware("query", "downloads"))
.input(z.object({ limitPerIntegration: z.number().default(50) })) .input(z.object({ limitPerIntegration: z.number().default(50) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DownloadClientQueryResult>( return await settleIntegrationQueries<(typeof ctx.integrations)[number], DownloadClientQueryResult>(
@@ -86,27 +90,27 @@ export const downloadsRouter = createTRPCRouter({
"Pause all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)", "Pause all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
}, },
}) })
.concat(createDownloadClientIntegrationMiddleware("interact")) .concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.mutation(async ({ ctx }) => { .mutation(async ({ ctx }) => {
await Promise.all( await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => { ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration); const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.pauseQueueAsync(); await integrationInstance.pauseQueueAsync();
}), }),
ctx.integrations.map(({ id }) => id),
); );
downloadClientRequestHandler.invalidateCache();
}), }),
pauseItem: protectedProcedure pauseItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact")) .concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.input(z.object({ item: downloadClientItemSchema })) .input(z.object({ item: downloadClientItemSchema }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await Promise.all( await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => { ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration); const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.pauseItemAsync(input.item); await integrationInstance.pauseItemAsync(input.item);
}), }),
ctx.integrations.map(({ id }) => id),
); );
downloadClientRequestHandler.invalidateCache();
}), }),
resume: protectedProcedure resume: protectedProcedure
.meta({ .meta({
@@ -116,38 +120,38 @@ export const downloadsRouter = createTRPCRouter({
"Resume all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)", "Resume all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
}, },
}) })
.concat(createDownloadClientIntegrationMiddleware("interact")) .concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.mutation(async ({ ctx }) => { .mutation(async ({ ctx }) => {
await Promise.all( await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => { ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration); const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.resumeQueueAsync(); await integrationInstance.resumeQueueAsync();
}), }),
ctx.integrations.map(({ id }) => id),
); );
downloadClientRequestHandler.invalidateCache();
}), }),
resumeItem: protectedProcedure resumeItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact")) .concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.input(z.object({ item: downloadClientItemSchema })) .input(z.object({ item: downloadClientItemSchema }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await Promise.all( await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => { ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration); const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.resumeItemAsync(input.item); await integrationInstance.resumeItemAsync(input.item);
}), }),
ctx.integrations.map(({ id }) => id),
); );
downloadClientRequestHandler.invalidateCache();
}), }),
deleteItem: protectedProcedure deleteItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact")) .concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.input(z.object({ item: downloadClientItemSchema, fromDisk: z.boolean() })) .input(z.object({ item: downloadClientItemSchema, fromDisk: z.boolean() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await Promise.all( await runDownloadMutationsAsync(
ctx.integrations.map(async (integration) => { ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration); const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.deleteItemAsync(input.item, input.fromDisk); await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
}), }),
ctx.integrations.map(({ id }) => id),
); );
downloadClientRequestHandler.invalidateCache();
}), }),
}); });
+23 -24
View File
@@ -1,5 +1,3 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { mockWidgetData } from "@homarr/integrations"; import { mockWidgetData } from "@homarr/integrations";
import { import {
firewallCpuRequestHandler, firewallCpuRequestHandler,
@@ -8,36 +6,37 @@ import {
firewallVersionRequestHandler, firewallVersionRequestHandler,
} from "@homarr/request-handler/firewall"; } from "@homarr/request-handler/firewall";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
const firewallMiddleware = createManyIntegrationMiddleware( const firewallMiddleware = createManyWidgetIntegrationMiddleware("query", "firewall");
"query",
...getIntegrationKindsByCategory("firewall"),
"mock",
);
interface FirewallResult<TSummary> { type FirewallIntegration = Parameters<typeof firewallCpuRequestHandler.handler>[0];
integration: { id: string; name: string; kind: IntegrationKind; updatedAt: Date };
summary: TSummary; interface FirewallHandler<TData> {
error?: string; handler: (
integration: FirewallIntegration,
input: Record<string, never>,
) => { getDataAsync: () => Promise<{ data: TData; timestamp: Date }> };
} }
const queryFirewall = <TSummary>( interface FirewallQueryResult<TData> {
handler: { integration: {
handler: ( id: string;
integration: any, name: string;
input: Record<string, never>, kind: FirewallIntegration["kind"] | "mock";
) => { getDataAsync: () => Promise<{ data: TSummary; timestamp: Date }> }; updatedAt: Date;
}, };
createFallback: () => TSummary, summary: TData;
getMockData: () => TSummary, error: string | undefined;
) => }
const queryFirewall = <TData>(handler: FirewallHandler<TData>, createFallback: () => TData, getMockData: () => TData) =>
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) => publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
settleIntegrationQueries( settleIntegrationQueries(
ctx.integrations, ctx.integrations,
async (integration): Promise<FirewallResult<TSummary>> => { async (integration): Promise<FirewallQueryResult<TData>> => {
if (integration.kind === "mock") { if (integration.kind === "mock") {
return { return {
integration: { integration: {
@@ -58,7 +57,7 @@ const queryFirewall = <TSummary>(
}; };
}, },
{ {
fallback: (integration, error): FirewallResult<TSummary> => ({ fallback: (integration, error): FirewallQueryResult<TData> => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) }, integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) },
summary: createFallback(), summary: createFallback(),
error: toPublicIntegrationError(error), error: toPublicIntegrationError(error),
@@ -1,22 +1,12 @@
import { createIntegrationAsync } from "@homarr/integrations"; import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { SystemHealthMonitoring } from "@homarr/integrations"; import type { SystemHealthMonitoring } from "@homarr/integrations";
import { SynologyIntegration } from "@homarr/integrations"; import { healthMonitoringClusterIntegrationKinds, healthMonitoringSystemIntegrationKinds } from "@homarr/definitions";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
const healthMonitoringIntegrationKinds = [
"openmediavault",
"dashDot",
"truenas",
"unraid",
"glances",
"synology",
"mock",
] as const;
interface SystemHealthQueryResult { interface SystemHealthQueryResult {
integrationId: string; integrationId: string;
integrationName: string; integrationName: string;
@@ -34,7 +24,7 @@ export const healthMonitoringRouter = createTRPCRouter({
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Synology/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)", "Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Synology/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
}, },
}) })
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds)) .concat(createManyIntegrationMiddleware("query", ...healthMonitoringSystemIntegrationKinds))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], SystemHealthQueryResult>( return await settleIntegrationQueries<(typeof ctx.integrations)[number], SystemHealthQueryResult>(
ctx.integrations, ctx.integrations,
@@ -69,10 +59,6 @@ export const healthMonitoringRouter = createTRPCRouter({
.concat(createOneIntegrationMiddleware("query", "synology")) .concat(createOneIntegrationMiddleware("query", "synology"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const integrationInstance = await createIntegrationAsync(ctx.integration); const integrationInstance = await createIntegrationAsync(ctx.integration);
if (!(integrationInstance instanceof SynologyIntegration)) {
throw new Error("Expected Synology integration");
}
return await integrationInstance.listStorageVolumesAsync(); return await integrationInstance.listStorageVolumesAsync();
}), }),
getClusterHealthStatus: publicProcedure getClusterHealthStatus: publicProcedure
@@ -83,7 +69,7 @@ export const healthMonitoringRouter = createTRPCRouter({
"Get Proxmox cluster health status including nodes, VMs, and resource usage. REQUIRED: integrationId (single Proxmox integration ID from integration_all)", "Get Proxmox cluster health status including nodes, VMs, and resource usage. REQUIRED: integrationId (single Proxmox integration ID from integration_all)",
}, },
}) })
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock")) .concat(createOneIntegrationMiddleware("query", ...healthMonitoringClusterIntegrationKinds))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {}); const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getDataAsync(); const { data } = await innerHandler.getDataAsync();
+11 -14
View File
@@ -1,6 +1,5 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mockWidgetData } from "@homarr/integrations"; import { mockWidgetData } from "@homarr/integrations";
import { import {
immichAlbumRequestHandler, immichAlbumRequestHandler,
@@ -8,23 +7,21 @@ import {
immichStatsRequestHandler, immichStatsRequestHandler,
} from "@homarr/request-handler/immich"; } from "@homarr/request-handler/immich";
import type { IntegrationAction } from "../../middlewares/integration"; import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
const createImmichIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("photoService"), "mock");
export const immichRouter = createTRPCRouter({ export const immichRouter = createTRPCRouter({
getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => { getServerStats: publicProcedure
if (ctx.integration.kind === "mock") return mockWidgetData.immichStats; .concat(createOneWidgetIntegrationMiddleware("query", "immich-serverStats"))
const innerHandler = immichStatsRequestHandler.handler({ ...ctx.integration, kind: "immich" }, {}); .query(async ({ ctx }) => {
const data = await innerHandler.getDataAsync(); if (ctx.integration.kind === "mock") return mockWidgetData.immichStats;
return data.data; const innerHandler = immichStatsRequestHandler.handler({ ...ctx.integration, kind: "immich" }, {});
}), const data = await innerHandler.getDataAsync();
return data.data;
}),
getAlbum: publicProcedure getAlbum: publicProcedure
.concat(createImmichIntegrationMiddleware("query")) .concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
.input( .input(
z.object({ z.object({
albumId: z.string().optional(), albumId: z.string().optional(),
@@ -48,7 +45,7 @@ export const immichRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500).optional(), limit: z.number().int().min(1).max(500).optional(),
}), }),
) )
.concat(createImmichIntegrationMiddleware("query")) .concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") { if (ctx.integration.kind === "mock") {
if (input.limit === undefined) return [...mockWidgetData.immichAlbums]; if (input.limit === undefined) return [...mockWidgetData.immichAlbums];
@@ -1,20 +1,15 @@
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations/factory";
import { createIntegrationAsync } from "@homarr/integrations";
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager"; import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
import type { IntegrationAction } from "../../middlewares/integration"; import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("indexerManager"));
export const indexerManagerRouter = createTRPCRouter({ export const indexerManagerRouter = createTRPCRouter({
getIndexersStatus: publicProcedure getIndexersStatus: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query")) .concat(createManyWidgetIntegrationMiddleware("query", "indexerManager"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await settleIntegrationQueries( return await settleIntegrationQueries(
ctx.integrations, ctx.integrations,
@@ -26,7 +21,7 @@ export const indexerManagerRouter = createTRPCRouter({
integrationId: integration.id, integrationId: integration.id,
integrationName: integration.name, integrationName: integration.name,
indexers, indexers,
error: undefined as string | undefined, error: undefined,
}; };
}, },
{ {
@@ -41,9 +36,9 @@ export const indexerManagerRouter = createTRPCRouter({
); );
}), }),
testAllIndexers: protectedProcedure testAllIndexers: protectedProcedure
.concat(createIndexerManagerIntegrationMiddleware("interact")) .concat(createManyWidgetIntegrationMiddleware("interact", "indexerManager"))
.mutation(async ({ ctx }) => { .mutation(async ({ ctx }) => {
await Promise.all( const results = await Promise.allSettled(
ctx.integrations.map(async (integration) => { ctx.integrations.map(async (integration) => {
const client = await createIntegrationAsync(integration); const client = await createIntegrationAsync(integration);
await client.testAllAsync().catch((err) => { await client.testAllAsync().catch((err) => {
@@ -55,5 +50,9 @@ export const indexerManagerRouter = createTRPCRouter({
}); });
}), }),
); );
await indexerManagerRequestHandler.invalidateCacheAsync(ctx.integrations.map(({ id }) => id));
const firstFailure = results.find((result) => result.status === "rejected");
if (firstFailure?.status === "rejected") throw firstFailure.reason;
}), }),
}); });
@@ -1,9 +1,8 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer"; import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -16,7 +15,7 @@ export const mediaOrganizerRouter = createTRPCRouter({
"Get missing and queued movies/episodes from Radarr and Sonarr. Requires query (use) access to each integration. REQUIRED: integrationIds (array of Radarr/Sonarr integration IDs from integration_all). OPTIONAL: pageSize (1-50, default 10)", "Get missing and queued movies/episodes from Radarr and Sonarr. Requires query (use) access to each integration. REQUIRED: integrationIds (array of Radarr/Sonarr integration IDs from integration_all). OPTIONAL: pageSize (1-50, default 10)",
}, },
}) })
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer"))) .concat(createManyWidgetIntegrationMiddleware("query", "mediaMissing"))
.input(z.object({ pageSize: z.number().min(1).max(50).default(10) })) .input(z.object({ pageSize: z.number().min(1).max(50).default(10) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
return await settleIntegrationQueries( return await settleIntegrationQueries(
@@ -28,7 +27,7 @@ export const mediaOrganizerRouter = createTRPCRouter({
integrationId: integration.id, integrationId: integration.id,
integrationName: integration.name, integrationName: integration.name,
...data, ...data,
error: undefined as string | undefined, error: undefined,
}; };
}, },
{ {
@@ -1,13 +1,12 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release"; import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
import { getMediaReleaseResponse } from "./media-release-result"; import { getMediaReleaseResponse } from "./media-release-result";
export const mediaReleaseRouter = createTRPCRouter({ export const mediaReleaseRouter = createTRPCRouter({
getMediaReleases: publicProcedure getMediaReleases: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease"))) .concat(createManyWidgetIntegrationMiddleware("query", "mediaReleases"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await getMediaReleaseResponse(ctx.integrations, async (integration) => { return await getMediaReleaseResponse(ctx.integrations, async (integration) => {
const innerHandler = mediaReleaseRequestHandler.handler(integration, {}); const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
@@ -1,8 +1,7 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions"; import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations/factory";
import { createIntegrationAsync } from "@homarr/integrations";
import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types"; import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types";
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types"; import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import { import {
@@ -11,7 +10,10 @@ import {
} from "@homarr/request-handler/media-request-list"; } from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import {
createManyWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
@@ -30,7 +32,7 @@ export const mediaRequestsRouter = createTRPCRouter({
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).", "Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).",
}, },
}) })
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .concat(createManyWidgetIntegrationMiddleware("query", "mediaRequests-requestList"))
.input(mediaRequestListInputSchema) .input(mediaRequestListInputSchema)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const results: IntegrationQueryResult<MediaRequest[]>[] = await settleIntegrationQueries( const results: IntegrationQueryResult<MediaRequest[]>[] = await settleIntegrationQueries(
@@ -40,7 +42,7 @@ export const mediaRequestsRouter = createTRPCRouter({
return { return {
integration: { id: integration.id, name: integration.name, kind: integration.kind }, integration: { id: integration.id, name: integration.name, kind: integration.kind },
data, data,
error: undefined as string | undefined, error: undefined,
}; };
}, },
{ {
@@ -85,7 +87,7 @@ export const mediaRequestsRouter = createTRPCRouter({
"Get media request statistics including total counts and top requesters. REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)", "Get media request statistics including total counts and top requesters. REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
}, },
}) })
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .concat(createManyWidgetIntegrationMiddleware("query", "mediaRequests-requestStats"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const results: IntegrationQueryResult<{ const results: IntegrationQueryResult<{
stats: MediaRequestStats["stats"] | null; stats: MediaRequestStats["stats"] | null;
@@ -96,8 +98,8 @@ export const mediaRequestsRouter = createTRPCRouter({
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync(); const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
return { return {
integration: { id: integration.id, name: integration.name, kind: integration.kind }, integration: { id: integration.id, name: integration.name, kind: integration.kind },
data: { ...data, stats: data.stats as typeof data.stats | null }, data,
error: undefined as string | undefined, error: undefined,
}; };
}, },
{ {
@@ -134,7 +136,7 @@ export const mediaRequestsRouter = createTRPCRouter({
"Approve or decline a pending media request. REQUIRED: integrationId (single Overseerr/Jellyseerr integration ID), requestId (number from getLatestRequests), answer ('approve' or 'decline')", "Approve or decline a pending media request. REQUIRED: integrationId (single Overseerr/Jellyseerr integration ID), requestId (number from getLatestRequests), answer ('approve' or 'decline')",
}, },
}) })
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest"))) .concat(createOneWidgetIntegrationMiddleware("interact", "mediaRequests-requestList"))
.input( .input(
z.object({ z.object({
requestId: z.number(), requestId: z.number(),
@@ -150,6 +152,8 @@ export const mediaRequestsRouter = createTRPCRouter({
} as const; } as const;
await answerActions[input.answer](input.requestId); await answerActions[input.answer](input.requestId);
mediaRequestListRequestHandler.invalidateCache();
await mediaRequestStatsRequestHandler.invalidateCacheAsync([integration.id]);
return { success: true }; return { success: true };
}), }),
}); });
@@ -1,16 +1,11 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server"; import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
import type { IntegrationAction } from "../../middlewares/integration"; import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaService"));
export const mediaServerRouter = createTRPCRouter({ export const mediaServerRouter = createTRPCRouter({
getCurrentStreams: publicProcedure getCurrentStreams: publicProcedure
.meta({ .meta({
@@ -20,7 +15,7 @@ export const mediaServerRouter = createTRPCRouter({
"Get currently active streams from Plex/Jellyfin/Emby/Navidrome media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)", "Get currently active streams from Plex/Jellyfin/Emby/Navidrome media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)",
}, },
}) })
.concat(createMediaServerIntegrationMiddleware("query")) .concat(createManyWidgetIntegrationMiddleware("query", "mediaServer"))
.input(z.object({ showOnlyPlaying: z.boolean() })) .input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
return await settleIntegrationQueries( return await settleIntegrationQueries(
@@ -34,7 +29,7 @@ export const mediaServerRouter = createTRPCRouter({
integrationName: integration.name, integrationName: integration.name,
integrationKind: integration.kind, integrationKind: integration.kind,
sessions: data, sessions: data,
error: undefined as string | undefined, error: undefined,
}; };
}, },
{ {
@@ -1,17 +1,12 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding"; import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
import { paginatedSchema } from "@homarr/validation/common"; import { paginatedSchema } from "@homarr/validation/common";
import type { IntegrationAction } from "../../middlewares/integration"; import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaTranscoding"));
export const mediaTranscodingRouter = createTRPCRouter({ export const mediaTranscodingRouter = createTRPCRouter({
getDataAsync: publicProcedure getDataAsync: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query")) .concat(createOneWidgetIntegrationMiddleware("query", "mediaTranscoding"))
.input(paginatedSchema.pick({ page: true, pageSize: true })) .input(paginatedSchema.pick({ page: true, pageSize: true }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, { const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, {
@@ -1,9 +1,8 @@
import type { IntegrationKindByCategory } from "@homarr/definitions"; import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { NetworkControllerSummary } from "@homarr/integrations/types"; import type { NetworkControllerSummary } from "@homarr/integrations/types";
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller"; import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -22,7 +21,7 @@ interface NetworkControllerQueryResult {
export const networkControllerRouter = createTRPCRouter({ export const networkControllerRouter = createTRPCRouter({
summary: publicProcedure summary: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController"))) .concat(createManyWidgetIntegrationMiddleware("query", "networkControllerSummary"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], NetworkControllerQueryResult>( return await settleIntegrationQueries<(typeof ctx.integrations)[number], NetworkControllerQueryResult>(
ctx.integrations, ctx.integrations,
@@ -1,17 +1,12 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { notificationsRequestHandler } from "@homarr/request-handler/notifications"; import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
import type { IntegrationAction } from "../../middlewares/integration"; import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("notifications"));
export const notificationsRouter = createTRPCRouter({ export const notificationsRouter = createTRPCRouter({
getNotifications: publicProcedure getNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query")) .unstable_concat(createManyWidgetIntegrationMiddleware("query", "notifications"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await settleIntegrationQueries( return await settleIntegrationQueries(
ctx.integrations, ctx.integrations,
@@ -27,7 +22,7 @@ export const notificationsRouter = createTRPCRouter({
updatedAt: timestamp, updatedAt: timestamp,
}, },
data, data,
error: undefined as string | undefined, error: undefined,
}; };
}, },
{ {
@@ -1,12 +1,12 @@
import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx"; import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx";
import { mockWidgetData } from "@homarr/integrations"; import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const paperlessNgxRouter = createTRPCRouter({ export const paperlessNgxRouter = createTRPCRouter({
getStats: publicProcedure getStats: publicProcedure
.concat(createOneIntegrationMiddleware("query", "paperlessNgx", "mock")) .concat(createOneWidgetIntegrationMiddleware("query", "paperlessNgx"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.paperlessNgx; if (ctx.integration.kind === "mock") return mockWidgetData.paperlessNgx;
const innerHandler = paperlessNgxStatsRequestHandler.handler({ ...ctx.integration, kind: "paperlessNgx" }, {}); const innerHandler = paperlessNgxStatsRequestHandler.handler({ ...ctx.integration, kind: "paperlessNgx" }, {});
+2 -2
View File
@@ -1,7 +1,7 @@
import { patchmonStatsRequestHandler } from "@homarr/request-handler/patchmon"; import { patchmonStatsRequestHandler } from "@homarr/request-handler/patchmon";
import { mockWidgetData } from "@homarr/integrations"; import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure } from "../../trpc"; import { createTRPCRouter, protectedProcedure } from "../../trpc";
export const patchmonRouter = createTRPCRouter({ export const patchmonRouter = createTRPCRouter({
@@ -13,7 +13,7 @@ export const patchmonRouter = createTRPCRouter({
"Get PatchMon host patch statistics including total hosts, hosts needing updates, security update counts, up-to-date hosts, outdated packages, repositories, and OS distribution. REQUIRED: integrationId (single PatchMon integration ID from integration_all). Requires authenticated session (API key or browser login) and integration query access.", "Get PatchMon host patch statistics including total hosts, hosts needing updates, security update counts, up-to-date hosts, outdated packages, repositories, and OS distribution. REQUIRED: integrationId (single PatchMon integration ID from integration_all). Requires authenticated session (API key or browser login) and integration query access.",
}, },
}) })
.concat(createOneIntegrationMiddleware("query", "patchmon", "mock")) .concat(createOneWidgetIntegrationMiddleware("query", "patchmon"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.patchmon; if (ctx.integration.kind === "mock") return mockWidgetData.patchmon;
const innerHandler = patchmonStatsRequestHandler.handler({ ...ctx.integration, kind: "patchmon" }, {}); const innerHandler = patchmonStatsRequestHandler.handler({ ...ctx.integration, kind: "patchmon" }, {});
+8 -11
View File
@@ -1,20 +1,15 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations/factory";
import { createIntegrationAsync } from "@homarr/integrations";
import { import {
smartHomeEntityStateRequestHandler, smartHomeEntityStateRequestHandler,
toSafeEntityDetails, toSafeEntityDetails,
} from "@homarr/request-handler/smart-home-entity-state"; } from "@homarr/request-handler/smart-home-entity-state";
import type { IntegrationAction } from "../../middlewares/integration"; import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("smartHomeServer"));
export const smartHomeRouter = createTRPCRouter({ export const smartHomeRouter = createTRPCRouter({
entityState: publicProcedure entityState: publicProcedure
.meta({ .meta({
@@ -25,7 +20,7 @@ export const smartHomeRouter = createTRPCRouter({
}, },
}) })
.input(z.object({ entityId: z.string() })) .input(z.object({ entityId: z.string() }))
.concat(createSmartHomeIntegrationMiddleware("query")) .concat(createOneWidgetIntegrationMiddleware("query", "smartHome-entityState"))
.query(async ({ ctx: { integration }, input }) => { .query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId }); const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getDataAsync(); const { data } = await innerHandler.getDataAsync();
@@ -40,7 +35,7 @@ export const smartHomeRouter = createTRPCRouter({
}, },
}) })
.input(z.object({ entityId: z.string() })) .input(z.object({ entityId: z.string() }))
.concat(createSmartHomeIntegrationMiddleware("query")) .concat(createOneWidgetIntegrationMiddleware("query", "smartHome-entityState"))
.query(async ({ ctx: { integration }, input }) => { .query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId }); const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getDataAsync(); const { data } = await innerHandler.getDataAsync();
@@ -54,7 +49,7 @@ export const smartHomeRouter = createTRPCRouter({
"Toggle a Home Assistant entity (turn on/off a light, switch, etc.). REQUIRED: integrationId (Home Assistant integration ID from integration_all), entityId (e.g. 'light.living_room')", "Toggle a Home Assistant entity (turn on/off a light, switch, etc.). REQUIRED: integrationId (Home Assistant integration ID from integration_all), entityId (e.g. 'light.living_room')",
}, },
}) })
.concat(createSmartHomeIntegrationMiddleware("interact")) .concat(createOneWidgetIntegrationMiddleware("interact", "smartHome-entityState"))
.input(z.object({ entityId: z.string() })) .input(z.object({ entityId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => { .mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration); const client = await createIntegrationAsync(integration);
@@ -62,6 +57,7 @@ export const smartHomeRouter = createTRPCRouter({
if (!success) { if (!success) {
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not toggle the entity" }); throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not toggle the entity" });
} }
await smartHomeEntityStateRequestHandler.invalidateCacheAsync([integration.id]);
return true; return true;
}), }),
executeAutomation: protectedProcedure executeAutomation: protectedProcedure
@@ -72,7 +68,7 @@ export const smartHomeRouter = createTRPCRouter({
"Trigger a Home Assistant automation by its ID. REQUIRED: integrationId (Home Assistant integration ID from integration_all), automationId (the automation entity ID)", "Trigger a Home Assistant automation by its ID. REQUIRED: integrationId (Home Assistant integration ID from integration_all), automationId (the automation entity ID)",
}, },
}) })
.concat(createSmartHomeIntegrationMiddleware("interact")) .concat(createOneWidgetIntegrationMiddleware("interact", "smartHome-executeAutomation"))
.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);
@@ -80,6 +76,7 @@ export const smartHomeRouter = createTRPCRouter({
if (!success) { if (!success) {
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not execute the automation" }); throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not execute the automation" });
} }
await smartHomeEntityStateRequestHandler.invalidateCacheAsync([integration.id]);
return true; return true;
}), }),
}); });
@@ -1,13 +1,13 @@
import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker"; import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
import { mockWidgetData } from "@homarr/integrations"; import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const speedtestTrackerRouter = createTRPCRouter({ export const speedtestTrackerRouter = createTRPCRouter({
getDashboard: publicProcedure getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "speedtestTracker", "mock")) .concat(createManyWidgetIntegrationMiddleware("query", "speedtestTracker"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await settleIntegrationQueries( return await settleIntegrationQueries(
ctx.integrations, ctx.integrations,
@@ -29,9 +29,9 @@ export const speedtestTrackerRouter = createTRPCRouter({
integrationId: integration.id, integrationId: integration.id,
integrationName: integration.name, integrationName: integration.name,
integrationUrl: integration.url, integrationUrl: integration.url,
dashboard: data as typeof data | null, dashboard: data,
updatedAt: timestamp, updatedAt: timestamp,
error: undefined as string | undefined, error: undefined,
}; };
}, },
{ {
+2 -2
View File
@@ -1,13 +1,13 @@
import { tracearrRequestHandler } from "@homarr/request-handler/tracearr"; import { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
import { mockWidgetData } from "@homarr/integrations"; import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const tracearrRouter = createTRPCRouter({ export const tracearrRouter = createTRPCRouter({
getDashboard: publicProcedure getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "tracearr", "mock")) .concat(createManyWidgetIntegrationMiddleware("query", "tracearr"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await settleIntegrationQueries( return await settleIntegrationQueries(
ctx.integrations, ctx.integrations,
+4 -4
View File
@@ -1,7 +1,7 @@
import { traefikRequestHandler } from "@homarr/request-handler/traefik"; import { traefikRequestHandler } from "@homarr/request-handler/traefik";
import { mockWidgetData } from "@homarr/integrations"; import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -14,7 +14,7 @@ export const traefikRouter = createTRPCRouter({
"Retrieve Traefik dashboard data, including routers, services, middlewares, and entry points, for the given integration IDs.", "Retrieve Traefik dashboard data, including routers, services, middlewares, and entry points, for the given integration IDs.",
}, },
}) })
.concat(createManyIntegrationMiddleware("query", "traefik", "mock")) .concat(createManyWidgetIntegrationMiddleware("query", "traefik"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await settleIntegrationQueries( return await settleIntegrationQueries(
ctx.integrations, ctx.integrations,
@@ -36,9 +36,9 @@ export const traefikRouter = createTRPCRouter({
integrationId: integration.id, integrationId: integration.id,
integrationName: integration.name, integrationName: integration.name,
integrationUrl: integration.url, integrationUrl: integration.url,
dashboard: data as typeof data | null, dashboard: data,
updatedAt: timestamp, updatedAt: timestamp,
error: undefined as string | undefined, error: undefined,
}; };
}, },
{ {
+22 -20
View File
@@ -2,7 +2,8 @@ import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs"; import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createIntegrationAsync, mockWidgetData } from "@homarr/integrations"; import { mockWidgetData } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { import {
umamiActiveVisitorsRequestHandler, umamiActiveVisitorsRequestHandler,
umamiEventNamesRequestHandler, umamiEventNamesRequestHandler,
@@ -12,25 +13,26 @@ import {
umamiTopReferrersRequestHandler, umamiTopReferrersRequestHandler,
} from "@homarr/request-handler/umami"; } from "@homarr/request-handler/umami";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import {
createManyWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations"; import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "umami-router" }); const logger = createLogger({ module: "umami-router" });
export const umamiRouter = createTRPCRouter({ export const umamiRouter = createTRPCRouter({
getWebsites: publicProcedure getWebsites: publicProcedure.concat(createOneWidgetIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => {
.concat(createOneIntegrationMiddleware("query", "umami", "mock")) if (ctx.integration.kind === "mock") return mockWidgetData.umamiWebsites;
.query(async ({ ctx }) => { try {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiWebsites; const instance = await createIntegrationAsync({ ...ctx.integration, kind: "umami" });
try { return await instance.getWebsitesAsync();
const instance = await createIntegrationAsync({ ...ctx.integration, kind: "umami" }); } catch (error) {
return await instance.getWebsitesAsync(); logger.warn(new Error("Failed to load websites", { cause: error }));
} catch (error) { return [];
logger.warn(new Error("Failed to load websites", { cause: error })); }
return []; }),
}
}),
getVisitorStats: publicProcedure getVisitorStats: publicProcedure
.input( .input(
@@ -40,7 +42,7 @@ export const umamiRouter = createTRPCRouter({
eventName: z.string().optional(), eventName: z.string().optional(),
}), }),
) )
.concat(createManyIntegrationMiddleware("query", "umami", "mock")) .concat(createManyWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => { return await settleIntegrationQueries(ctx.integrations, async (integration) => {
if (integration.kind === "mock") { if (integration.kind === "mock") {
@@ -74,7 +76,7 @@ export const umamiRouter = createTRPCRouter({
getEventNames: publicProcedure getEventNames: publicProcedure
.input(z.object({ websiteId: z.string() })) .input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami", "mock")) .concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiEventNames; if (ctx.integration.kind === "mock") return mockWidgetData.umamiEventNames;
try { try {
@@ -102,7 +104,7 @@ export const umamiRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500), limit: z.number().int().min(1).max(500),
}), }),
) )
.concat(createOneIntegrationMiddleware("query", "umami", "mock")) .concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopPages.slice(0, input.limit); if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopPages.slice(0, input.limit);
try { try {
@@ -136,7 +138,7 @@ export const umamiRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500), limit: z.number().int().min(1).max(500),
}), }),
) )
.concat(createOneIntegrationMiddleware("query", "umami", "mock")) .concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopReferrers.slice(0, input.limit); if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopReferrers.slice(0, input.limit);
try { try {
@@ -170,7 +172,7 @@ export const umamiRouter = createTRPCRouter({
eventNames: z.array(z.string()), eventNames: z.array(z.string()),
}), }),
) )
.concat(createOneIntegrationMiddleware("query", "umami", "mock")) .concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") { if (ctx.integration.kind === "mock") {
return mockWidgetData.umamiEventSeries.filter((series) => input.eventNames.includes(series.eventName)); return mockWidgetData.umamiEventSeries.filter((series) => input.eventNames.includes(series.eventName));
@@ -201,7 +203,7 @@ export const umamiRouter = createTRPCRouter({
getActiveVisitors: publicProcedure getActiveVisitors: publicProcedure
.input(z.object({ websiteId: z.string() })) .input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami", "mock")) .concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return 7; if (ctx.integration.kind === "mock") return 7;
try { try {
+29 -32
View File
@@ -1,40 +1,37 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups"; import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const upsRouter = createTRPCRouter({ export const upsRouter = createTRPCRouter({
getSummaries: publicProcedure getSummaries: publicProcedure.concat(createManyWidgetIntegrationMiddleware("query", "ups")).query(async ({ ctx }) => {
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups"))) return await settleIntegrationQueries(
.query(async ({ ctx }) => { ctx.integrations,
return await settleIntegrationQueries( async (integration) => {
ctx.integrations, const innerHandler = upsSummariesRequestHandler.handler(integration, {});
async (integration) => { const { data, timestamp } = await innerHandler.getDataAsync();
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return { return {
integrationId: integration.id, integrationId: integration.id,
integrationName: integration.name, integrationName: integration.name,
integrationUrl: integration.url, integrationUrl: integration.url,
summaries: data, summaries: data,
updatedAt: timestamp, updatedAt: timestamp,
error: undefined as string | undefined, error: undefined,
}; };
}, },
{ {
fallback: (integration, error) => ({ fallback: (integration, error) => ({
integrationId: integration.id, integrationId: integration.id,
integrationName: integration.name, integrationName: integration.name,
integrationUrl: integration.url, integrationUrl: integration.url,
summaries: [], summaries: [],
updatedAt: new Date(0), updatedAt: new Date(0),
error: toPublicIntegrationError(error), error: toPublicIntegrationError(error),
}), }),
throwOnAllFailures: true, throwOnAllFailures: true,
}, },
); );
}), }),
}); });
@@ -1,13 +1,13 @@
import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma"; import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
import { mockWidgetData } from "@homarr/integrations"; import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const uptimeKumaRouter = createTRPCRouter({ export const uptimeKumaRouter = createTRPCRouter({
getDashboard: publicProcedure getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "uptimeKuma", "mock")) .concat(createManyWidgetIntegrationMiddleware("query", "uptimeKuma"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await settleIntegrationQueries( return await settleIntegrationQueries(
ctx.integrations, ctx.integrations,
@@ -29,9 +29,9 @@ export const uptimeKumaRouter = createTRPCRouter({
integrationId: integration.id, integrationId: integration.id,
integrationName: integration.name, integrationName: integration.name,
integrationUrl: integration.url, integrationUrl: integration.url,
dashboard: data as typeof data | null, dashboard: data,
updatedAt: timestamp, updatedAt: timestamp,
error: undefined as string | undefined, error: undefined,
}; };
}, },
{ {
+4 -5
View File
@@ -1,10 +1,9 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions";
import { mockWidgetData } from "@homarr/integrations"; import { mockWidgetData } from "@homarr/integrations";
import type { VpnSummary } from "@homarr/integrations/types"; import type { VpnSummary } from "@homarr/integrations/types";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn"; import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -16,7 +15,7 @@ interface VpnResult {
export const vpnRouter = createTRPCRouter({ export const vpnRouter = createTRPCRouter({
getSummaries: publicProcedure getSummaries: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"), "mock")) .unstable_concat(createManyWidgetIntegrationMiddleware("query", "vpn"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await settleIntegrationQueries( return await settleIntegrationQueries(
ctx.integrations, ctx.integrations,
@@ -43,8 +42,8 @@ export const vpnRouter = createTRPCRouter({
kind: integration.kind, kind: integration.kind,
updatedAt: timestamp, updatedAt: timestamp,
}, },
summary: data as typeof data | null, summary: data,
error: undefined as string | undefined, error: undefined,
}; };
}, },
{ {
+2 -2
View File
@@ -1,7 +1,7 @@
import { wudStatsRequestHandler } from "@homarr/request-handler/wud"; import { wudStatsRequestHandler } from "@homarr/request-handler/wud";
import { mockWidgetData } from "@homarr/integrations"; import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const wudRouter = createTRPCRouter({ export const wudRouter = createTRPCRouter({
@@ -13,7 +13,7 @@ export const wudRouter = createTRPCRouter({
"Returns monitored-container counts and available updates from a What's Up Docker integration, or mock statistics from a mock integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.", "Returns monitored-container counts and available updates from a What's Up Docker integration, or mock statistics from a mock integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
}, },
}) })
.concat(createOneIntegrationMiddleware("query", "wud", "mock")) .concat(createOneWidgetIntegrationMiddleware("query", "wud"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") { if (ctx.integration.kind === "mock") {
return { stats: mockWidgetData.wud, updatedAt: new Date(mockWidgetData.timestamp) }; return { stats: mockWidgetData.wud, updatedAt: new Date(mockWidgetData.timestamp) };
+6 -6
View File
@@ -15,18 +15,18 @@ interface IntegrationLike {
kind: string; kind: string;
} }
interface Options<TIntegration extends IntegrationLike, TResult> { interface Options<TIntegration extends IntegrationLike, TFallback> {
fallback?: (integration: TIntegration, error: unknown) => TResult; fallback?: (integration: TIntegration, error: unknown) => TFallback;
throwOnAllFailures?: boolean; throwOnAllFailures?: boolean;
} }
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>( export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult, TFallback = TResult>(
integrations: TIntegration[], integrations: TIntegration[],
fn: (integration: TIntegration) => Promise<TResult>, fn: (integration: TIntegration) => Promise<TResult>,
options?: Options<TIntegration, TResult>, options?: Options<TIntegration, TFallback>,
): Promise<TResult[]> { ): Promise<(TResult | TFallback)[]> {
const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration))); const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
const results: TResult[] = []; const results: (TResult | TFallback)[] = [];
const errors: unknown[] = []; const errors: unknown[] = [];
settled.forEach((result, index) => { settled.forEach((result, index) => {
+12
View File
@@ -7,6 +7,7 @@ const algorithm = "aes-256-cbc"; //Using AES encryption
// We fallback to a key of 0s if the key was not provided because env validation was skipped // We fallback to a key of 0s if the key was not provided because env validation was skipped
// This should only be the case in CI // This should only be the case in CI
const key = Buffer.from(env.SECRET_ENCRYPTION_KEY || "0".repeat(64), "hex"); const key = Buffer.from(env.SECRET_ENCRYPTION_KEY || "0".repeat(64), "hex");
const fingerprintKey = crypto.createHmac("sha256", key).update("homarr-keyed-fingerprint-v1").digest();
export function encryptSecret(text: string): `${string}.${string}` { export function encryptSecret(text: string): `${string}.${string}` {
const initializationVector = crypto.randomBytes(16); const initializationVector = crypto.randomBytes(16);
@@ -29,3 +30,14 @@ export function decryptSecretWithKey(value: `${string}.${string}`, key: Buffer)
decrypted = Buffer.concat([decrypted, decipher.final()]); decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString(); return decrypted.toString();
} }
export function createKeyedFingerprint(value: string) {
return crypto.createHmac("sha256", fingerprintKey).update(value).digest("hex");
}
export function verifyKeyedFingerprint(value: string, fingerprint: string) {
const expected = Buffer.from(createKeyedFingerprint(value), "hex");
const actual = Buffer.from(fingerprint, "hex");
if (expected.length !== actual.length) return false;
return crypto.timingSafeEqual(expected, actual);
}
@@ -9,7 +9,7 @@ const defaultRedisOptions = {
export type { Redis as RedisClient } from "ioredis"; export type { Redis as RedisClient } from "ioredis";
export const createRedisClient = () => export const createRedisClient = (options: RedisOptions = {}) =>
redisEnv.IS_EXTERNAL redisEnv.IS_EXTERNAL
? new Redis({ ? new Redis({
...defaultRedisOptions, ...defaultRedisOptions,
@@ -23,5 +23,6 @@ export const createRedisClient = () =>
: undefined, : undefined,
username: redisEnv.USERNAME, username: redisEnv.USERNAME,
password: redisEnv.PASSWORD, password: redisEnv.PASSWORD,
...options,
}) })
: new Redis(defaultRedisOptions); : new Redis({ ...defaultRedisOptions, ...options });
+2 -2
View File
@@ -143,8 +143,8 @@ export const seedProtectedBoardLayoutsAsync = async (db: Database, boardId?: str
} }
} }
const mobileLayout = normalizedLayouts[0]; const mobileLayout = normalizedLayouts.find((layout) => layout.role === "mobile");
const baseLayout = normalizedLayouts.at(-1); const baseLayout = normalizedLayouts.find((layout) => layout.role === "base");
if (mobileLayout && baseLayout) { if (mobileLayout && baseLayout) {
await insertMissingProjectedPositionsAsync( await insertMissingProjectedPositionsAsync(
db, db,
+1
View File
@@ -14,6 +14,7 @@
"exports": { "exports": {
".": "./index.ts", ".": "./index.ts",
"./cookie": "./src/cookie.ts", "./cookie": "./src/cookie.ts",
"./integration": "./src/integration.ts",
"./docs/*": "./src/docs/*.ts" "./docs/*": "./src/docs/*.ts"
}, },
"scripts": { "scripts": {
+14 -9
View File
@@ -33,14 +33,14 @@ export const normalizeBoardLayoutRoles = <
const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base"); const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base");
const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined; const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined;
const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined; const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined;
const orderedLayouts = const hasProtectedLayouts = mobileLayout !== undefined && baseLayout !== undefined;
mobileLayout && baseLayout let orderedLayouts = sourceLayouts.toSorted(compareBoardLayouts);
? [ if (hasProtectedLayouts) {
mobileLayout, orderedLayouts = [
...sourceLayouts.filter((layout) => layout.role === "custom").toSorted(compareBoardLayouts), mobileLayout,
baseLayout, ...sourceLayouts.filter((layout) => layout.id !== mobileLayout.id).toSorted(compareBoardLayouts),
] ];
: sourceLayouts.toSorted(compareBoardLayouts); }
let previousBreakpoint = -1; let previousBreakpoint = -1;
return orderedLayouts.map((layout, index) => { return orderedLayouts.map((layout, index) => {
@@ -49,7 +49,12 @@ export const normalizeBoardLayoutRoles = <
index === 0 index === 0
? 0 ? 0
: Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint)); : Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint));
const role: LayoutRole = index === 0 ? "mobile" : index === orderedLayouts.length - 1 ? "base" : "custom"; let role: LayoutRole = layout.role ?? "custom";
if (!hasProtectedLayouts) {
if (index === 0) role = "mobile";
else if (index === orderedLayouts.length - 1) role = "base";
else role = "custom";
}
previousBreakpoint = breakpoint; previousBreakpoint = breakpoint;
return { ...layout, breakpoint, role }; return { ...layout, breakpoint, role };
}); });
@@ -1,6 +1,6 @@
import { objectKeys } from "@homarr/common"; import { objectKeys } from "@homarr/common";
import { integrationDefs } from "./integration"; import { getIntegrationDockerMetadata, integrationDefs } from "./integration";
import type { IntegrationKind } from "./integration"; import type { IntegrationKind } from "./integration";
export const extractContainerImageName = (image: string): string => image.split("/").at(-1)?.split(":").at(0) ?? ""; export const extractContainerImageName = (image: string): string => image.split("/").at(-1)?.split(":").at(0) ?? "";
@@ -14,30 +14,6 @@ export const integrationIconSlugs: Record<IntegrationKind, string> = Object.from
objectKeys(integrationDefs).map((kind) => [kind, extractIconSlug(integrationDefs[kind].iconUrl)]), objectKeys(integrationDefs).map((kind) => [kind, extractIconSlug(integrationDefs[kind].iconUrl)]),
) as Record<IntegrationKind, string>; ) as Record<IntegrationKind, string>;
const integrationAliases: Partial<Record<IntegrationKind, readonly string[]>> = {
piHole: ["pihole", "pi-hole"],
adGuardHome: ["adguardhome", "adguard-home", "adguard"],
homeAssistant: ["homeassistant", "home-assistant", "hass"],
openmediavault: ["omv"],
qBittorrent: ["qbittorrent"],
sabNzbd: ["sabnzbd"],
nzbGet: ["nzbget"],
unifiController: ["unifi", "unifi-controller"],
jellyseerr: ["jellyseerr"],
overseerr: ["overseerr"],
dashDot: ["dashdot", "dash-dot", "dash."],
speedtestTracker: ["speedtest-tracker"],
uptimeKuma: ["uptime-kuma"],
audiobookshelf: ["audiobookshelf"],
navidrome: ["navidrome"],
paperlessNgx: ["paperless-ngx", "paperless"],
patchmon: ["patchmon", "patch-mon"],
coolify: ["coolify"],
truenas: ["truenas"],
bazarr: ["bazarr"],
synology: ["synology", "diskstation"],
};
export const matchIntegrationKind = (search: string): IntegrationKind | null => { export const matchIntegrationKind = (search: string): IntegrationKind | null => {
const normalized = search.toLowerCase().trim(); const normalized = search.toLowerCase().trim();
if (!normalized) return null; if (!normalized) return null;
@@ -51,8 +27,8 @@ export const matchIntegrationKind = (search: string): IntegrationKind | null =>
if (integrationIconSlugs[kind] === normalized) return kind; if (integrationIconSlugs[kind] === normalized) return kind;
} }
for (const [kind, aliases] of Object.entries(integrationAliases) as [IntegrationKind, readonly string[]][]) { for (const kind of objectKeys(integrationDefs)) {
if (aliases.some((alias) => alias === normalized)) return kind; if (getIntegrationDockerMetadata(kind).aliases.some((alias) => alias === normalized)) return kind;
} }
for (const kind of objectKeys(integrationDefs)) { for (const kind of objectKeys(integrationDefs)) {
@@ -63,8 +39,6 @@ export const matchIntegrationKind = (search: string): IntegrationKind | null =>
return null; return null;
}; };
const notDockerDiscoverable = new Set<IntegrationKind>(["ical", "mock"]);
interface ContainerMatchInput { interface ContainerMatchInput {
image: string; image: string;
name: string; name: string;
@@ -73,10 +47,10 @@ interface ContainerMatchInput {
export const matchIntegrationKindFromContainer = (container: ContainerMatchInput): IntegrationKind | null => { export const matchIntegrationKindFromContainer = (container: ContainerMatchInput): IntegrationKind | null => {
const imageName = extractContainerImageName(container.image); const imageName = extractContainerImageName(container.image);
const fromImage = matchIntegrationKind(imageName); const fromImage = matchIntegrationKind(imageName);
if (fromImage && !notDockerDiscoverable.has(fromImage)) return fromImage; if (fromImage && getIntegrationDockerMetadata(fromImage).discoverable) return fromImage;
const fromName = matchIntegrationKind(container.name); const fromName = matchIntegrationKind(container.name);
if (fromName && !notDockerDiscoverable.has(fromName)) return fromName; if (fromName && getIntegrationDockerMetadata(fromName).discoverable) return fromName;
return null; return null;
}; };
+3 -2
View File
@@ -5,8 +5,8 @@ import { XMLParser } from "fast-xml-parser";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { createDocumentationLink } from "./index"; import { createDocumentationLink } from "./index";
import { integrationDocSlugs } from "./integration-doc-slugs";
import { widgetDocSlugs } from "./widget-doc-slugs"; import { widgetDocSlugs } from "./widget-doc-slugs";
import { getIntegrationDocumentationSlug, integrationKinds } from "../integration";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -60,7 +60,8 @@ const updateSitemapTypeFileAsync = async (sitemapPathType: string) => {
}; };
const slugMapPaths = [ const slugMapPaths = [
...Object.values(integrationDocSlugs) ...integrationKinds
.map(getIntegrationDocumentationSlug)
.filter(Boolean) .filter(Boolean)
.map((slug) => `/docs/integrations/${slug}`), .map((slug) => `/docs/integrations/${slug}`),
...Object.values(widgetDocSlugs) ...Object.values(widgetDocSlugs)
@@ -1,59 +0,0 @@
import type { IntegrationKind } from "../integration";
export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
sabNzbd: "sabnzbd",
nzbGet: "nzbget",
deluge: "deluge",
transmission: "transmission",
qBittorrent: "q-bittorent",
aria2: "aria2",
slskd: "slskd",
sonarr: "sonarr",
radarr: "radarr",
lidarr: "lidarr",
readarr: "readarr",
prowlarr: "prowlarr",
bazarr: "bazarr",
jellyfin: "jellyfin",
emby: "emby",
plex: "plex",
jellyseerr: "jellyseerr",
seerr: "seerr",
overseerr: "overseerr",
piHole: "pi-hole",
adGuardHome: "adguard-home",
technitiumDns: "technitium-dns",
homeAssistant: "home-assistant",
openmediavault: "open-media-vault",
dashDot: "dash-dot",
glances: "glances",
tdarr: "tdarr",
proxmox: "proxmox",
nextcloud: "nextcloud",
unifiController: "unifi-controller",
opnsense: "opnsense",
ntfy: "ntfy",
gotify: "gotify",
ical: "ical",
anchor: "anchor",
truenas: "truenas",
synology: "synology",
unraid: "unraid",
coolify: "coolify",
immich: "immich",
paperlessNgx: "paperless-ngx",
patchmon: "patchmon",
tracearr: "tracearr",
speedtestTracker: "speedtest-tracker",
uptimeKuma: "uptime-kuma",
audiobookshelf: "audiobookshelf",
navidrome: "navidrome",
umami: "umami",
gluetun: "gluetun",
peaNut: "peanut",
beszel: "beszel",
traefik: "traefik",
archiveTeamWarrior: "archiveteam-warrior",
wud: "whats-up-docker",
mock: null,
};
@@ -1,6 +1,6 @@
import type { WidgetKind } from "../widget"; import type { WidgetKind } from "../widget";
export const widgetDocSlugs: Record<WidgetKind, string | null> = { export const widgetDocSlugs = {
clock: "clock", clock: "clock",
weather: "weather", weather: "weather",
airQuality: "air-quality", airQuality: "air-quality",
@@ -60,4 +60,4 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
customApi: "custom-api", customApi: "custom-api",
assistant: "assistant", assistant: "assistant",
wud: "whats-up-docker", wud: "whats-up-docker",
}; } satisfies Record<WidgetKind, string | null>;
+167 -76
View File
@@ -1,7 +1,12 @@
import { objectKeys } from "@homarr/common";
import type { AtLeastOneOf } from "@homarr/common/types"; import type { AtLeastOneOf } from "@homarr/common/types";
import { createDocumentationLink } from "./docs"; import { createDocumentationLink } from "./docs";
import type { HomarrDocumentationPath } from "./docs/homarr-docs-sitemap";
type IntegrationDocumentationPath = Extract<HomarrDocumentationPath, `/docs/integrations/${string}`>;
type IntegrationDocumentationSlug = IntegrationDocumentationPath extends `/docs/integrations/${infer TSlug}`
? TSlug
: never;
export const integrationSecretKindObject = { export const integrationSecretKindObject = {
apiKey: { isPublic: false, multiline: false }, apiKey: { isPublic: false, multiline: false },
@@ -22,14 +27,29 @@ export const integrationSecretKindObject = {
slug: { isPublic: true, multiline: false }, slug: { isPublic: true, multiline: false },
} satisfies Record<string, { isPublic: boolean; multiline: boolean }>; } satisfies Record<string, { isPublic: boolean; multiline: boolean }>;
export const integrationSecretKinds = objectKeys(integrationSecretKindObject); const isIntegrationSecretKind = (value: string): value is IntegrationSecretKind =>
Object.hasOwn(integrationSecretKindObject, value);
interface integrationDefinition { export const integrationSecretKinds = Object.keys(integrationSecretKindObject).filter(isIntegrationSecretKind);
interface IntegrationFeatureMetadata {
docker?: {
aliases?: readonly string[];
discoverable?: boolean;
};
onboarding?: {
featuredOrder?: number;
hidden?: boolean;
};
}
interface IntegrationDefinition {
name: string; name: string;
iconUrl: string; iconUrl: string;
secretKinds: AtLeastOneOf<IntegrationSecretKind[]>; // at least one secret kind set is required secretKinds: readonly [readonly IntegrationSecretKind[], ...(readonly IntegrationSecretKind[])[]];
category: AtLeastOneOf<IntegrationCategory>; category: readonly [IntegrationCategory, ...IntegrationCategory[]];
documentationUrl: string | null; documentationSlug: IntegrationDocumentationSlug | null;
features?: IntegrationFeatureMetadata;
defaultUrl?: string; defaultUrl?: string;
defaultPort?: number; defaultPort?: number;
apiKeySettingsPath?: string; apiKeySettingsPath?: string;
@@ -41,24 +61,29 @@ export const integrationDefs = {
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg",
category: ["downloadClient", "usenet"], category: ["downloadClient", "usenet"],
documentationUrl: createDocumentationLink("/docs/integrations/sabnzbd"), documentationSlug: "sabnzbd",
defaultPort: 8080, defaultPort: 8080,
apiKeySettingsPath: "/config/general/", apiKeySettingsPath: "/config/general/",
features: {
docker: { aliases: ["sabnzbd"] },
onboarding: { featuredOrder: 3 },
},
}, },
nzbGet: { nzbGet: {
name: "NZBGet", name: "NZBGet",
secretKinds: [["username", "password"]], secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nzbget.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nzbget.svg",
category: ["downloadClient", "usenet"], category: ["downloadClient", "usenet"],
documentationUrl: createDocumentationLink("/docs/integrations/nzbget"), documentationSlug: "nzbget",
defaultPort: 6789, defaultPort: 6789,
features: { docker: { aliases: ["nzbget"] } },
}, },
deluge: { deluge: {
name: "Deluge", name: "Deluge",
secretKinds: [["password"]], secretKinds: [["password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/deluge.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/deluge.svg",
category: ["downloadClient", "torrent"], category: ["downloadClient", "torrent"],
documentationUrl: createDocumentationLink("/docs/integrations/deluge"), documentationSlug: "deluge",
defaultPort: 8112, defaultPort: 8112,
}, },
transmission: { transmission: {
@@ -66,7 +91,7 @@ export const integrationDefs = {
secretKinds: [["username", "password"]], secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg",
category: ["downloadClient", "torrent"], category: ["downloadClient", "torrent"],
documentationUrl: createDocumentationLink("/docs/integrations/transmission"), documentationSlug: "transmission",
defaultPort: 9091, defaultPort: 9091,
}, },
qBittorrent: { qBittorrent: {
@@ -74,16 +99,20 @@ export const integrationDefs = {
secretKinds: [["apiKey"], ["username", "password"]], secretKinds: [["apiKey"], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg",
category: ["downloadClient", "torrent"], category: ["downloadClient", "torrent"],
documentationUrl: createDocumentationLink("/docs/integrations/q-bittorent"), documentationSlug: "q-bittorent",
defaultPort: 8080, defaultPort: 8080,
apiKeySettingsPath: "/#/settings/webui", apiKeySettingsPath: "/#/settings/webui",
features: {
docker: { aliases: ["qbittorrent"] },
onboarding: { featuredOrder: 4 },
},
}, },
aria2: { aria2: {
name: "Aria2", name: "Aria2",
secretKinds: [[], ["apiKey"]], secretKinds: [[], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg", iconUrl: "https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg",
category: ["downloadClient", "torrent", "miscellaneous"], category: ["downloadClient", "torrent", "miscellaneous"],
documentationUrl: createDocumentationLink("/docs/integrations/aria2"), documentationSlug: "aria2",
defaultPort: 6800, defaultPort: 6800,
}, },
slskd: { slskd: {
@@ -91,7 +120,7 @@ export const integrationDefs = {
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/slskd.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/slskd.svg",
category: ["downloadClient", "miscellaneous"], category: ["downloadClient", "miscellaneous"],
documentationUrl: createDocumentationLink("/docs/integrations/slskd"), documentationSlug: "slskd",
defaultPort: 5030, defaultPort: 5030,
}, },
sonarr: { sonarr: {
@@ -99,25 +128,27 @@ export const integrationDefs = {
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg",
category: ["calendar", "mediaOrganizer"], category: ["calendar", "mediaOrganizer"],
documentationUrl: createDocumentationLink("/docs/integrations/sonarr"), documentationSlug: "sonarr",
defaultPort: 8989, defaultPort: 8989,
apiKeySettingsPath: "/settings/general", apiKeySettingsPath: "/settings/general",
features: { onboarding: { featuredOrder: 0 } },
}, },
radarr: { radarr: {
name: "Radarr", name: "Radarr",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg",
category: ["calendar", "mediaOrganizer"], category: ["calendar", "mediaOrganizer"],
documentationUrl: createDocumentationLink("/docs/integrations/radarr"), documentationSlug: "radarr",
defaultPort: 7878, defaultPort: 7878,
apiKeySettingsPath: "/settings/general", apiKeySettingsPath: "/settings/general",
features: { onboarding: { featuredOrder: 1 } },
}, },
lidarr: { lidarr: {
name: "Lidarr", name: "Lidarr",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/lidarr.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/lidarr.svg",
category: ["calendar"], category: ["calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/lidarr"), documentationSlug: "lidarr",
defaultPort: 8686, defaultPort: 8686,
apiKeySettingsPath: "/settings/general", apiKeySettingsPath: "/settings/general",
}, },
@@ -126,7 +157,7 @@ export const integrationDefs = {
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/png/readarr.png", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/png/readarr.png",
category: ["calendar"], category: ["calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/readarr"), documentationSlug: "readarr",
defaultPort: 8787, defaultPort: 8787,
apiKeySettingsPath: "/settings/general", apiKeySettingsPath: "/settings/general",
}, },
@@ -135,34 +166,37 @@ export const integrationDefs = {
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/prowlarr.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/prowlarr.svg",
category: ["indexerManager"], category: ["indexerManager"],
documentationUrl: createDocumentationLink("/docs/integrations/prowlarr"), documentationSlug: "prowlarr",
defaultPort: 9696, defaultPort: 9696,
apiKeySettingsPath: "/settings/general", apiKeySettingsPath: "/settings/general",
features: { onboarding: { featuredOrder: 2 } },
}, },
bazarr: { bazarr: {
name: "Bazarr", name: "Bazarr",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/bazarr.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/bazarr.svg",
category: ["subtitleManager"], category: ["subtitleManager"],
documentationUrl: createDocumentationLink("/docs/integrations/bazarr"), documentationSlug: "bazarr",
defaultPort: 6767, defaultPort: 6767,
apiKeySettingsPath: "/settings/general", apiKeySettingsPath: "/settings/general",
features: { docker: { aliases: ["bazarr"] } },
}, },
jellyfin: { jellyfin: {
name: "Jellyfin", name: "Jellyfin",
secretKinds: [["username", "password"], ["apiKey"]], secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
category: ["mediaService", "mediaRelease"], category: ["mediaService", "mediaRelease"],
documentationUrl: createDocumentationLink("/docs/integrations/jellyfin"), documentationSlug: "jellyfin",
defaultPort: 8096, defaultPort: 8096,
apiKeySettingsPath: "/web/index.html#!/dashboard/keys", apiKeySettingsPath: "/web/index.html#!/dashboard/keys",
features: { onboarding: { featuredOrder: 6 } },
}, },
emby: { emby: {
name: "Emby", name: "Emby",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg",
category: ["mediaService", "mediaRelease"], category: ["mediaService", "mediaRelease"],
documentationUrl: createDocumentationLink("/docs/integrations/emby"), documentationSlug: "emby",
defaultPort: 8096, defaultPort: 8096,
}, },
plex: { plex: {
@@ -170,7 +204,7 @@ export const integrationDefs = {
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg",
category: ["mediaService", "mediaRelease"], category: ["mediaService", "mediaRelease"],
documentationUrl: createDocumentationLink("/docs/integrations/plex"), documentationSlug: "plex",
defaultPort: 32400, defaultPort: 32400,
}, },
jellyseerr: { jellyseerr: {
@@ -178,50 +212,55 @@ export const integrationDefs = {
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
category: ["mediaSearch", "mediaRequest", "search"], category: ["mediaSearch", "mediaRequest", "search"],
documentationUrl: createDocumentationLink("/docs/integrations/jellyseerr"), documentationSlug: "jellyseerr",
defaultPort: 5055, defaultPort: 5055,
apiKeySettingsPath: "/settings", apiKeySettingsPath: "/settings",
features: { docker: { aliases: ["jellyseerr"] } },
}, },
seerr: { seerr: {
name: "Seerr", name: "Seerr",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/seerr.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/seerr.svg",
category: ["mediaSearch", "mediaRequest", "search"], category: ["mediaSearch", "mediaRequest", "search"],
documentationUrl: createDocumentationLink("/docs/integrations/seerr"), documentationSlug: "seerr",
defaultPort: 5055, defaultPort: 5055,
apiKeySettingsPath: "/settings", apiKeySettingsPath: "/settings",
features: { onboarding: { featuredOrder: 5 } },
}, },
overseerr: { overseerr: {
name: "Overseerr", name: "Overseerr",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/overseerr.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/overseerr.svg",
category: ["mediaSearch", "mediaRequest", "search"], category: ["mediaSearch", "mediaRequest", "search"],
documentationUrl: createDocumentationLink("/docs/integrations/overseerr"), documentationSlug: "overseerr",
defaultPort: 5055, defaultPort: 5055,
apiKeySettingsPath: "/settings", apiKeySettingsPath: "/settings",
features: { docker: { aliases: ["overseerr"] } },
}, },
piHole: { piHole: {
name: "Pi-hole", name: "Pi-hole",
secretKinds: [["apiKey"], []], secretKinds: [["apiKey"], []],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg",
category: ["dnsHole"], category: ["dnsHole"],
documentationUrl: createDocumentationLink("/docs/integrations/pi-hole"), documentationSlug: "pi-hole",
defaultPort: 80, defaultPort: 80,
features: { docker: { aliases: ["pihole", "pi-hole"] } },
}, },
adGuardHome: { adGuardHome: {
name: "AdGuard Home", name: "AdGuard Home",
secretKinds: [["username", "password"]], secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/adguard-home.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/adguard-home.svg",
category: ["dnsHole"], category: ["dnsHole"],
documentationUrl: createDocumentationLink("/docs/integrations/adguard-home"), documentationSlug: "adguard-home",
defaultPort: 3000, defaultPort: 3000,
features: { docker: { aliases: ["adguardhome", "adguard-home", "adguard"] } },
}, },
technitiumDns: { technitiumDns: {
name: "Technitium DNS", name: "Technitium DNS",
secretKinds: [["apiKey"], ["username", "password"]], secretKinds: [["apiKey"], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/technitium.svg", iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/technitium.svg",
category: ["dnsHole"], category: ["dnsHole"],
documentationUrl: createDocumentationLink("/docs/integrations/technitium-dns"), documentationSlug: "technitium-dns",
defaultPort: 5380, defaultPort: 5380,
}, },
homeAssistant: { homeAssistant: {
@@ -229,32 +268,35 @@ export const integrationDefs = {
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg",
category: ["smartHomeServer", "calendar"], category: ["smartHomeServer", "calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/home-assistant"), documentationSlug: "home-assistant",
defaultPort: 8123, defaultPort: 8123,
apiKeySettingsPath: "/profile/security", apiKeySettingsPath: "/profile/security",
features: { docker: { aliases: ["homeassistant", "home-assistant", "hass"] } },
}, },
openmediavault: { openmediavault: {
name: "OpenMediaVault", name: "OpenMediaVault",
secretKinds: [["username", "password"]], secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/openmediavault.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/openmediavault.svg",
category: ["healthMonitoring"], category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/open-media-vault"), documentationSlug: "open-media-vault",
defaultPort: 80, defaultPort: 80,
features: { docker: { aliases: ["omv"] } },
}, },
dashDot: { dashDot: {
name: "Dash.", name: "Dash.",
secretKinds: [[]], secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png",
category: ["healthMonitoring"], category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/dash-dot"), documentationSlug: "dash-dot",
defaultPort: 3001, defaultPort: 3001,
features: { docker: { aliases: ["dashdot", "dash-dot", "dash."] } },
}, },
glances: { glances: {
name: "Glances", name: "Glances",
secretKinds: [[]], secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg",
category: ["healthMonitoring"], category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/glances"), documentationSlug: "glances",
defaultPort: 61208, defaultPort: 61208,
}, },
tdarr: { tdarr: {
@@ -262,7 +304,7 @@ export const integrationDefs = {
secretKinds: [[], ["apiKey"]], secretKinds: [[], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png",
category: ["mediaTranscoding"], category: ["mediaTranscoding"],
documentationUrl: createDocumentationLink("/docs/integrations/tdarr"), documentationSlug: "tdarr",
defaultPort: 8265, defaultPort: 8265,
}, },
proxmox: { proxmox: {
@@ -270,7 +312,7 @@ export const integrationDefs = {
secretKinds: [["username", "tokenId", "apiKey", "realm"]], secretKinds: [["username", "tokenId", "apiKey", "realm"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/proxmox.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/proxmox.svg",
category: ["healthMonitoring"], category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/proxmox"), documentationSlug: "proxmox",
defaultPort: 8006, defaultPort: 8006,
}, },
nextcloud: { nextcloud: {
@@ -278,7 +320,7 @@ export const integrationDefs = {
secretKinds: [["username", "password"]], secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg",
category: ["calendar", "notifications"], category: ["calendar", "notifications"],
documentationUrl: createDocumentationLink("/docs/integrations/nextcloud"), documentationSlug: "nextcloud",
defaultPort: 443, defaultPort: 443,
}, },
unifiController: { unifiController: {
@@ -286,15 +328,16 @@ export const integrationDefs = {
secretKinds: [["username", "password"]], secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
category: ["networkController"], category: ["networkController"],
documentationUrl: createDocumentationLink("/docs/integrations/unifi-controller"), documentationSlug: "unifi-controller",
defaultPort: 8443, defaultPort: 8443,
features: { docker: { aliases: ["unifi", "unifi-controller"] } },
}, },
opnsense: { opnsense: {
name: "OPNsense", name: "OPNsense",
secretKinds: [["opnsenseApiKey", "opnsenseApiSecret"]], secretKinds: [["opnsenseApiKey", "opnsenseApiSecret"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg",
category: ["firewall"], category: ["firewall"],
documentationUrl: createDocumentationLink("/docs/integrations/opnsense"), documentationSlug: "opnsense",
defaultPort: 443, defaultPort: 443,
apiKeySettingsPath: "/system_usermanager.php", apiKeySettingsPath: "/system_usermanager.php",
}, },
@@ -303,7 +346,7 @@ export const integrationDefs = {
secretKinds: [["topic"], ["topic", "apiKey"]], secretKinds: [["topic"], ["topic", "apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg",
category: ["notifications"], category: ["notifications"],
documentationUrl: createDocumentationLink("/docs/integrations/ntfy"), documentationSlug: "ntfy",
defaultPort: 80, defaultPort: 80,
apiKeySettingsPath: "/account", apiKeySettingsPath: "/account",
}, },
@@ -312,7 +355,7 @@ export const integrationDefs = {
secretKinds: [["username", "password"]], secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gotify.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gotify.svg",
category: ["notifications"], category: ["notifications"],
documentationUrl: createDocumentationLink("/docs/integrations/gotify"), documentationSlug: "gotify",
defaultPort: 80, defaultPort: 80,
}, },
ical: { ical: {
@@ -320,14 +363,15 @@ export const integrationDefs = {
secretKinds: [["url"]], secretKinds: [["url"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ical.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ical.svg",
category: ["calendar"], category: ["calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/ical"), documentationSlug: "ical",
features: { docker: { discoverable: false } },
}, },
anchor: { anchor: {
name: "Anchor", name: "Anchor",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/anchor.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/anchor.svg",
category: ["notes"], category: ["notes"],
documentationUrl: createDocumentationLink("/docs/integrations/anchor"), documentationSlug: "anchor",
defaultPort: 8080, defaultPort: 8080,
apiKeySettingsPath: "/settings", apiKeySettingsPath: "/settings",
}, },
@@ -336,23 +380,25 @@ export const integrationDefs = {
secretKinds: [["username", "password"], ["apiKey"]], secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg",
category: ["healthMonitoring"], category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/truenas"), documentationSlug: "truenas",
defaultPort: 80, defaultPort: 80,
features: { docker: { aliases: ["truenas"] } },
}, },
synology: { synology: {
name: "Synology DiskStation", name: "Synology DiskStation",
secretKinds: [["username", "password"]], secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/synology.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/synology.svg",
category: ["healthMonitoring"], category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/synology"), documentationSlug: "synology",
defaultPort: 5000, defaultPort: 5000,
features: { docker: { aliases: ["synology", "diskstation"] } },
}, },
unraid: { unraid: {
name: "Unraid", name: "Unraid",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/unraid.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/unraid.svg",
category: ["healthMonitoring"], category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/unraid"), documentationSlug: "unraid",
defaultPort: 80, defaultPort: 80,
apiKeySettingsPath: "/Settings/ManagementAccess", apiKeySettingsPath: "/Settings/ManagementAccess",
}, },
@@ -361,16 +407,17 @@ export const integrationDefs = {
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg",
category: ["healthMonitoring"], category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/coolify"), documentationSlug: "coolify",
defaultPort: 8000, defaultPort: 8000,
apiKeySettingsPath: "/security/api-tokens", apiKeySettingsPath: "/security/api-tokens",
features: { docker: { aliases: ["coolify"] } },
}, },
immich: { immich: {
name: "Immich", name: "Immich",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/immich.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/immich.svg",
category: ["photoService"], category: ["photoService"],
documentationUrl: createDocumentationLink("/docs/integrations/immich"), documentationSlug: "immich",
defaultPort: 2283, defaultPort: 2283,
apiKeySettingsPath: "/user-settings", apiKeySettingsPath: "/user-settings",
}, },
@@ -379,24 +426,26 @@ export const integrationDefs = {
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/paperless-ngx.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/paperless-ngx.svg",
category: ["documents"], category: ["documents"],
documentationUrl: createDocumentationLink("/docs/integrations/paperless-ngx"), documentationSlug: "paperless-ngx",
defaultPort: 8000, defaultPort: 8000,
features: { docker: { aliases: ["paperless-ngx", "paperless"] } },
}, },
patchmon: { patchmon: {
name: "PatchMon", name: "PatchMon",
secretKinds: [["patchmonApiKey", "patchmonApiSecret"]], secretKinds: [["patchmonApiKey", "patchmonApiSecret"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/patchmon.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/patchmon.svg",
category: ["healthMonitoring"], category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/patchmon"), documentationSlug: "patchmon",
defaultPort: 3413, defaultPort: 3413,
apiKeySettingsPath: "/settings/integrations", apiKeySettingsPath: "/settings/integrations",
features: { docker: { aliases: ["patchmon", "patch-mon"] } },
}, },
tracearr: { tracearr: {
name: "Tracearr", name: "Tracearr",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg",
category: ["mediaMonitoring"], category: ["mediaMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/tracearr"), documentationSlug: "tracearr",
defaultPort: 7040, defaultPort: 7040,
apiKeySettingsPath: "/settings", apiKeySettingsPath: "/settings",
}, },
@@ -405,35 +454,39 @@ export const integrationDefs = {
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/speedtest-tracker.png", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/speedtest-tracker.png",
category: ["speedtest"], category: ["speedtest"],
documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"), documentationSlug: "speedtest-tracker",
defaultPort: 80, defaultPort: 80,
apiKeySettingsPath: "/admin/api-tokens", apiKeySettingsPath: "/admin/api-tokens",
features: { docker: { aliases: ["speedtest-tracker"] } },
}, },
uptimeKuma: { uptimeKuma: {
name: "Uptime Kuma", name: "Uptime Kuma",
secretKinds: [[], ["slug"], ["slug", "apiKey"], ["apiKey"]], secretKinds: [[], ["slug"], ["slug", "apiKey"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/uptime-kuma.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/uptime-kuma.svg",
category: ["uptimeMonitoring"], category: ["uptimeMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/uptime-kuma"), documentationSlug: "uptime-kuma",
defaultPort: 3001, defaultPort: 3001,
apiKeySettingsPath: "/settings/api-keys", apiKeySettingsPath: "/settings/api-keys",
features: { docker: { aliases: ["uptime-kuma"] } },
}, },
audiobookshelf: { audiobookshelf: {
name: "Audiobookshelf", name: "Audiobookshelf",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/audiobookshelf.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/audiobookshelf.svg",
category: ["mediaLibrary"], category: ["mediaLibrary"],
documentationUrl: createDocumentationLink("/docs/integrations/audiobookshelf"), documentationSlug: "audiobookshelf",
defaultPort: 13378, defaultPort: 13378,
apiKeySettingsPath: "/account", apiKeySettingsPath: "/account",
features: { docker: { aliases: ["audiobookshelf"] } },
}, },
navidrome: { navidrome: {
name: "Navidrome", name: "Navidrome",
secretKinds: [["username", "password"]], secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg",
category: ["mediaLibrary", "mediaService"], category: ["mediaLibrary", "mediaService"],
documentationUrl: createDocumentationLink("/docs/integrations/navidrome"), documentationSlug: "navidrome",
defaultPort: 4533, defaultPort: 4533,
features: { docker: { aliases: ["navidrome"] } },
}, },
umami: { umami: {
name: "Umami", name: "Umami",
@@ -441,7 +494,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/umami.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/umami.svg",
category: ["analytics"], category: ["analytics"],
defaultUrl: "https://api.umami.is/v1", defaultUrl: "https://api.umami.is/v1",
documentationUrl: createDocumentationLink("/docs/integrations/umami"), documentationSlug: "umami",
defaultPort: 3000, defaultPort: 3000,
}, },
peaNut: { peaNut: {
@@ -449,7 +502,7 @@ export const integrationDefs = {
secretKinds: [["username", "password"], []], secretKinds: [["username", "password"], []],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg",
category: ["ups"], category: ["ups"],
documentationUrl: createDocumentationLink("/docs/integrations/peanut"), documentationSlug: "peanut",
defaultPort: 8080, defaultPort: 8080,
}, },
beszel: { beszel: {
@@ -457,7 +510,7 @@ export const integrationDefs = {
secretKinds: [["username", "password"]], secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
category: ["beszel"], category: ["beszel"],
documentationUrl: createDocumentationLink("/docs/integrations/beszel"), documentationSlug: "beszel",
defaultPort: 8090, defaultPort: 8090,
}, },
gluetun: { gluetun: {
@@ -465,7 +518,7 @@ export const integrationDefs = {
secretKinds: [["username", "password"], ["apiKey"], []], secretKinds: [["username", "password"], ["apiKey"], []],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gluetun.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gluetun.svg",
category: ["vpn"], category: ["vpn"],
documentationUrl: createDocumentationLink("/docs/integrations/gluetun"), documentationSlug: "gluetun",
defaultPort: 8000, defaultPort: 8000,
}, },
traefik: { traefik: {
@@ -473,7 +526,7 @@ export const integrationDefs = {
secretKinds: [[], ["username", "password"], ["apiKey"]], secretKinds: [[], ["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg",
category: ["reverseProxy"], category: ["reverseProxy"],
documentationUrl: createDocumentationLink("/docs/integrations/traefik"), documentationSlug: "traefik",
defaultPort: 8080, defaultPort: 8080,
}, },
archiveTeamWarrior: { archiveTeamWarrior: {
@@ -481,7 +534,7 @@ export const integrationDefs = {
secretKinds: [[], ["username", "password"]], secretKinds: [[], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png", iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
category: ["archiving"], category: ["archiving"],
documentationUrl: createDocumentationLink("/docs/integrations/archiveteam-warrior"), documentationSlug: "archiveteam-warrior",
defaultPort: 8001, defaultPort: 8001,
}, },
wud: { wud: {
@@ -489,7 +542,7 @@ export const integrationDefs = {
secretKinds: [[], ["username", "password"]], secretKinds: [[], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
category: ["healthMonitoring"], category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/whats-up-docker"), documentationSlug: "whats-up-docker",
defaultPort: 3000, 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)
@@ -514,21 +567,36 @@ export const integrationDefs = {
"uptimeMonitoring", "uptimeMonitoring",
"mediaOrganizer", "mediaOrganizer",
], ],
documentationUrl: null, documentationSlug: null,
features: {
docker: { discoverable: false },
onboarding: { hidden: true },
},
}, },
} as const satisfies Record<string, integrationDefinition>; } as const satisfies Record<string, IntegrationDefinition>;
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>; const isIntegrationKind = (value: string): value is IntegrationKind => Object.hasOwn(integrationDefs, value);
const integrationKindValues = Object.keys(integrationDefs).filter(isIntegrationKind);
const firstIntegrationKind = integrationKindValues[0];
if (!firstIntegrationKind) throw new Error("At least one integration definition is required");
export const integrationKinds: AtLeastOneOf<IntegrationKind> = [
firstIntegrationKind,
...integrationKindValues.slice(1),
];
export const getIconUrl = (integration: IntegrationKind) => integrationDefs[integration].iconUrl; export const getIconUrl = (integration: IntegrationKind) => integrationDefs[integration].iconUrl;
export const getIntegrationName = (integration: IntegrationKind) => integrationDefs[integration].name; export const getIntegrationName = (integration: IntegrationKind) => integrationDefs[integration].name;
export const getDefaultSecretKinds = (integration: IntegrationKind): IntegrationSecretKind[] => export const getDefaultSecretKinds = (integration: IntegrationKind): IntegrationSecretKind[] => [
integrationDefs[integration].secretKinds[0]; ...integrationDefs[integration].secretKinds[0],
];
export const getAllSecretKindOptions = (integration: IntegrationKind): AtLeastOneOf<IntegrationSecretKind[]> => export const getAllSecretKindOptions = (integration: IntegrationKind): AtLeastOneOf<IntegrationSecretKind[]> => {
integrationDefs[integration].secretKinds; const [first, ...rest] = integrationDefs[integration].secretKinds;
return [[...first], ...rest.map((secretKinds) => [...secretKinds])];
};
export const getIntegrationDefaultUrl = (integration: IntegrationKind) => { export const getIntegrationDefaultUrl = (integration: IntegrationKind) => {
const definition = integrationDefs[integration]; const definition = integrationDefs[integration];
@@ -540,8 +608,30 @@ export const getIntegrationDefaultPort = (kind: IntegrationKind): number | undef
return "defaultPort" in definition ? definition.defaultPort : undefined; return "defaultPort" in definition ? definition.defaultPort : undefined;
}; };
export const getIntegrationDocumentationUrl = (kind: IntegrationKind): string | null => export const getIntegrationDocumentationSlug = (kind: IntegrationKind): IntegrationDocumentationSlug | null =>
integrationDefs[kind].documentationUrl; integrationDefs[kind].documentationSlug;
export const getIntegrationDocumentationUrl = (kind: IntegrationKind): string | null => {
const slug = getIntegrationDocumentationSlug(kind);
if (slug === null) return null;
return createDocumentationLink(`/docs/integrations/${slug}`);
};
export const getIntegrationDockerMetadata = (kind: IntegrationKind) => {
const definition: IntegrationDefinition = integrationDefs[kind];
return {
aliases: definition.features?.docker?.aliases ?? [],
discoverable: definition.features?.docker?.discoverable ?? true,
};
};
export const getIntegrationOnboardingMetadata = (kind: IntegrationKind) => {
const definition: IntegrationDefinition = integrationDefs[kind];
return {
featuredOrder: definition.features?.onboarding?.featuredOrder ?? null,
hidden: definition.features?.onboarding?.hidden ?? false,
};
};
export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: IntegrationKind): string | null => { export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: IntegrationKind): string | null => {
const definition = integrationDefs[kind]; const definition = integrationDefs[kind];
@@ -567,9 +657,12 @@ export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: Integratio
* @returns Partial list of integration kinds * @returns Partial list of integration kinds
*/ */
export const getIntegrationKindsByCategory = <TCategory extends IntegrationCategory>(category: TCategory) => { export const getIntegrationKindsByCategory = <TCategory extends IntegrationCategory>(category: TCategory) => {
return objectKeys(integrationDefs).filter((integration) => const matchesCategory = (integration: IntegrationKind): integration is IntegrationKindByCategory<TCategory> =>
integrationDefs[integration].category.some((defCategory) => defCategory === category), integrationDefs[integration].category.some((definitionCategory) => definitionCategory === category);
) as AtLeastOneOf<IntegrationKindByCategory<TCategory>>; const matches = integrationKinds.filter(matchesCategory);
const firstMatch = matches[0];
if (!firstMatch) throw new Error(`No integration definition uses the ${category} category`);
return [firstMatch, ...matches.slice(1)] satisfies AtLeastOneOf<IntegrationKindByCategory<TCategory>>;
}; };
/** /**
@@ -579,10 +672,8 @@ export type IntegrationKindByCategory<TCategory extends IntegrationCategory> = {
[Key in keyof typeof integrationDefs]: TCategory extends (typeof integrationDefs)[Key]["category"][number] [Key in keyof typeof integrationDefs]: TCategory extends (typeof integrationDefs)[Key]["category"][number]
? Key ? Key
: never; : never;
}[keyof typeof integrationDefs] extends infer U }[keyof typeof integrationDefs] &
? //Needed to simplify the type when using it IntegrationKind;
U
: never;
export type IntegrationSecretKind = keyof typeof integrationSecretKindObject; export type IntegrationSecretKind = keyof typeof integrationSecretKindObject;
export type IntegrationKind = keyof typeof integrationDefs; export type IntegrationKind = keyof typeof integrationDefs;
@@ -1,12 +1,14 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getIntegrationApiKeyUrl, integrationDefs } from "../integration"; import { objectEntries } from "@homarr/common";
import { getIntegrationApiKeyUrl, getIntegrationDocumentationUrl, integrationDefs } from "../integration";
describe("getIntegrationApiKeyUrl", () => { describe("getIntegrationApiKeyUrl", () => {
it("links every user-facing integration to its Homarr setup guide", () => { it("links every user-facing integration to its Homarr setup guide", () => {
expect( expect(
Object.entries(integrationDefs) objectEntries(integrationDefs)
.filter(([kind, definition]) => kind !== "mock" && !definition.documentationUrl) .filter(([kind]) => kind !== "mock" && !getIntegrationDocumentationUrl(kind))
.map(([kind]) => kind), .map(([kind]) => kind),
).toEqual([]); ).toEqual([]);
}); });
@@ -4,19 +4,19 @@ import { integrationKinds } from "../integration";
import { import {
getWidgetKindsForIntegration, getWidgetKindsForIntegration,
integrationWidgetSupport, integrationWidgetSupport,
nativeFeatureCapabilities, widgetIntegrationConfigs,
widgetIntegrationLimits, widgetIntegrationLimits,
widgetIntegrationSupport, widgetIntegrationSupport,
widgetKindsWithOptionalIntegrations, widgetKindsWithOptionalIntegrations,
} from "../widget-integration-map"; } from "../widget-integration-map";
describe("native feature capability descriptor", () => { describe("widget integration config", () => {
test("derives forward and reverse support without losing relationships", () => { test("derives forward and reverse support without losing relationships", () => {
for (const [widgetKind, capability] of Object.entries(nativeFeatureCapabilities)) { for (const [widgetKind, capability] of Object.entries(widgetIntegrationConfigs)) {
expect(widgetIntegrationSupport[widgetKind as keyof typeof widgetIntegrationSupport]).toEqual( expect(widgetIntegrationSupport[widgetKind as keyof typeof widgetIntegrationSupport]).toEqual(
capability.integrations, capability.supportedIntegrations,
); );
for (const integrationKind of capability.integrations) { for (const integrationKind of capability.supportedIntegrations) {
expect(integrationWidgetSupport[integrationKind]).toContain(widgetKind); expect(integrationWidgetSupport[integrationKind]).toContain(widgetKind);
} }
} }
@@ -27,8 +27,8 @@ describe("native feature capability descriptor", () => {
}); });
test("supports the mock integration for every integration-backed widget", () => { test("supports the mock integration for every integration-backed widget", () => {
for (const capability of Object.values(nativeFeatureCapabilities)) { for (const config of Object.values(widgetIntegrationConfigs)) {
expect(capability.integrations).toContain("mock"); expect(config.supportedIntegrations).toContain("mock");
} }
}); });
Loaded 100 of 257 files, more files were not shown because too many files have changed in this diff. Show more