Compare commits

...
Author SHA1 Message Date
ajnart af1ae122ba style: auto-fix lint and format 2026-07-10 15:54:51 +00:00
ajnart d33ed20149 fix(releases): bind token to saved provider:identifier match
The token must only be used for repositories that are registered in the
widget's saved options, matched by provider:identifier (not by fragile
cuid2 id). This prevents tokens from being sent to arbitrary providerUrl
values submitted by the caller.

The saved providerUrl is used in preference to the request's providerUrl
when a token is being applied.
2026-07-10 17:53:26 +02:00
ajnart 173881f468 style: auto-fix lint and format 2026-07-10 15:39:45 +00:00
ajnart e48402ac4e fix(releases): remove broken allowedRepoIds token gate
The token was only applied if the repository's id matched the saved
widget options, but the token is already scoped to the widget's itemId
and provider kind. This check silently failed when saved options lacked
ids (old data) or when ids mismatched, causing all GitHub API calls to
be unauthenticated and hit the 60 req/hr rate limit.

Changes:
- Remove allowedRepoIds check in releases.ts — use token by provider
- Remove unused SuperJSON import
- Enable Octokit throttling for authenticated requests
2026-07-10 17:38:15 +02:00
Anil K TiwariandThomas 9fb2e37605 feat(widgets): add missing & queued media widget for Radarr and Sonarr (#6078)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-07-10 17:31:08 +02:00
homarr-crowdin[bot] 713fcd6f3f chore(lang): updated translations from crowdin (#6240) 2026-07-10 17:30:10 +02:00
homarr-update-contributors[bot] 6b10cd6874 chore: update integrations list readme (#6258) 2026-07-10 17:29:47 +02:00
homarr-update-contributors[bot] cabc66cc69 chore: update contributors (#6272) 2026-07-10 17:29:30 +02:00
Felix-AyushandThomas 2c5368dd4e feat: add Bazarr integration and subtitle status widget (#6156)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-07-10 17:29:10 +02:00
Nam NguyenandThomas 7d93df04dd fix: add utf8 email validation support (#6239)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-07-10 17:28:22 +02:00
Thomas 7c152cf4fe feat(api): expose board automation endpoints (#6213)
* feat(api): expose board automation endpoints

* style: auto-fix lint and format

* test(api): cover board settings updates

* Delete .opencode/changelog/2026-07-06.md
2026-07-10 17:27:42 +02:00
Thomas 718f095cb7 fix: prevent stale persisted query cache from blocking widget data (#6273) 2026-07-10 17:19:24 +02:00
Thomas 24c5d04bfa fix(beszel): disable request handler cache for stats to make live mode work (#6244) 2026-07-10 17:16:23 +02:00
Thomas 0e513fffd5 feat(widgets): add per-filesystem storage chart to Beszel system stats (#6209) 2026-07-10 17:08:22 +02:00
Thomas 2ada1aa249 fix(qbittorrent): handle v5 stopped torrent states for pause functionality (#6211) 2026-07-10 16:55:20 +02:00
Junker der Provinz af89423f66 fix(docker): report current CPU usage instead of the lifetime average (#6249) 2026-07-10 16:54:52 +02:00
Nerdy TechnicianandThomas 9864fed775 feat: Add Traefik Integration with dedicated widget and Add optional Icons for Gotify/Notifcation Widget (ONLY GOTIFY) (#6206)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-07-10 16:49:24 +02:00
Thomas bcd36b1b27 fix(integrations): sort media requests by last modified instead of creation date (#6264) 2026-07-10 16:46:33 +02:00
homarr-renovate[bot] a2f7b35c5c chore(deps): update pnpm to v11.10.0 (#6265)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-09 21:36:24 +00:00
homarr-renovate[bot] 616a444e64 chore(deps): update dependency @immich/sdk to ^3.0.1 (#6251)
* chore(deps): update dependency @immich/sdk to ^3.0.1

* style: auto-fix lint and format

---------

Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-07-09 19:51:55 +00:00
143 changed files with 4435 additions and 874 deletions

No files matched your search

+16
View File
@@ -19,6 +19,14 @@ packages/integrations/src/anchor @ohmzi
apps/docs/docs/integrations/archive-team-warrior @darbyjack
packages/integrations/src/archive-team-warrior @darbyjack
# Bazarr - introduced in https://github.com/homarr-labs/homarr/pull/6156 by @Ayush7614
apps/docs/docs/integrations/bazarr @Ayush7614
apps/docs/docs/widgets/bazarr @Ayush7614
packages/api/src/router/widgets/bazarr.ts @Ayush7614
packages/integrations/src/bazarr @Ayush7614
packages/request-handler/src/bazarr.ts @Ayush7614
packages/widgets/src/bazarr @Ayush7614
# Audiobookshelf - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/audiobookshelf @ajnart
packages/integrations/src/audiobookshelf @ajnart
@@ -191,6 +199,14 @@ packages/integrations/src/unraid @manuel-rw
apps/docs/docs/integrations/uptime-kuma @ajnart
packages/integrations/src/uptime-kuma @ajnart
# Traefik - by @Nerdy-Technician
apps/docs/docs/integrations/traefik @Nerdy-Technician
apps/docs/docs/widgets/traefik @Nerdy-Technician
packages/api/src/router/widgets/traefik.ts @Nerdy-Technician
packages/integrations/src/traefik @Nerdy-Technician
packages/request-handler/src/traefik.ts @Nerdy-Technician
packages/widgets/src/traefik @Nerdy-Technician
# Exempt Renovate-managed files (no owners)
package.json
**/package.json
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,54 @@
---
title: 'Bazarr'
description: "Bazarr is a companion application to Sonarr and Radarr that manages and downloads subtitles."
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { bazarrIntegration } from '.';
import { bazarrWidget } from '@site/docs/widgets/bazarr';
<IntegrationHeader
integration={bazarrIntegration}
categories={['Subtitles', 'Media']}
/>
Bazarr complements Sonarr and Radarr by managing subtitle downloads. The Homarr integration uses your Bazarr API key to fetch missing subtitle counts and health badges.
### Screenshots
import apiKeySettings from './img/api-key-settings.png';
import widgetOverview from '@site/docs/widgets/bazarr/img/widget-overview.png';
<div className="flex flex-col gap-4">
<div>
<p>API key location in Bazarr (<strong>Settings → General → Security</strong>):</p>
<img src={apiKeySettings} alt="bazarr-api-key-settings" width={128 * 4} />
</div>
<div>
<p>Bazarr widget on a Homarr board:</p>
<img src={widgetOverview} alt="bazarr-widget-on-board" width={128 * 4} />
</div>
</div>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{ widget: bazarrWidget }]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
You can find your Bazarr API key under **Settings → General → Security**.
<IntegrationSecrets secrets={[{
credentials: ['apiKey'],
steps: [
"Open Bazarr and go to Settings → General → Security.",
"Copy the API key and paste it into Homarr when creating the integration.",
],
}]} />
@@ -0,0 +1,9 @@
import { IntegrationDefinition } from "@site/src/types";
export const bazarrIntegration: IntegrationDefinition = {
name: "Bazarr",
description:
"Bazarr is a companion application to Sonarr and Radarr that manages and downloads subtitles for your media library.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bazarr.svg",
path: "../../integrations/bazarr",
};
@@ -0,0 +1,57 @@
---
title: "Traefik"
description: "Cloud-native reverse proxy and load balancer"
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { traefikIntegration } from ".";
import { traefikWidget } from "../../widgets/traefik";
<IntegrationHeader integration={traefikIntegration} categories={["Reverse proxy", "Monitoring"]} />
### Widgets & Capabilities
<IntegrationCapabilites items={[{ widget: traefikWidget }]} />
### Adding the integration
<AddingIntegration />
:::info URL configuration
Use the URL where Traefik exposes its dashboard/API, for example `http://traefik:8080` or `https://traefik.example.com`. Homarr calls Traefik endpoints under `/api` automatically.
:::
:::warning API requirement
Traefik must have the API enabled. For local or internal setups this is commonly exposed through `api.insecure=true`; for production setups, expose the `api@internal` service through an authenticated router.
:::
### Secrets
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [
"Choose this option when the Traefik API is only reachable from a trusted internal network and does not require authentication.",
],
},
{
credentials: ["username", "password"],
steps: [
"Use this option when your Traefik dashboard route is protected by Basic Auth.",
"Enter the same username and password required to open the Traefik dashboard.",
],
},
{
credentials: ["apiKey"],
steps: [
"Use this option when your Traefik dashboard route accepts a bearer token.",
"Paste the token into the API Key field in Homarr.",
],
},
]}
/>
@@ -0,0 +1,8 @@
import { IntegrationDefinition } from "@site/src/types";
export const traefikIntegration: IntegrationDefinition = {
name: "Traefik",
description: "Cloud-native reverse proxy and load balancer",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg",
path: "../../integrations/traefik",
};
+13 -1
View File
@@ -70,4 +70,16 @@ API key authentication enables programmatic access for:
- Automation scripts and CI/CD pipelines
- Third-party integrations
- Custom dashboards and monitoring tools
- Container orchestration systems that need to sync app definitions
- Container orchestration systems that need to sync app definitions
## Board Automation
The OpenAPI and MCP APIs include board management endpoints for common automation workflows:
- Create, duplicate, rename and delete boards
- Change board visibility
- Update board visual and behavior settings
- Set desktop and mobile home boards for the current user
- Configure global board defaults such as public home boards and status behavior
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+41
View File
@@ -0,0 +1,41 @@
---
title: 'Bazarr'
description: 'Displays missing subtitle counts and health indicators from your Bazarr instance.'
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { bazarrWidget } from '.';
import { bazarrIntegration } from '@site/docs/integrations/bazarr';
<WidgetHeader
widget={bazarrWidget}
categories={['Subtitles', 'Media']}
/>
This widget shows subtitle status from your Bazarr instance, including missing episode and movie subtitles, provider issues, and health warnings.
### Screenshots
import widgetOverview from './img/widget-overview.png';
<div className="flex flex-col gap-2">
<img src={widgetOverview} alt="bazarr-widget-overview" width={128 * 4} />
</div>
### Supported Integrations
<WidgetIntegrations items={[{
integration: bazarrIntegration,
note: "Displays missing subtitle counts and health indicators"
}]} />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={bazarrWidget.configuration} />
+37
View File
@@ -0,0 +1,37 @@
import { WidgetDefinition } from "@site/src/types";
import { IconBadgeCc } from "@tabler/icons-react";
export const bazarrWidget: WidgetDefinition = {
icon: IconBadgeCc,
name: "Bazarr",
description: "Displays missing subtitle counts and health indicators from your Bazarr instance.",
path: "../../widgets/bazarr",
configuration: {
items: [
{
name: "Show missing episodes",
description: "Displays the number of episodes with missing subtitles",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show missing movies",
description: "Displays the number of movies with missing subtitles",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show provider issues",
description: "Displays the number of subtitle providers with issues",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show health issues",
description: "Displays the number of Bazarr health issues",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

@@ -0,0 +1,65 @@
---
title: 'Media Missing & Queue'
description: 'Track missing media and active downloads from Radarr and Sonarr'
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { mediaMissingWidget } from '.';
import { radarrIntegration } from '@site/docs/integrations/radarr';
import { sonarrIntegration } from '@site/docs/integrations/sonarr';
<WidgetHeader
widget={mediaMissingWidget}
categories={['Media', 'Downloads']}
/>
Monitor which movies and episodes are missing from your library, and track what is currently being downloaded. The widget pulls data from Radarr and Sonarr and groups results by type in two tabs.
### Screenshots
import widgetMissing from './img/missing.png';
import widgetQueued from './img/queued.png';
import widgetCompact from './img/compact.png';
<div className="flex gap-2 flex-wrap">
<img src={widgetMissing} alt="media-missing widget showing missing movies and episodes in a grid" width={360} />
<img src={widgetQueued} alt="media-missing tab showing download queue with progress bars" width={256} />
<img src={widgetCompact} alt="media-missing widget in a compact wide-and-short size" width={256} />
</div>
### Supported Integrations
<WidgetIntegrations items={[{
integration: radarrIntegration,
}, {
integration: sonarrIntegration,
}]} />
### Tabs
| Tab | Description |
|---|---|
| **Missing** | Lists movies (Radarr) and episodes (Sonarr) that are monitored but not yet available in your library. |
| **Queued** | Shows items currently being downloaded with a progress bar and estimated time remaining. |
### Sizing
The widget adapts to its container size, so it looks good at any grid size:
- **Wide** — fits as many columns of cards as the width allows, filling larger widgets instead of stretching a single column.
- **Short** — packs items into more, tighter columns so they stay visible in the limited height.
- **Narrow (1×N)** — tab labels collapse to counts only and posters scale down.
Each item shows its poster art (with the artwork bleeding softly behind the card), a type badge, and — on the queue — an animated progress bar with the estimated time remaining.
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={mediaMissingWidget.configuration} />
@@ -0,0 +1,31 @@
import { WidgetDefinition } from "@site/src/types";
import { IconMovie } from "@tabler/icons-react";
export const mediaMissingWidget: WidgetDefinition = {
icon: IconMovie,
name: "Media Missing & Queue",
description: "Track missing media and active downloads from Radarr and Sonarr",
path: "../../widgets/media-missing",
configuration: {
items: [
{
name: "Show Missing",
description: "Display the missing media tab, listing movies or episodes not yet downloaded",
values: { type: "boolean" },
defaultValue: "true",
},
{
name: "Show Queued",
description: "Display the download queue tab with progress for each item",
values: { type: "boolean" },
defaultValue: "true",
},
{
name: "Page Size",
description: "Maximum number of items to fetch per integration",
values: { type: "string" },
defaultValue: "10",
},
],
},
};
+35
View File
@@ -0,0 +1,35 @@
---
title: "Traefik"
description: "Displays Traefik router, service, middleware, and entry point counts."
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { traefikWidget } from ".";
import { traefikIntegration } from "@site/docs/integrations/traefik";
<WidgetHeader widget={traefikWidget} categories={["Monitoring", "Reverse proxy"]} />
This widget displays a compact overview of your Traefik instance, including total routers, entry points, protocol-specific router/service/middleware counts, and warning or error totals when Traefik reports them.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: traefikIntegration,
note: "Reads data from Traefik's dashboard/API endpoints",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={traefikWidget.configuration} />
+31
View File
@@ -0,0 +1,31 @@
import { WidgetDefinition } from "@site/src/types";
import { IconRoute } from "@tabler/icons-react";
export const traefikWidget: WidgetDefinition = {
icon: IconRoute,
name: "Traefik",
description: "Overview of Traefik routers, services, middlewares, and entry points.",
path: "../../widgets/traefik",
configuration: {
items: [
{
name: "Show TCP",
description: "Display TCP routers, services, and middlewares.",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show UDP",
description: "Display UDP routers and services.",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show entry points",
description: "Display the configured Traefik entry point names.",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
+1 -1
View File
@@ -1 +1 @@
{"openapi":"3.1.0","info":{"title":"Homarr API documentation","version":"1.0.0"},"servers":[{"url":"http://localhost:3000"}],"externalDocs":{"url":"https://homarr.dev"},"paths":{"/api/apps/paginated":{"get":{"operationId":"appRouter-getPaginated","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"search","schema":{"type":"string"}},{"in":"query","name":"pageSize","schema":{"default":10,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},{"in":"query","name":"page","schema":{"default":1,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}},"totalCount":{"type":"number"}},"required":["items","totalCount"],"additionalProperties":false}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.NOT_FOUND"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps":{"get":{"operationId":"appRouter-all","tags":["apps"],"security":[{"apikey":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}},"post":{"operationId":"appRouter-create","tags":["apps"],"security":[{"apikey":[]}],"parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":64},"description":{"anyOf":[{"type":"string","maxLength":512},{"type":"null"}]},"iconUrl":{"type":"string","minLength":1},"href":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^(?!javascript)[a-zA-Z]*:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]},"pingUrl":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^https?:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]}},"required":["name","description","iconUrl","href","pingUrl"]}}}},"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"allOf":[{"type":"object","properties":{"appId":{"type":"string"}},"required":["appId"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}]}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps/search":{"get":{"operationId":"appRouter-search","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"query","schema":{"type":"string"},"required":true},{"in":"queLine truncated
{"openapi":"3.1.0","info":{"title":"Homarr API documentation","version":"1.1.0"},"servers":[{"url":"http://localhost:3000"}],"externalDocs":{"url":"https://homarr.dev"},"paths":{"/api/apps/paginated":{"get":{"operationId":"appRouter-getPaginated","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"search","schema":{"type":"string"}},{"in":"query","name":"pageSize","schema":{"default":10,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},{"in":"query","name":"page","schema":{"default":1,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}},"totalCount":{"type":"number"}},"required":["items","totalCount"],"additionalProperties":false}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"404":{"description":"Not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.NOT_FOUND"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps":{"get":{"operationId":"appRouter-all","tags":["apps"],"security":[{"apikey":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}},"post":{"operationId":"appRouter-create","tags":["apps"],"security":[{"apikey":[]}],"parameters":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":64},"description":{"anyOf":[{"type":"string","maxLength":512},{"type":"null"}]},"iconUrl":{"type":"string","minLength":1},"href":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^(?!javascript)[a-zA-Z]*:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]},"pingUrl":{"anyOf":[{"anyOf":[{"type":"string","pattern":"^https?:\\/\\/"},{"type":"string","const":""}]},{"type":"null"}]}},"required":["name","description","iconUrl","href","pingUrl"]}}}},"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"allOf":[{"type":"object","properties":{"appId":{"type":"string"}},"required":["appId"],"additionalProperties":false},{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"description":{"anyOf":[{"type":"string"},{"type":"null"}]},"iconUrl":{"type":"string"},"href":{"anyOf":[{"type":"string"},{"type":"null"}]},"pingUrl":{"anyOf":[{"type":"string"},{"type":"null"}]}},"required":["id","name","description","iconUrl","href","pingUrl"],"additionalProperties":false}]}}}},"400":{"description":"Invalid input data","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.BAD_REQUEST"}}}},"401":{"description":"Authorization not provided","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.UNAUTHORIZED"}}}},"403":{"description":"Insufficient access","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.FORBIDDEN"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/error.INTERNAL_SERVER_ERROR"}}}}}}},"/api/apps/search":{"get":{"operationId":"appRouter-search","tags":["apps"],"security":[{"apikey":[]}],"parameters":[{"in":"query","name":"query","schema":{"type":"string"},"required":true},{"in":"queLine truncated
@@ -79,14 +79,16 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
const userId = session?.user.id ?? "anonymous";
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<Suspense>
<QueryCacheHydration userId={userId} boardId={board.id} />
</Suspense>
<DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
<>
<Suspense>
<QueryCacheHydration userId={userId} boardId={board.id} />
</Suspense>
<HydrationBoundary state={dehydrate(queryClient)}>
<IntegrationProvider integrations={integrations}>
<DynamicClientBoard />
</IntegrationProvider>
</HydrationBoundary>
</>
);
},
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
+1 -1
View File
@@ -142,7 +142,7 @@ export default async function Layout(props: {
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
</head>
<body className={["font-sans", fontSans.variable].join(" ")}>
<body className={[fontSans.className, fontSans.variable].join(" ")} suppressHydrationWarning>
<Analytics enabled={serverSettings.analytics.enableGeneral} />
<StackedProvider>
<Notifications pauseResetOnHover="notification" />
@@ -17,6 +17,7 @@ import { showErrorNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
import { createCustomErrorParams } from "@homarr/validation/form/i18n";
import { optionalEmailSchema } from "@homarr/validation/email";
import { userPasswordSchema } from "@homarr/validation/user";
import { GroupSelectModal } from "~/components/access/group-select-modal";
@@ -60,7 +61,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC
const generalForm = useZodForm(
z.object({
username: z.string().min(1),
email: z.string().email().or(z.string().length(0).optional()),
email: optionalEmailSchema,
}),
{
initialValues: {
@@ -30,7 +30,19 @@ export const InheritAccessTable = <TPermission extends string>({
</TableTr>
</TableThead>
<TableTbody>
{accessQueryData.inherited.map(({ group, permission }) => {
{Array.from(
accessQueryData.inherited
.reduce((map, entry) => {
const existing = map.get(entry.group.id);
if (!existing) {
map.set(entry.group.id, entry);
} else if (getPermissionsWithChildren([entry.permission]).includes(existing.permission)) {
map.set(entry.group.id, entry);
}
return map;
}, new Map<string, (typeof accessQueryData.inherited)[number]>())
.values(),
).map(({ group, permission }) => {
const entityPermission =
permission in mapPermissions
? mapPermissions[permission]
+2 -2
View File
@@ -6,7 +6,7 @@
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.9.0"
"pnpm": ">=11.10.0"
},
"packageManager": "pnpm@11.9.0"
"packageManager": "pnpm@11.10.0"
}
+20 -6
View File
@@ -340,20 +340,27 @@
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/synology" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/synology.svg" alt="Synology DiskStation" width="90" height="90" />
<br/>
<p align="center">Synology<br/>DiskStation</p>
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/tdarr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png" alt="Tdarr" width="90" height="90" />
<br/>
<p align="center">Tdarr</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/technitium-dns" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/selfhst/icons/svg/technitium.svg" alt="Technitium DNS" width="90" height="90" />
<br/>
<p align="center">Technitium<br/>DNS</p>
</a>
</td></tr>
<tr><td align="center">
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/tracearr" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg" alt="Tracearr" width="90" height="90" />
<br/>
@@ -361,6 +368,13 @@
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/traefik" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg" alt="Traefik" width="90" height="90" />
<br/>
<p align="center">Traefik</p>
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/transmission" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg" alt="Transmission" width="90" height="90" />
<br/>
@@ -387,8 +401,8 @@
<br/>
<p align="center">Unifi<br/>Controller</p>
</a>
</td>
<td align="center">
</td></tr>
<tr><td align="center">
<a href="https://homarr.dev/docs/integrations/unraid" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/unraid.svg" alt="Unraid" width="90" height="90" />
<br/>
+2 -2
View File
@@ -63,9 +63,9 @@
},
"engines": {
"node": ">=24.18.0",
"pnpm": ">=11.9.0"
"pnpm": ">=11.10.0"
},
"packageManager": "pnpm@11.9.0",
"packageManager": "pnpm@11.10.0",
"dependencies": {
"jsonpath-plus": "catalog:"
}
+4
View File
@@ -9,6 +9,8 @@ import { iconsRouter } from "./router/icons";
import { infoRouter } from "./router/info";
import { integrationRouter } from "./router/integration/integration-router";
import { inviteRouter } from "./router/invite";
import { bazarrRouter } from "./router/widgets/bazarr";
import { serverSettingsRouter } from "./router/serverSettings";
import { beszelRouter } from "./router/widgets/beszel";
import { calendarRouter } from "./router/widgets/calendar";
import { dnsHoleRouter } from "./router/widgets/dns-hole";
@@ -28,6 +30,8 @@ export const mcpRouter = createTRPCRouter({
info: infoRouter,
integration: integrationRouter,
invite: inviteRouter,
bazarr: bazarrRouter,
serverSettings: serverSettingsRouter,
beszel: beszelRouter,
calendar: calendarRouter,
dnsHole: dnsHoleRouter,
+3 -1
View File
@@ -6,6 +6,7 @@ import { appRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { infoRouter } from "./router/info";
import { inviteRouter } from "./router/invite";
import { serverSettingsRouter } from "./router/serverSettings";
import { userRouter } from "./router/user";
import { createTRPCRouter } from "./trpc";
@@ -14,13 +15,14 @@ export const openApiRouter = createTRPCRouter({
boardRouter,
infoRouter,
inviteRouter,
serverSettingsRouter,
userRouter,
});
export const openApiDocument = (base: string) =>
generateOpenApiDocument(openApiRouter, {
title: "Homarr API documentation",
version: "1.0.0",
version: "1.1.0",
baseUrl: base,
docsUrl: "https://homarr.dev",
securitySchemes: {
+113 -36
View File
@@ -35,6 +35,7 @@ import {
everyoneGroup,
getPermissionsWithChildren,
getPermissionsWithParents,
widgetDefaultSizes,
widgetKinds,
} from "@homarr/definitions";
import { importOldmarrAsync } from "@homarr/old-import";
@@ -330,7 +331,16 @@ export const boardRouter = createTRPCRouter({
}),
duplicateBoard: permissionRequiredProcedure
.requiresPermission("board-create")
.meta({
openapi: { method: "POST", path: "/api/boards/{id}/duplicate", tags: ["boards"], protect: true },
mcp: {
enabled: true,
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 }",
},
})
.input(boardDuplicateSchema)
.output(z.object({ boardId: z.string() }))
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await noBoardWithSimilarNameAsync(ctx.db, input.name);
@@ -544,33 +554,57 @@ export const boardRouter = createTRPCRouter({
});
},
});
return { boardId: newBoardId };
}),
renameBoard: protectedProcedure.input(boardRenameSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
renameBoard: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/name", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Rename a board by ID. Requires full permission on the board. REQUIRED: id (board ID), name (new unique board name)",
},
})
.input(boardRenameSchema)
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
await noBoardWithSimilarNameAsync(ctx.db, input.name, [input.id]);
await noBoardWithSimilarNameAsync(ctx.db, input.name, [input.id]);
await ctx.db.update(boards).set({ name: input.name }).where(eq(boards.id, input.id));
}),
changeBoardVisibility: protectedProcedure.input(boardChangeVisibilitySchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
await ctx.db.update(boards).set({ name: input.name }).where(eq(boards.id, input.id));
}),
changeBoardVisibility: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/visibility", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Change board visibility. Requires full permission on the board. REQUIRED: id (board ID), visibility ('public' or 'private'). Home boards cannot be made private",
},
})
.input(boardChangeVisibilitySchema)
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
const boardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
if (
input.visibility !== "public" &&
(boardSettings.homeBoardId === input.id || boardSettings.mobileHomeBoardId === input.id)
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot make home board private",
});
}
if (
input.visibility !== "public" &&
(boardSettings.homeBoardId === input.id || boardSettings.mobileHomeBoardId === input.id)
) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot make home board private",
});
}
await ctx.db
.update(boards)
.set({ isPublic: input.visibility === "public" })
.where(eq(boards.id, input.id));
}),
await ctx.db
.update(boards)
.set({ isPublic: input.visibility === "public" })
.where(eq(boards.id, input.id));
}),
deleteBoard: protectedProcedure
.meta({
openapi: { method: "DELETE", path: "/api/boards/{id}", tags: ["boards"], protect: true },
@@ -587,16 +621,38 @@ export const boardRouter = createTRPCRouter({
await ctx.db.delete(boards).where(eq(boards.id, input.id));
}),
setHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
setHomeBoard: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/home", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Set the current user's desktop home board. Requires view permission on the board. REQUIRED: id (board ID)",
},
})
.input(z.object({ id: z.string() }))
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
setMobileHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await ctx.db.update(users).set({ homeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
setMobileHomeBoard: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/mobile-home", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Set the current user's mobile home board. Requires view permission on the board. REQUIRED: id (board ID)",
},
})
.input(z.object({ id: z.string() }))
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
await ctx.db.update(users).set({ mobileHomeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
await ctx.db.update(users).set({ mobileHomeBoardId: input.id }).where(eq(users.id, ctx.session.user.id));
}),
getHomeBoard: publicProcedure.query(async ({ ctx }) => {
const userId = ctx.session?.user.id;
const user = userId
@@ -752,7 +808,16 @@ export const boardRouter = createTRPCRouter({
}
}),
savePartialBoardSettings: protectedProcedure
.input(boardSavePartialSettingsSchema.and(z.object({ id: z.string() })))
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true },
mcp: {
enabled: true,
description:
"Update visual and behavior settings for a board. Requires modify permission. REQUIRED: id (board ID). Optional fields include pageTitle, metaTitle, logoImageUrl, faviconImageUrl, backgroundImageUrl, colors, opacity, customCss, itemRadius, and disableStatus",
},
})
.input(boardSavePartialSettingsSchema.extend({ id: z.string() }))
.output(z.void())
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
@@ -1438,21 +1503,33 @@ export const boardRouter = createTRPCRouter({
}
}
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
const fitsAt = (x: number, y: number) => {
if (x + defaultSize.width > layout.columnCount) return false;
for (let dy = 0; dy < defaultSize.height; dy++) {
const row = occupied[y + dy];
if (!row) continue;
for (let dx = 0; dx < defaultSize.width; dx++) {
if (row[x + dx]) return false;
}
}
return true;
};
let placed = false;
for (let y = 0; y < 9999 && !placed; y++) {
if (!occupied[y]) occupied.push(Array.from<boolean>({ length: layout.columnCount }).fill(false));
const occupiedRow = occupied[y];
if (!occupiedRow) continue;
for (let x = 0; x < layout.columnCount && !placed; x++) {
if (!occupiedRow[x]) {
if (fitsAt(x, y)) {
layoutRows.push({
itemId,
sectionId: emptySection.id,
layoutId: layout.id,
xOffset: x,
yOffset: y,
width: 1,
height: 1,
width: defaultSize.width,
height: defaultSize.height,
});
placed = true;
}
+79 -1
View File
@@ -1,6 +1,14 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { getServerSettingByKeyAsync, getServerSettingsAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { and, eq, inArray } from "@homarr/db";
import {
getServerSettingByKeyAsync,
getServerSettingsAsync,
insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync,
} from "@homarr/db/queries";
import { boards, serverSettings } from "@homarr/db/schema";
import type { ServerSettings } from "@homarr/server-settings";
import { defaultServerSettingsKeys } from "@homarr/server-settings";
import { settingsInitSchema } from "@homarr/validation/settings";
@@ -8,6 +16,15 @@ import { settingsInitSchema } from "@homarr/validation/settings";
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, publicProcedure } from "../trpc";
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
const boardServerSettingsSchema = z.object({
homeBoardId: z.string().nullable(),
mobileHomeBoardId: z.string().nullable(),
enableStatusByDefault: z.boolean(),
forceDisableStatus: z.boolean(),
}) satisfies z.ZodType<ServerSettings["board"]>;
const boardServerSettingsUpdateSchema = boardServerSettingsSchema.partial();
export const serverSettingsRouter = createTRPCRouter({
getCulture: publicProcedure.query(async ({ ctx }) => {
return await getServerSettingByKeyAsync(ctx.db, "culture");
@@ -15,6 +32,67 @@ export const serverSettingsRouter = createTRPCRouter({
getAll: permissionRequiredProcedure.requiresPermission("admin").query(async ({ ctx }) => {
return await getServerSettingsAsync(ctx.db);
}),
getBoardSettings: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
openapi: { method: "GET", path: "/api/settings/board", tags: ["settings"], protect: true },
mcp: {
enabled: true,
description:
"Get global board defaults, including desktop/mobile home board IDs and status behavior. Requires admin permission",
},
})
.input(z.void())
.output(boardServerSettingsSchema)
.query(async ({ ctx }) => {
return await getServerSettingByKeyAsync(ctx.db, "board");
}),
updateBoardSettings: permissionRequiredProcedure
.requiresPermission("admin")
.meta({
openapi: { method: "PATCH", path: "/api/settings/board", tags: ["settings"], protect: true },
mcp: {
enabled: true,
description:
"Update global board defaults. Requires admin permission. Optional fields: homeBoardId, mobileHomeBoardId, enableStatusByDefault, forceDisableStatus. Home board IDs must reference public boards or be null",
},
})
.input(boardServerSettingsUpdateSchema)
.output(boardServerSettingsSchema)
.mutation(async ({ ctx, input }) => {
const inputBoardIds = [input.homeBoardId, input.mobileHomeBoardId].filter(
(id) => id !== undefined && id !== null,
);
if (inputBoardIds.length > 0) {
const publicBoards = await ctx.db.query.boards.findMany({
columns: { id: true },
where: and(inArray(boards.id, inputBoardIds), eq(boards.isPublic, true)),
});
const publicBoardIds = new Set(publicBoards.map((board) => board.id));
const invalidBoardIds = inputBoardIds.filter((id) => !publicBoardIds.has(id));
if (invalidBoardIds.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Board settings home board IDs must reference public boards: ${invalidBoardIds.join(", ")}`,
});
}
}
const current = await getServerSettingByKeyAsync(ctx.db, "board");
const next = { ...current, ...input };
const existing = await ctx.db.query.serverSettings.findFirst({
where: eq(serverSettings.settingKey, "board"),
});
if (existing) {
await updateServerSettingByKeyAsync(ctx.db, "board", next);
} else {
await insertServerSettingByKeyAsync(ctx.db, "board", next);
}
return next;
}),
saveSettings: permissionRequiredProcedure
.requiresPermission("admin")
.input(
@@ -1,9 +1,9 @@
import SuperJSON from "superjson";
import { stringify } from "superjson";
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { serverSettings } from "@homarr/db/schema";
import { boards, serverSettings } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
@@ -33,7 +33,7 @@ describe("getAll server settings", () => {
await db.insert(serverSettings).values([
{
settingKey: defaultServerSettingsKeys[0],
value: SuperJSON.stringify(defaultServerSettings.analytics),
value: stringify(defaultServerSettings.analytics),
},
]);
@@ -67,7 +67,7 @@ describe("saveSettings", () => {
await db.insert(serverSettings).values([
{
settingKey: defaultServerSettingsKeys[0],
value: SuperJSON.stringify(defaultServerSettings.analytics),
value: stringify(defaultServerSettings.analytics),
},
]);
@@ -82,7 +82,7 @@ describe("saveSettings", () => {
expect(dbSettings).toStrictEqual([
{
settingKey: "analytics",
value: SuperJSON.stringify({
value: stringify({
enableGeneral: true,
instanceId: null,
}),
@@ -90,3 +90,91 @@ describe("saveSettings", () => {
]);
});
});
describe("board settings API", () => {
test("getBoardSettings should return board defaults", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
await expect(caller.getBoardSettings()).resolves.toStrictEqual(defaultServerSettings.board);
});
test("updateBoardSettings should insert settings when defaults are not persisted", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "default", isPublic: true });
const result = await caller.updateBoardSettings({
homeBoardId: boardId,
forceDisableStatus: true,
});
expect(result).toStrictEqual({
...defaultServerSettings.board,
homeBoardId: boardId,
forceDisableStatus: true,
});
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
{
settingKey: "board",
value: stringify(result),
},
]);
});
test("updateBoardSettings should update existing settings", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "default", isPublic: true });
await caller.updateBoardSettings({ homeBoardId: boardId });
const result = await caller.updateBoardSettings({
mobileHomeBoardId: boardId,
enableStatusByDefault: false,
});
expect(result).toStrictEqual({
...defaultServerSettings.board,
homeBoardId: boardId,
mobileHomeBoardId: boardId,
enableStatusByDefault: false,
});
const dbSettings = await db.select().from(serverSettings);
expect(dbSettings).toStrictEqual([
{
settingKey: "board",
value: stringify(result),
},
]);
});
test("updateBoardSettings should reject private home boards", async () => {
const db = createDb();
const caller = serverSettingsRouter.createCaller({
db,
deviceType: undefined,
session: defaultSession,
});
const boardId = createId();
await db.insert(boards).values({ id: boardId, name: "private", isPublic: false });
const actAsync = async () => await caller.updateBoardSettings({ homeBoardId: boardId });
await expect(actAsync()).rejects.toThrow("must reference public boards");
});
});
+21
View File
@@ -0,0 +1,21 @@
import { bazarrBadgesRequestHandler } from "@homarr/request-handler/bazarr";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const bazarrRouter = createTRPCRouter({
getBadges: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Get missing subtitle counts, provider issues, and health warnings for a Bazarr integration. REQUIRED: integrationId from integration_all",
},
})
.concat(createOneIntegrationMiddleware("query", "bazarr"))
.query(async ({ ctx }) => {
const innerHandler = bazarrBadgesRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
});
+100 -42
View File
@@ -1,18 +1,24 @@
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { LiveStatsEvent } from "@homarr/integrations/types";
import {
beszelAlertsRequestHandler,
beszelStatsRequestHandler,
beszelSystemsRequestHandler,
} from "@homarr/request-handler/beszel";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "beszelRouter" });
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
export const beszelRouter = createTRPCRouter({
getSystems: publicProcedure
.meta({
@@ -26,8 +32,9 @@ export const beszelRouter = createTRPCRouter({
.query(async ({ ctx }) => {
const integrationIds = ctx.integrations.map((i) => i.id);
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
const settled = await Promise.allSettled(
ctx.integrations.map(async (integration) => {
const results = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
@@ -37,25 +44,18 @@ export const beszelRouter = createTRPCRouter({
systems: data,
updatedAt: timestamp,
};
}),
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
systems: [],
updatedAt: new Date(0),
error: errorMessage(error),
}),
},
);
const results = settled.map((result, index) => {
if (result.status === "fulfilled") return result.value;
const integration = ctx.integrations[index];
logger.warn("getSystems integration failed", {
userId: ctx.session?.user?.id,
integrationId: integration?.id,
error: result.reason,
});
return {
integrationId: integration?.id ?? "unknown",
integrationName: integration?.name ?? "unknown",
integrationUrl: integration?.url ?? "",
systems: [],
updatedAt: new Date(0),
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
};
});
logger.debug("getSystems completed", {
userId: ctx.session?.user?.id,
integrationIds,
@@ -88,8 +88,9 @@ export const beszelRouter = createTRPCRouter({
includeHistory: input.includeHistory,
maxHistoryItems: input.maxHistoryItems,
});
const settled = await Promise.allSettled(
ctx.integrations.map(async (integration) => {
const results = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
includeHistory: input.includeHistory,
maxHistoryItems: input.maxHistoryItems,
@@ -115,26 +116,19 @@ export const beszelRouter = createTRPCRouter({
systemNameMap,
updatedAt: alertsResult.timestamp,
};
}),
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
alerts: [],
history: [],
systemNameMap: {},
updatedAt: new Date(0),
error: errorMessage(error),
}),
},
);
const results = settled.map((result, index) => {
if (result.status === "fulfilled") return result.value;
const integration = ctx.integrations[index];
logger.warn("getAlerts integration failed", {
userId: ctx.session?.user?.id,
integrationId: integration?.id,
error: result.reason,
});
return {
integrationId: integration?.id ?? "unknown",
integrationName: integration?.name ?? "unknown",
alerts: [],
history: [],
systemNameMap: {},
updatedAt: new Date(0),
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
};
});
logger.debug("getAlerts completed", {
userId: ctx.session?.user?.id,
integrationIds,
@@ -197,8 +191,72 @@ export const beszelRouter = createTRPCRouter({
systemStats: [],
containerStats: [],
updatedAt: new Date(0),
error: error instanceof Error ? error.message : String(error),
error: errorMessage(error),
};
}
}),
subscribeSystemStats: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.input(
z.object({
systemId: z.string(),
}),
)
.subscription(({ ctx, input }) => {
return observable<LiveStatsEvent>((emit) => {
const controller = new AbortController();
let isActive = true;
void (async () => {
const integration = ctx.integrations[0];
if (!integration) {
emit.error(
new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" }),
);
return;
}
try {
const instance = await createIntegrationAsync(integration);
if (typeof instance.subscribeRealtimeMetrics === "function") {
await instance.subscribeRealtimeMetrics(
input.systemId,
(event) => {
if (isActive) emit.next(event);
},
controller.signal,
);
} else {
emit.error(
new TRPCError({
code: "METHOD_NOT_SUPPORTED",
message: `Integration ${integration.kind} does not support realtime metrics`,
}),
);
}
} catch (error) {
if (isActive) {
emit.error(
error instanceof TRPCError
? error
: new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: error instanceof Error ? error.message : String(error),
}),
);
}
}
if (isActive) {
emit.complete();
}
})();
return () => {
isActive = false;
controller.abort();
};
});
}),
});
@@ -49,6 +49,7 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.pauseQueueAsync();
}),
);
downloadClientRequestHandler.invalidateCache();
}),
pauseItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
@@ -60,6 +61,7 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.pauseItemAsync(input.item);
}),
);
downloadClientRequestHandler.invalidateCache();
}),
resume: protectedProcedure
.meta({
@@ -77,6 +79,7 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.resumeQueueAsync();
}),
);
downloadClientRequestHandler.invalidateCache();
}),
resumeItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
@@ -88,6 +91,7 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.resumeItemAsync(input.item);
}),
);
downloadClientRequestHandler.invalidateCache();
}),
deleteItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
@@ -99,5 +103,6 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
}),
);
downloadClientRequestHandler.invalidateCache();
}),
});
+3
View File
@@ -9,6 +9,7 @@ export const widgetRouter = createTRPCRouter({
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
paperlessNgx: lazy(() => import("./paperless-ngx").then((mod) => mod.paperlessNgxRouter)),
bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)),
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
@@ -20,6 +21,7 @@ export const widgetRouter = createTRPCRouter({
calendar: lazy(() => import("./calendar").then((mod) => mod.calendarRouter)),
downloads: lazy(() => import("./downloads").then((mod) => mod.downloadsRouter)),
mediaRequests: lazy(() => import("./media-requests").then((mod) => mod.mediaRequestsRouter)),
mediaOrganizer: lazy(() => import("./media-organizer").then((mod) => mod.mediaOrganizerRouter)),
rssFeed: lazy(() => import("./rssFeed").then((mod) => mod.rssFeedRouter)),
indexerManager: lazy(() => import("./indexer-manager").then((mod) => mod.indexerManagerRouter)),
healthMonitoring: lazy(() => import("./health-monitoring").then((mod) => mod.healthMonitoringRouter)),
@@ -38,6 +40,7 @@ export const widgetRouter = createTRPCRouter({
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
});
@@ -0,0 +1,33 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const mediaOrganizerRouter = createTRPCRouter({
getData: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"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")))
.input(z.object({ pageSize: z.number().min(1).max(50).default(10) }))
.query(async ({ ctx, input }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = mediaOrganizerRequestHandler.handler(integration, { pageSize: input.pageSize });
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integrationId: integration.id,
...data,
};
}),
);
return results;
}),
});
+12 -6
View File
@@ -45,7 +45,7 @@ export const releasesRouter = createTRPCRouter({
)
.query(async ({ ctx, input }) => {
const tokensByProvider = new Map<string, string>();
let allowedRepoIds: Set<string> | null = null;
const savedRepos = new Map<string, { providerUrl?: string }>();
if (input.itemId) {
try {
@@ -54,9 +54,15 @@ export const releasesRouter = createTRPCRouter({
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
const options = SuperJSON.parse<Record<string, unknown>>(item.options);
const repos = options.repositories as Array<{ id?: string }> | undefined;
const repos = options.repositories as
| Array<{ provider?: string; identifier: string; providerUrl?: string }>
| undefined;
if (repos) {
allowedRepoIds = new Set(repos.map((r) => r.id).filter((id): id is string => Boolean(id)));
for (const repo of repos) {
if (repo.provider && repo.identifier) {
savedRepos.set(`${repo.provider}:${repo.identifier}`, { providerUrl: repo.providerUrl });
}
}
}
const secrets = await ctx.db.query.widgetSecrets.findMany({
@@ -78,16 +84,16 @@ export const releasesRouter = createTRPCRouter({
return await Promise.all(
input.repositories.map(async (repository) => {
const repositoryId = repository.id ?? repository.identifier;
const savedRepo = savedRepos.get(`${repository.provider}:${repository.identifier}`);
try {
const useToken = allowedRepoIds === null || allowedRepoIds.has(repositoryId);
const response = await releasesRequestHandler
.handler({
id: repositoryId,
provider: repository.provider,
identifier: repository.identifier,
versionRegex: formatVersionFilterRegex(repository.versionFilter),
providerUrl: repository.providerUrl,
token: useToken ? tokensByProvider.get(repository.provider) : undefined,
providerUrl: savedRepo?.providerUrl ?? repository.providerUrl,
token: savedRepo ? tokensByProvider.get(repository.provider) : undefined,
})
.getDataAsync();
@@ -0,0 +1,31 @@
import { traefikRequestHandler } from "@homarr/request-handler/traefik";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const traefikRouter = createTRPCRouter({
getDashboard: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Retrieve Traefik dashboard data, including routers, services, middlewares, and entry points, for the given integration IDs.",
},
})
.concat(createManyIntegrationMiddleware("query", "traefik"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = traefikRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
});
}),
});
+6
View File
@@ -9,9 +9,14 @@ interface IntegrationLike {
kind: string;
}
interface Options<TIntegration extends IntegrationLike, TResult> {
fallback?: (integration: TIntegration, error: unknown) => TResult;
}
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
integrations: TIntegration[],
fn: (integration: TIntegration) => Promise<TResult>,
options?: Options<TIntegration, TResult>,
): Promise<TResult[]> {
const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
return settled.flatMap((result, index) => {
@@ -24,6 +29,7 @@ export async function settleIntegrationQueries<TIntegration extends IntegrationL
{ cause: result.reason },
),
);
if (options?.fallback && integration) return [options.fallback(integration, result.reason)];
return [];
});
}
+8
View File
@@ -2,16 +2,20 @@ import { expect, test, vi } from "vitest";
import { extractToolsFromProcedures } from "trpc-to-mcp";
import { appRouter } from "../router/app";
import { boardRouter } from "../router/board";
import { infoRouter } from "../router/info";
import { inviteRouter } from "../router/invite";
import { serverSettingsRouter } from "../router/serverSettings";
import { createTRPCRouter } from "../trpc";
vi.mock("@homarr/auth", () => ({}));
const mcpTestRouter = createTRPCRouter({
app: appRouter,
board: boardRouter,
info: infoRouter,
invite: inviteRouter,
serverSettings: serverSettingsRouter,
});
test("MCP tools should be extractable from the router", () => {
@@ -28,8 +32,12 @@ test("MCP tools should contain expected procedures", () => {
expect(toolNames).toContain("app_all");
expect(toolNames).toContain("app_byId");
expect(toolNames).toContain("app_create");
expect(toolNames).toContain("board_savePartialBoardSettings");
expect(toolNames).toContain("board_duplicateBoard");
expect(toolNames).toContain("info_getInfo");
expect(toolNames).toContain("invite_getAll");
expect(toolNames).toContain("serverSettings_getBoardSettings");
expect(toolNames).toContain("serverSettings_updateBoardSettings");
});
test("MCP tools should have descriptions", () => {
+9
View File
@@ -14,3 +14,12 @@ test("OpenAPI documentation should be generated", () => {
// Assert
expect(act).not.toThrow();
});
test("OpenAPI documentation should expose board automation endpoints", () => {
const document = openApiDocument("https://homarr.dev");
expect(document.info.version).toBe("1.1.0");
expect(document.paths).toHaveProperty("/api/boards/{id}/settings");
expect(document.paths).toHaveProperty("/api/boards/{id}/duplicate");
expect(document.paths).toHaveProperty("/api/settings/board");
});
@@ -1,5 +1,5 @@
import { CredentialsSignin } from "@auth/core/errors";
import { z } from "zod/v4";
import type { z } from "zod/v4";
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -7,6 +7,7 @@ import type { Database, InferInsertModel } from "@homarr/db";
import { and, eq } from "@homarr/db";
import { users } from "@homarr/db/schema";
import type { ldapSignInSchema } from "@homarr/validation/user";
import { utf8EmailSchema } from "@homarr/validation/email";
import { env } from "../../../env";
import { LdapClient } from "../ldap-client";
@@ -52,8 +53,7 @@ export const authorizeWithLdapCredentialsAsync = async (
throw new CredentialsSignin(`User not found in LDAP username="${credentials.name}"`);
}
// Validate email
const mailResult = await z.string().email().safeParseAsync(ldapUser[env.AUTH_LDAP_USER_MAIL_ATTRIBUTE]);
const mailResult = await utf8EmailSchema.safeParseAsync(ldapUser[env.AUTH_LDAP_USER_MAIL_ATTRIBUTE]);
if (!mailResult.success) {
logger.error("User found in LDAP but with invalid or non-existing Email", {
@@ -295,13 +295,6 @@
"when": 1780764500000,
"tag": "0041_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 42,
"version": "5",
"when": 1783176100870,
"tag": "0042_add_widget_secret",
"breakpoints": true
}
]
}
@@ -57,13 +57,6 @@
"when": 1780764500000,
"tag": "0007_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1783176100870,
"tag": "0008_add_widget_secret",
"breakpoints": true
}
]
}
@@ -281,13 +281,6 @@
"when": 1780764500000,
"tag": "0039_merge_custom_widget_url",
"breakpoints": true
},
{
"idx": 40,
"version": "6",
"when": 1783176100870,
"tag": "0040_add_widget_secret",
"breakpoints": true
}
]
}
@@ -33,6 +33,7 @@ const integrationAliases: Partial<Record<IntegrationKind, readonly string[]>> =
paperlessNgx: ["paperless-ngx", "paperless"],
coolify: ["coolify"],
truenas: ["truenas"],
bazarr: ["bazarr"],
synology: ["synology", "diskstation"],
};
@@ -159,6 +159,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/archiveteam-warrior"
| "/docs/integrations/aria2"
| "/docs/integrations/audiobookshelf"
| "/docs/integrations/bazarr"
| "/docs/integrations/beszel"
| "/docs/integrations/coolify"
| "/docs/integrations/dash-dot"
@@ -183,6 +184,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/opnsense"
| "/docs/integrations/overseerr"
| "/docs/integrations/paperless-ngx"
| "/docs/integrations/patchmon"
| "/docs/integrations/peanut"
| "/docs/integrations/pi-hole"
| "/docs/integrations/plex"
@@ -196,9 +198,11 @@ export type HomarrDocumentationPath =
| "/docs/integrations/slskd"
| "/docs/integrations/sonarr"
| "/docs/integrations/speedtest-tracker"
| "/docs/integrations/synology"
| "/docs/integrations/tdarr"
| "/docs/integrations/technitium-dns"
| "/docs/integrations/tracearr"
| "/docs/integrations/traefik"
| "/docs/integrations/transmission"
| "/docs/integrations/truenas"
| "/docs/integrations/umami"
@@ -221,6 +225,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/anchor-note"
| "/docs/widgets/app"
| "/docs/widgets/audio-stats"
| "/docs/widgets/bazarr"
| "/docs/widgets/beszel-alerts"
| "/docs/widgets/beszel-system-grid"
| "/docs/widgets/beszel-system-stats"
@@ -240,6 +245,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/immich-album-carousel"
| "/docs/widgets/immich-server-stats"
| "/docs/widgets/indexer-manager"
| "/docs/widgets/media-missing"
| "/docs/widgets/media-releases"
| "/docs/widgets/media-request-list"
| "/docs/widgets/media-request-stats"
@@ -251,16 +257,18 @@ export type HomarrDocumentationPath =
| "/docs/widgets/notebook"
| "/docs/widgets/notifications"
| "/docs/widgets/paperless-ngx"
| "/docs/widgets/patchmon"
| "/docs/widgets/releases"
| "/docs/widgets/rss-feed"
| "/docs/widgets/smart-home-entity-state"
| "/docs/widgets/smart-home-execute-automation"
| "/docs/widgets/speedtest-tracker"
| "/docs/widgets/stock-price"
| "/docs/widgets/system-resources"
| "/docs/widgets/system-disks"
| "/docs/widgets/system-resources"
| "/docs/widgets/timetable"
| "/docs/widgets/tracearr"
| "/docs/widgets/traefik"
| "/docs/widgets/umami"
| "/docs/widgets/ups"
| "/docs/widgets/uptime-kuma"
@@ -281,6 +289,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/lidarr"
| "/docs/integrations/readarr"
| "/docs/integrations/prowlarr"
| "/docs/integrations/bazarr"
| "/docs/integrations/jellyfin"
| "/docs/integrations/emby"
| "/docs/integrations/plex"
@@ -318,6 +327,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/gluetun"
| "/docs/integrations/peanut"
| "/docs/integrations/beszel"
| "/docs/integrations/traefik"
| "/docs/widgets/clock"
| "/docs/widgets/weather"
| "/docs/widgets/app"
@@ -336,6 +346,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/media-request-list"
| "/docs/widgets/media-request-stats"
| "/docs/widgets/media-transcoding"
| "/docs/widgets/media-missing"
| "/docs/widgets/minecraft-server-status"
| "/docs/widgets/network-controller-summary"
| "/docs/widgets/network-controller-status"
@@ -349,12 +360,13 @@ export type HomarrDocumentationPath =
| "/docs/widgets/firewall"
| "/docs/widgets/notifications"
| "/docs/widgets/system-resources"
| "/docs/widgets/system-disks"
| "/docs/widgets/coolify"
| "/docs/widgets/system-disks"
| "/docs/widgets/timetable"
| "/docs/widgets/immich-server-stats"
| "/docs/widgets/immich-album-carousel"
| "/docs/widgets/paperless-ngx"
| "/docs/widgets/bazarr"
| "/docs/widgets/tracearr"
| "/docs/widgets/speedtest-tracker"
| "/docs/widgets/uptime-kuma"
@@ -366,4 +378,5 @@ export type HomarrDocumentationPath =
| "/docs/widgets/beszel-system-grid"
| "/docs/widgets/beszel-alerts"
| "/docs/widgets/beszel-system-stats"
| "/docs/widgets/traefik"
| "/docs/widgets/custom-api";
@@ -13,6 +13,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
lidarr: "lidarr",
readarr: "readarr",
prowlarr: "prowlarr",
bazarr: "bazarr",
jellyfin: "jellyfin",
emby: "emby",
plex: "plex",
@@ -50,6 +51,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
gluetun: "gluetun",
peaNut: "peanut",
beszel: "beszel",
traefik: "traefik",
archiveTeamWarrior: null,
mock: null,
};
@@ -19,6 +19,7 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
"mediaRequests-requestList": "media-request-list",
"mediaRequests-requestStats": "media-request-stats",
mediaTranscoding: "media-transcoding",
mediaMissing: "media-missing",
minecraftServerStatus: "minecraft-server-status",
networkControllerSummary: "network-controller-summary",
networkControllerStatus: "network-controller-status",
@@ -38,6 +39,7 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
"immich-serverStats": "immich-server-stats",
"immich-albumCarousel": "immich-album-carousel",
paperlessNgx: "paperless-ngx",
bazarr: "bazarr",
tracearr: "tracearr",
speedtestTracker: "speedtest-tracker",
uptimeKuma: "uptime-kuma",
@@ -50,5 +52,6 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
beszelSystemGrid: "beszel-system-grid",
beszelAlerts: "beszel-alerts",
beszelSystemStats: "beszel-system-stats",
traefik: "traefik",
customApi: "custom-api",
};
+23 -2
View File
@@ -96,7 +96,7 @@ export const integrationDefs = {
name: "Sonarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg",
category: ["calendar"],
category: ["calendar", "mediaOrganizer"],
documentationUrl: createDocumentationLink("/docs/integrations/sonarr"),
defaultPort: 8989,
apiKeySettingsPath: "/settings/general",
@@ -105,7 +105,7 @@ export const integrationDefs = {
name: "Radarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg",
category: ["calendar"],
category: ["calendar", "mediaOrganizer"],
documentationUrl: createDocumentationLink("/docs/integrations/radarr"),
defaultPort: 7878,
apiKeySettingsPath: "/settings/general",
@@ -137,6 +137,15 @@ export const integrationDefs = {
defaultPort: 9696,
apiKeySettingsPath: "/settings/general",
},
bazarr: {
name: "Bazarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/bazarr.svg",
category: ["subtitleManager"],
documentationUrl: createDocumentationLink("/docs/integrations/bazarr"),
defaultPort: 6767,
apiKeySettingsPath: "/settings/general",
},
jellyfin: {
name: "Jellyfin",
secretKinds: [["username", "password"], ["apiKey"]],
@@ -433,6 +442,14 @@ export const integrationDefs = {
category: ["vpn"],
documentationUrl: createDocumentationLink("/docs/integrations/gluetun"),
},
traefik: {
name: "Traefik",
secretKinds: [[], ["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg",
category: ["reverseProxy"],
documentationUrl: createDocumentationLink("/docs/integrations/traefik"),
defaultPort: 8080,
},
archiveTeamWarrior: {
name: "ArchiveTeam Warrior",
secretKinds: [[], ["username", "password"]],
@@ -461,6 +478,7 @@ export const integrationDefs = {
"smartHomeServer",
"ups",
"uptimeMonitoring",
"mediaOrganizer",
],
documentationUrl: null,
},
@@ -526,6 +544,7 @@ export const integrationCategories = [
"dnsHole",
"mediaService",
"calendar",
"mediaOrganizer",
"mediaSearch",
"mediaRelease",
"mediaRequest",
@@ -553,6 +572,8 @@ export const integrationCategories = [
"documents",
"mediaLibrary",
"uptimeMonitoring",
"subtitleManager",
"reverseProxy",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
@@ -20,6 +20,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
"mediaRequests-requestList": getIntegrationKindsByCategory("mediaRequest"),
"mediaRequests-requestStats": getIntegrationKindsByCategory("mediaRequest"),
mediaTranscoding: getIntegrationKindsByCategory("mediaTranscoding"),
mediaMissing: getIntegrationKindsByCategory("mediaOrganizer"),
networkControllerSummary: getIntegrationKindsByCategory("networkController"),
networkControllerStatus: getIntegrationKindsByCategory("networkController"),
indexerManager: getIntegrationKindsByCategory("indexerManager"),
@@ -33,6 +34,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
"immich-serverStats": ["immich"],
"immich-albumCarousel": ["immich"],
paperlessNgx: ["paperlessNgx"],
bazarr: ["bazarr"],
tracearr: ["tracearr"],
speedtestTracker: ["speedtestTracker"],
uptimeKuma: ["uptimeKuma"],
@@ -40,6 +42,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
vpn: getIntegrationKindsByCategory("vpn"),
archiveTeamWarrior: ["archiveTeamWarrior"],
anchorNote: ["anchor"],
traefik: ["traefik"],
};
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
+5
View File
@@ -17,6 +17,7 @@ export const widgetKinds = [
"mediaRequests-requestList",
"mediaRequests-requestStats",
"mediaTranscoding",
"mediaMissing",
"minecraftServerStatus",
"networkControllerSummary",
"networkControllerStatus",
@@ -36,6 +37,7 @@ export const widgetKinds = [
"immich-serverStats",
"immich-albumCarousel",
"paperlessNgx",
"bazarr",
"tracearr",
"speedtestTracker",
"uptimeKuma",
@@ -48,6 +50,7 @@ export const widgetKinds = [
"beszelSystemGrid",
"beszelAlerts",
"beszelSystemStats",
"traefik",
"customApi",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -56,4 +59,6 @@ export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; hei
uptimeKuma: { width: 2, height: 3 },
audioStats: { width: 2, height: 2 },
paperlessNgx: { width: 2, height: 2 },
mediaMissing: { width: 4, height: 3 },
bazarr: { width: 2, height: 2 },
};
@@ -2,6 +2,7 @@ import type { IntegrationKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { BeszelIntegration } from "../beszel/beszel-integration";
import { BazarrIntegration } from "../bazarr/bazarr-integration";
import { AnchorIntegration } from "../anchor/anchor-integration";
import { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration";
import { CoolifyIntegration } from "../coolify/coolify-integration";
@@ -45,6 +46,7 @@ import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
import { SeerrIntegration } from "../seerr/seerr-integration";
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
import { TracearrIntegration } from "../tracearr/tracearr-integration";
import { TraefikIntegration } from "../traefik/traefik-integration";
import { SynologyIntegration } from "../synology/synology-integration";
import { TrueNasIntegration } from "../truenas/truenas-integration";
import { UmamiIntegration } from "../umami/umami-integration";
@@ -127,6 +129,8 @@ export const integrationCreators = {
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
beszel: BeszelIntegration,
bazarr: BazarrIntegration,
traefik: TraefikIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
@@ -0,0 +1,45 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { BazarrBadges } from "./bazarr-types";
import { bazarrBadgesSchema, bazarrSystemStatusSchema } from "./bazarr-types";
export class BazarrIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/system/status"), {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
bazarrSystemStatusSchema.parse(await response.json());
return { success: true };
}
public async getBadgesAsync(): Promise<BazarrBadges> {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/badges"), {
headers: this.getAuthHeaders(),
timeout: 10000,
});
if (!response.ok) {
throw new Error(
`Failed to fetch Bazarr badges for ${this.integration.name} (${this.integration.id}): ${response.statusText}`,
);
}
return bazarrBadgesSchema.parse(await response.json());
}
private getAuthHeaders(): Record<string, string> {
return {
"X-API-KEY": this.getSecretValue("apiKey"),
Accept: "application/json",
};
}
}
@@ -0,0 +1,19 @@
import { z } from "zod/v4";
export const bazarrBadgesSchema = z.object({
episodes: z.number(),
movies: z.number(),
providers: z.number(),
status: z.number(),
sonarr_signalr: z.string(),
radarr_signalr: z.string(),
announcements: z.number(),
});
export type BazarrBadges = z.infer<typeof bazarrBadgesSchema>;
export const bazarrSystemStatusSchema = z.object({
data: z.object({
bazarr_version: z.string(),
}),
});
@@ -20,6 +20,7 @@ import type {
BeszelSystemdService,
BeszelSystemStatsRecord,
CreateAlertInput,
LiveStatsEvent,
PocketBaseListResponse,
UpdateAlertInput,
} from "./beszel-types";
@@ -284,6 +285,125 @@ export class BeszelIntegration extends Integration {
});
}
/**
* Subscribe to real-time system and container stats via PocketBase SSE.
* Connects to the PocketBase Realtime API and parses incoming SSE events,
* filtering by systemId. This provides live-updating metrics (≤1s latency
* from agent) instead of polling the REST API every 5s.
*/
public async subscribeRealtimeMetrics(
systemId: string,
onMessage: (event: LiveStatsEvent) => void,
signal: AbortSignal,
): Promise<void> {
const session = await this.authenticateAsync();
const realtimeUrl = this.url("/api/realtime");
logger.debug("Opening Beszel SSE connection for realtime metrics", {
integrationId: this.integration.id,
systemId,
url: realtimeUrl.pathname,
});
const response = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
headers: { Authorization: session.token },
signal,
});
if (!response.ok) {
logger.warn("Beszel SSE connection failed", {
integrationId: this.integration.id,
systemId,
status: response.status,
});
throw new ResponseError(response);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body in SSE stream");
const decoder = new TextDecoder();
let buffer = "";
let subscribed = false;
const processLine = async (line: string) => {
if (!line.startsWith("data: ")) return;
try {
const parsed = JSON.parse(line.slice(6)) as Record<string, unknown>;
// PB_CONNECT delivers the clientId — use it to POST our subscriptions
if (!subscribed && typeof parsed.clientId === "string") {
subscribed = true;
const subscribeResponse = await fetchWithTrustedCertificatesAsync(realtimeUrl, {
method: "POST",
headers: {
Authorization: session.token,
"Content-Type": "application/json",
},
body: JSON.stringify({
clientId: parsed.clientId,
subscriptions: ["system_stats", "container_stats"],
}),
signal,
});
if (!subscribeResponse.ok) {
logger.warn("Beszel SSE subscription POST failed", {
integrationId: this.integration.id,
systemId,
status: subscribeResponse.status,
});
throw new ResponseError(subscribeResponse);
}
logger.debug("Beszel SSE subscribed to collections", {
integrationId: this.integration.id,
systemId,
clientId: parsed.clientId,
});
return;
}
const record = parsed.record as Record<string, unknown> | undefined;
if (!record || record.system !== systemId) return;
if (Array.isArray(record.stats)) {
onMessage({
type: "container_stats",
record: record as unknown as BeszelContainerStatsRecord,
});
} else if (record.stats && typeof record.stats === "object") {
onMessage({
type: "system_stats",
record: record as unknown as BeszelSystemStatsRecord,
});
}
} catch (error) {
if (error instanceof ResponseError) throw error;
// skip malformed SSE data lines
}
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
await processLine(line);
}
}
} finally {
reader.releaseLock();
logger.debug("Beszel SSE connection closed", { integrationId: this.integration.id, systemId });
}
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/collections/users/auth-with-password"), {
method: "POST",
@@ -343,6 +343,22 @@ export interface UpdateAlertInput {
min?: number;
}
/**
* Live SSE subscription event types for real-time Beszel metrics.
* Discriminated union — clients accumulate each type into its own buffer.
*/
export interface LiveSystemStatsEvent {
type: "system_stats";
record: BeszelSystemStatsRecord;
}
export interface LiveContainerStatsEvent {
type: "container_stats";
record: BeszelContainerStatsRecord;
}
export type LiveStatsEvent = LiveSystemStatsEvent | LiveContainerStatsEvent;
export interface PocketBaseListResponse<T> {
page: number;
perPage: number;
@@ -99,11 +99,14 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
password: this.getSecretValue("password"),
};
return new QBittorrent({
const client = new QBittorrent({
...credentials,
baseUrl: this.url("/").toString(),
dispatcher: dispatcher ?? (await createCertificateAgentAsync()),
});
// Populate version state so the library uses correct v5 endpoints (stop/start vs pause/resume)
await client.getAppVersion();
return client;
}
private static getTorrentState(state: string): DownloadClientItem["state"] {
@@ -125,6 +128,8 @@ export class QBitTorrentIntegration extends Integration implements IDownloadClie
return "seeding";
case "pausedDL":
case "pausedUP":
case "stoppedDL":
case "stoppedUP":
return "paused";
case "stalledDL":
return "stalled";
@@ -1,12 +1,13 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { ImageProxy } from "@homarr/image-proxy";
import { Integration } from "../base/integration";
import type { IntegrationTestingInput } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { Notification } from "../interfaces/notifications/notification-types";
import type { INotificationsIntegration } from "../interfaces/notifications/notifications-integration";
import { gotifyMessagesResponseSchema } from "./gotify-schema";
import { gotifyApplicationsResponseSchema, gotifyMessagesResponseSchema } from "./gotify-schema";
export class GotifyIntegration extends Integration implements INotificationsIntegration {
public async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
@@ -21,23 +22,90 @@ export class GotifyIntegration extends Integration implements INotificationsInte
return { Authorization: `Basic ${credentials}` };
}
private getMessagesUrl(applicationId: number) {
const url = this.externalUrl("/");
url.hash = `/messages/${applicationId}`;
return url.toString();
}
private async getApplicationsByIdAsync(): Promise<Map<number, { name: string; image?: string | null }>> {
try {
const response = await fetchWithTrustedCertificatesAsync(this.url("/application"), {
headers: this.getHeaders(),
});
if (!response.ok) throw new ResponseError(response);
const result = await gotifyApplicationsResponseSchema.safeParseAsync(await response.json());
if (!result.success) return new Map();
return new Map(result.data.map((application) => [application.id, application]));
} catch {
return new Map();
}
}
private async getApplicationIconUrlAsync(
imageProxy: ImageProxy,
imagePath: string | null | undefined,
): Promise<string | undefined> {
const trimmedImagePath = imagePath?.trim();
if (!trimmedImagePath) return undefined;
try {
const imageUrl = this.getApplicationImageUrl(trimmedImagePath);
return await imageProxy.createImageAsync(imageUrl, this.getHeaders());
} catch {
return undefined;
}
}
private getApplicationImageUrl(imagePath: string) {
try {
return new URL(imagePath).toString();
} catch {
const path = imagePath.startsWith("/") ? imagePath : `/${imagePath}`;
return this.url(path as `/${string}`).toString();
}
}
public async getNotificationsAsync(): Promise<Notification[]> {
const url = this.url("/message", { limit: 100 });
const response = await fetchWithTrustedCertificatesAsync(url, { headers: this.getHeaders() });
const [messagesResponse, applicationsById] = await Promise.all([
fetchWithTrustedCertificatesAsync(this.url("/message", { limit: 100 }), { headers: this.getHeaders() }),
this.getApplicationsByIdAsync(),
]);
if (!response.ok) throw new ResponseError(response);
if (!messagesResponse.ok) throw new ResponseError(messagesResponse);
const result = await gotifyMessagesResponseSchema.safeParseAsync(await response.json());
if (!result.success) throw new Error(`Failed to parse Gotify response: ${result.error.message}`);
const parsed = result.data;
const messagesResult = await gotifyMessagesResponseSchema.safeParseAsync(await messagesResponse.json());
if (!messagesResult.success) throw new Error(`Failed to parse Gotify response: ${messagesResult.error.message}`);
return parsed.messages.map(
(message): Notification => ({
const imageProxy = new ImageProxy();
const iconUrlsByApplicationId = new Map<number, string | undefined>();
await Promise.all(
[...applicationsById.entries()].map(async ([applicationId, application]) => {
iconUrlsByApplicationId.set(
applicationId,
await this.getApplicationIconUrlAsync(imageProxy, application.image),
);
}),
);
return messagesResult.data.messages.map((message): Notification => {
const application = applicationsById.get(message.appid);
return {
id: String(message.id),
time: new Date(message.date),
title: message.title,
body: message.message,
}),
);
href: this.getMessagesUrl(message.appid),
source: application
? {
name: application.name,
iconUrl: iconUrlsByApplicationId.get(message.appid),
}
: undefined,
};
});
}
}
@@ -3,6 +3,7 @@ import { z } from "zod/v4";
// See: https://gotify.net/api-docs#/message/getMessages
export const gotifyMessageSchema = z.object({
id: z.number(),
appid: z.number(),
date: z.string(),
title: z.string(),
message: z.string(),
@@ -12,3 +13,11 @@ export const gotifyMessageSchema = z.object({
export const gotifyMessagesResponseSchema = z.object({
messages: z.array(gotifyMessageSchema),
});
export const gotifyApplicationSchema = z.object({
id: z.number(),
name: z.string(),
image: z.string().nullable().optional(),
});
export const gotifyApplicationsResponseSchema = z.array(gotifyApplicationSchema);
+4
View File
@@ -42,6 +42,8 @@ export { NavidromeIntegration } from "./navidrome/navidrome-integration";
export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration";
export { UmamiIntegration } from "./umami/umami-integration";
export { PeaNutIntegration } from "./peanut/peanut-integration";
export { BazarrIntegration } from "./bazarr/bazarr-integration";
export { TraefikIntegration } from "./traefik/traefik-integration";
// Types
export type { IntegrationInput } from "./base/integration";
@@ -81,6 +83,8 @@ export type { AudiobookshelfDashboardData } from "./audiobookshelf/audiobookshel
export type { NavidromeDashboardData } from "./navidrome/navidrome-types";
export type { UptimeKumaDashboardData } from "./uptime-kuma/uptime-kuma-types";
export type { UmamiVisitorStats } from "./umami/umami-types";
export type { BazarrBadges } from "./bazarr/bazarr-types";
export type { TraefikDashboardData, TraefikProtocolSummary, TraefikResourceSummary } from "./traefik/traefik-types";
export type {
ArchiveTeamWarriorBandwidth,
ArchiveTeamWarriorItem,
@@ -0,0 +1,6 @@
import type { MissingMediaItem, QueuedMediaItem } from "./media-organizer-types";
export interface IMediaOrganizerIntegration {
getMissingAsync(pageSize?: number): Promise<{ items: MissingMediaItem[]; totalCount: number }>;
getMediaQueueAsync(pageSize?: number): Promise<{ items: QueuedMediaItem[]; totalCount: number }>;
}
@@ -0,0 +1,26 @@
export interface MissingMediaItem {
id: number;
title: string;
type: "movie" | "episode";
year?: number;
seasonNumber?: number;
episodeNumber?: number;
seriesTitle?: string;
imageUrl?: string | null;
link: string;
}
export interface QueuedMediaItem {
id: number;
title: string;
type: "movie" | "episode";
status: string;
timeLeft: string | null;
percentComplete: number;
year?: number;
seasonNumber?: number;
episodeNumber?: number;
seriesTitle?: string;
imageUrl?: string | null;
link: string;
}
@@ -3,4 +3,9 @@ export interface Notification {
time: Date;
title: string;
body: string;
href?: string;
source?: {
name: string;
iconUrl?: string;
};
}
@@ -10,11 +10,13 @@ import type { TestingResult } from "../../base/test-connection/test-connection-s
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
import { radarrReleaseTypes } from "../../interfaces/calendar/calendar-types";
import type { IMediaOrganizerIntegration } from "../../interfaces/media-organizer/media-organizer-integration";
import type { MissingMediaItem, QueuedMediaItem } from "../../interfaces/media-organizer/media-organizer-types";
import { mediaOrganizerPriorities } from "../media-organizer";
const logger = createLogger({ module: "radarrIntegration" });
export class RadarrIntegration extends Integration implements ICalendarIntegration {
export class RadarrIntegration extends Integration implements ICalendarIntegration, IMediaOrganizerIntegration {
/**
* Gets the events in the Radarr calendar between two dates.
* @param start The start date
@@ -111,6 +113,79 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati
return bestImage.remoteUrl;
};
async getMissingAsync(pageSize = 10): Promise<{ items: MissingMediaItem[]; totalCount: number }> {
const url = this.url("/api/v3/wanted/missing", {
page: 1,
pageSize,
sortKey: "title",
sortDirection: "ascending",
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
const data = await z
.object({
totalRecords: z.number(),
records: z.array(radarrMissingMovieSchema),
})
.parseAsync(await response.json());
return {
totalCount: data.totalRecords,
items: data.records.map(
(movie): MissingMediaItem => ({
id: movie.id,
title: movie.title,
type: "movie",
year: movie.year,
imageUrl: movie.images.find((img) => img.coverType === "poster")?.remoteUrl ?? null,
link: this.externalUrl(`/movie/${movie.titleSlug}`).toString(),
}),
),
};
}
async getMediaQueueAsync(pageSize = 10): Promise<{ items: QueuedMediaItem[]; totalCount: number }> {
const url = this.url("/api/v3/queue", {
page: 1,
pageSize,
includeMovie: true,
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
const data = await z
.object({
totalRecords: z.number(),
records: z.array(radarrQueueItemSchema),
})
.parseAsync(await response.json());
return {
totalCount: data.totalRecords,
items: data.records.map((item): QueuedMediaItem => {
const sizeLeft = item.sizeleft ?? 0;
const sizeTotal = item.size ?? 0;
const percentComplete = sizeTotal > 0 ? Math.round(((sizeTotal - sizeLeft) / sizeTotal) * 100) : 0;
return {
id: item.id,
title: item.movie?.title ?? item.title,
type: "movie",
status: item.status,
timeLeft: item.timeleft ?? null,
percentComplete,
year: item.movie?.year,
imageUrl: item.movie?.images.find((img) => img.coverType === "poster")?.remoteUrl ?? null,
link: item.movie?.titleSlug
? this.externalUrl(`/movie/${item.movie.titleSlug}`).toString()
: this.externalUrl("/activity/queue").toString(),
};
}),
};
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
@@ -131,6 +206,41 @@ const radarrCalendarEventImageSchema = z.array(
}),
);
const radarrMissingMovieSchema = z.object({
id: z.number(),
title: z.string(),
year: z.number().optional(),
titleSlug: z.string(),
images: z.array(
z.object({
coverType: z.string(),
remoteUrl: z.string().url(),
}),
),
});
const radarrQueueItemSchema = z.object({
id: z.number(),
title: z.string(),
status: z.string(),
timeleft: z.string().optional(),
size: z.number().optional(),
sizeleft: z.number().optional(),
movie: z
.object({
title: z.string(),
year: z.number().optional(),
titleSlug: z.string(),
images: z.array(
z.object({
coverType: z.string(),
remoteUrl: z.string().url(),
}),
),
})
.optional(),
});
const radarrCalendarEventSchema = z.object({
title: z.string(),
originalTitle: z.string(),
@@ -9,11 +9,13 @@ import { TestConnectionError } from "../../base/test-connection/test-connection-
import type { TestingResult } from "../../base/test-connection/test-connection-service";
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
import type { IMediaOrganizerIntegration } from "../../interfaces/media-organizer/media-organizer-integration";
import type { MissingMediaItem, QueuedMediaItem } from "../../interfaces/media-organizer/media-organizer-types";
import { mediaOrganizerPriorities } from "../media-organizer";
const logger = createLogger({ module: "sonarrIntegration" });
export class SonarrIntegration extends Integration implements ICalendarIntegration {
export class SonarrIntegration extends Integration implements ICalendarIntegration, IMediaOrganizerIntegration {
/**
* Gets the events in the Sonarr calendar between two dates.
* @param start The start date
@@ -107,6 +109,88 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
return bestImage.remoteUrl;
};
async getMissingAsync(pageSize = 10): Promise<{ items: MissingMediaItem[]; totalCount: number }> {
const url = this.url("/api/v3/wanted/missing", {
page: 1,
pageSize,
sortKey: "airDateUtc",
sortDirection: "ascending",
includeSeries: true,
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
const data = await z
.object({
totalRecords: z.number(),
records: z.array(sonarrMissingEpisodeSchema),
})
.parseAsync(await response.json());
return {
totalCount: data.totalRecords,
items: data.records.map(
(episode): MissingMediaItem => ({
id: episode.id,
title: episode.title,
type: "episode",
seasonNumber: episode.seasonNumber,
episodeNumber: episode.episodeNumber,
seriesTitle: episode.series?.title,
year: episode.series?.year,
imageUrl: episode.series?.images.find((img) => img.coverType === "poster")?.remoteUrl ?? null,
link: episode.series?.titleSlug
? this.externalUrl(`/series/${episode.series.titleSlug}`).toString()
: this.externalUrl("/wanted/missing").toString(),
}),
),
};
}
async getMediaQueueAsync(pageSize = 10): Promise<{ items: QueuedMediaItem[]; totalCount: number }> {
const url = this.url("/api/v3/queue", {
page: 1,
pageSize,
includeSeries: true,
includeEpisode: true,
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
});
const data = await z
.object({
totalRecords: z.number(),
records: z.array(sonarrQueueItemSchema),
})
.parseAsync(await response.json());
return {
totalCount: data.totalRecords,
items: data.records.map((item): QueuedMediaItem => {
const sizeLeft = item.sizeleft ?? 0;
const sizeTotal = item.size ?? 0;
const percentComplete = sizeTotal > 0 ? Math.round(((sizeTotal - sizeLeft) / sizeTotal) * 100) : 0;
return {
id: item.id,
title: item.episode?.title ?? item.title,
type: "episode",
status: item.status,
timeLeft: item.timeleft ?? null,
percentComplete,
seasonNumber: item.episode?.seasonNumber,
episodeNumber: item.episode?.episodeNumber,
seriesTitle: item.series?.title,
imageUrl: item.series?.images.find((img) => img.coverType === "poster")?.remoteUrl ?? null,
link: item.series?.titleSlug
? this.externalUrl(`/series/${item.series.titleSlug}`).toString()
: this.externalUrl("/activity/queue").toString(),
};
}),
};
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api"), {
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
@@ -119,6 +203,54 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
}
}
const sonarrMissingEpisodeSchema = z.object({
id: z.number(),
title: z.string(),
seasonNumber: z.number(),
episodeNumber: z.number(),
series: z
.object({
title: z.string(),
year: z.number().optional(),
titleSlug: z.string(),
images: z.array(
z.object({
coverType: z.string(),
remoteUrl: z.string().url(),
}),
),
})
.optional(),
});
const sonarrQueueItemSchema = z.object({
id: z.number(),
title: z.string(),
status: z.string(),
timeleft: z.string().optional(),
size: z.number().optional(),
sizeleft: z.number().optional(),
series: z
.object({
title: z.string(),
titleSlug: z.string(),
images: z.array(
z.object({
coverType: z.string(),
remoteUrl: z.string().url(),
}),
),
})
.optional(),
episode: z
.object({
title: z.string(),
seasonNumber: z.number(),
episodeNumber: z.number(),
})
.optional(),
});
const sonarrCalendarEventImageSchema = z.array(
z.object({
// See https://github.com/Sonarr/Sonarr/blob/9e5ebdc6245d4714776b53127a1e6b63c25fbcb9/src/NzbDrone.Core/MediaCover/MediaCover.cs#L5-L14
@@ -7,6 +7,7 @@ import type {
BeszelSystemDetails,
BeszelSystemStats,
BeszelSystemStatsRecord,
LiveStatsEvent,
} from "../../beszel/beszel-types";
const GB = 1024 * 1024 * 1024;
@@ -332,7 +333,7 @@ export class BeszelMockService {
public async subscribeRealtimeMetrics(
systemId: string,
onMessage: (data: { stats: BeszelSystemStatsRecord; containerStats: BeszelContainerStatsRecord | null }) => void,
onMessage: (event: LiveStatsEvent) => void,
signal: AbortSignal,
): Promise<void> {
const sys = resolveSystem(systemId);
@@ -348,7 +349,7 @@ export class BeszelMockService {
created: new Date().toISOString(),
updated: new Date().toISOString(),
};
onMessage({ stats: record, containerStats: null });
onMessage({ type: "system_stats", record });
};
if (signal.aborted) return;
await new Promise<void>((resolve) => {
@@ -0,0 +1,116 @@
import type { IMediaOrganizerIntegration } from "../../interfaces/media-organizer/media-organizer-integration";
import type { MissingMediaItem, QueuedMediaItem } from "../../interfaces/media-organizer/media-organizer-types";
const missingItems: MissingMediaItem[] = [
{
id: 1,
title: "Dune: Part Two",
type: "movie",
year: 2024,
imageUrl: "https://image.tmdb.org/t/p/w300/1pdfLvkbY9ohJlCjQH2CZjjYVvJ.jpg",
link: "https://www.imdb.com/title/tt15239678/",
},
{
id: 2,
title: "The Batman Part II",
type: "movie",
year: 2026,
imageUrl: "https://image.tmdb.org/t/p/w300/74xTEgt7R36Fpooo50r9T25onhq.jpg",
link: "https://www.imdb.com/title/tt7126948/",
},
{
id: 3,
title: "The Last of Us",
type: "episode",
seasonNumber: 2,
episodeNumber: 5,
seriesTitle: "The Last of Us",
year: 2025,
imageUrl: "https://image.tmdb.org/t/p/w300/uKvVjHNqB5VmOrdxqAt2F7J78ED.jpg",
link: "https://www.imdb.com/title/tt3581920/",
},
{
id: 4,
title: "Andor",
type: "episode",
seasonNumber: 2,
episodeNumber: 3,
seriesTitle: "Andor",
year: 2025,
imageUrl: "https://image.tmdb.org/t/p/w300/59SVNwLfoMnZPPB6ukW6dlPxAdI.jpg",
link: "https://www.imdb.com/title/tt9253284/",
},
{
id: 5,
title: "Gladiator II",
type: "movie",
year: 2024,
imageUrl: "https://image.tmdb.org/t/p/w300/2cxhvwyEwRlysAmRH4iodkvo0z5.jpg",
link: "https://www.imdb.com/title/tt9218128/",
},
];
const queuedItems: QueuedMediaItem[] = [
{
id: 101,
title: "Oppenheimer",
type: "movie",
status: "downloading",
timeLeft: "1h 23m",
percentComplete: 62,
imageUrl: "https://image.tmdb.org/t/p/w300/8Gxv8gSFCU0XGDykEGv7zR1n2ua.jpg",
link: "https://www.imdb.com/title/tt15398776/",
},
{
id: 102,
title: "Shogun",
type: "episode",
seasonNumber: 1,
episodeNumber: 8,
seriesTitle: "Shogun",
status: "downloading",
timeLeft: "47m",
percentComplete: 81,
imageUrl: "https://image.tmdb.org/t/p/w300/7O4iVfOMQmdCSxhOg1WnzG1AgYT.jpg",
link: "https://www.imdb.com/title/tt2098771/",
},
{
id: 103,
title: "The Substance",
type: "movie",
status: "queued",
timeLeft: null,
percentComplete: 0,
imageUrl: "https://image.tmdb.org/t/p/w300/lqoMzCcZYEFK729d6qzt349fB4o.jpg",
link: "https://www.imdb.com/title/tt17526714/",
},
{
id: 104,
title: "House of the Dragon",
type: "episode",
seasonNumber: 2,
episodeNumber: 7,
seriesTitle: "House of the Dragon",
status: "importing",
timeLeft: "3m",
percentComplete: 99,
imageUrl: "https://image.tmdb.org/t/p/w300/z2yahl2uefxDCl0nogcRBstwruJ.jpg",
link: "https://www.imdb.com/title/tt11198330/",
},
];
export class MediaOrganizerMockService implements IMediaOrganizerIntegration {
async getMissingAsync(pageSize = 10) {
return {
items: missingItems.slice(0, pageSize),
totalCount: missingItems.length,
};
}
async getMediaQueueAsync(pageSize = 10) {
return {
items: queuedItems.slice(0, pageSize),
totalCount: queuedItems.length,
};
}
}
@@ -9,6 +9,7 @@ import type {
ISystemHealthMonitoringIntegration,
} from "../interfaces/health-monitoring/health-monitoring-integration";
import type { IIndexerManagerIntegration } from "../interfaces/indexer-manager/indexer-manager-integration";
import type { IMediaOrganizerIntegration } from "../interfaces/media-organizer/media-organizer-integration";
import type { IMediaReleasesIntegration } from "../interfaces/media-releases";
import type { IMediaRequestIntegration } from "../interfaces/media-requests/media-request-integration";
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
@@ -21,6 +22,7 @@ import { ClusterHealthMonitoringMockService } from "./data/cluster-health-monito
import { DnsHoleMockService } from "./data/dns-hole";
import { DownloadClientMockService } from "./data/download";
import { IndexerManagerMockService } from "./data/indexer-manager";
import { MediaOrganizerMockService } from "./data/media-organizer";
import { MediaReleasesMockService } from "./data/media-releases";
import { MediaRequestMockService } from "./data/media-request";
import { MediaServerMockService } from "./data/media-server";
@@ -41,6 +43,7 @@ export class MockIntegration
IClusterHealthMonitoringIntegration,
ISystemHealthMonitoringIntegration,
IIndexerManagerIntegration,
IMediaOrganizerIntegration,
IMediaReleasesIntegration,
IMediaRequestIntegration,
IMediaServerIntegration,
@@ -55,6 +58,7 @@ export class MockIntegration
private static readonly clusterMonitoring = new ClusterHealthMonitoringMockService();
private static readonly systemMonitoring = new SystemHealthMonitoringMockService();
private static readonly indexerManager = new IndexerManagerMockService();
private static readonly mediaOrganizer = new MediaOrganizerMockService();
private static readonly mediaReleases = new MediaReleasesMockService();
private static readonly mediaRequest = new MediaRequestMockService();
private static readonly mediaServer = new MediaServerMockService();
@@ -97,6 +101,10 @@ export class MockIntegration
getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager);
testAllAsync = MockIntegration.indexerManager.testAllAsync.bind(MockIntegration.indexerManager);
// MediaOrganizerIntegration
getMissingAsync = MockIntegration.mediaOrganizer.getMissingAsync.bind(MockIntegration.mediaOrganizer);
getMediaQueueAsync = MockIntegration.mediaOrganizer.getMediaQueueAsync.bind(MockIntegration.mediaOrganizer);
// MediaReleasesIntegration
getMediaReleasesAsync = MockIntegration.mediaReleases.getMediaReleasesAsync.bind(MockIntegration.mediaReleases);
@@ -132,11 +132,14 @@ export class OverseerrIntegration
},
);
const allRequests = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/request", { take: 20 }), {
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
const allRequests = await fetchWithTrustedCertificatesAsync(
this.url("/api/v1/request", { take: 20, sort: "modified" }),
{
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
},
});
);
const pendingResults = (await getRequestsSchema.parseAsync(await pendingRequests.json())).results;
const allResults = (await getRequestsSchema.parseAsync(await allRequests.json())).results;
@@ -0,0 +1,163 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type {
TraefikDashboardData,
TraefikProtocolSummary,
TraefikResource,
TraefikResourceStatus,
TraefikResourceSummary,
} from "./traefik-types";
import { traefikResourcesSchema, traefikVersionSchema } from "./traefik-types";
type ResourcePath =
| "/api/http/routers"
| "/api/http/services"
| "/api/http/middlewares"
| "/api/tcp/routers"
| "/api/tcp/services"
| "/api/tcp/middlewares"
| "/api/udp/routers"
| "/api/udp/services"
| "/api/entrypoints";
export class TraefikIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/version"), { headers: this.getAuthHeaders() });
if (!response.ok) {
throw new ResponseError(response);
}
traefikVersionSchema.parse(await response.json());
return { success: true };
}
public async getDashboardDataAsync(): Promise<TraefikDashboardData> {
const [
versionResult,
entryPointsResult,
httpRoutersResult,
httpServicesResult,
httpMiddlewaresResult,
tcpRoutersResult,
tcpServicesResult,
tcpMiddlewaresResult,
udpRoutersResult,
udpServicesResult,
] = await Promise.allSettled([
this.getVersionAsync(),
this.getResourcesAsync("/api/entrypoints"),
this.getResourcesAsync("/api/http/routers"),
this.getResourcesAsync("/api/http/services"),
this.getResourcesAsync("/api/http/middlewares"),
this.getResourcesAsync("/api/tcp/routers"),
this.getResourcesAsync("/api/tcp/services"),
this.getResourcesAsync("/api/tcp/middlewares"),
this.getResourcesAsync("/api/udp/routers"),
this.getResourcesAsync("/api/udp/services"),
]);
const version = versionResult.status === "fulfilled" ? versionResult.value : null;
const entryPoints = entryPointsResult.status === "fulfilled" ? entryPointsResult.value : [];
const httpRouters = httpRoutersResult.status === "fulfilled" ? httpRoutersResult.value : [];
const httpServices = httpServicesResult.status === "fulfilled" ? httpServicesResult.value : [];
const httpMiddlewares = httpMiddlewaresResult.status === "fulfilled" ? httpMiddlewaresResult.value : [];
const tcpRouters = tcpRoutersResult.status === "fulfilled" ? tcpRoutersResult.value : [];
const tcpServices = tcpServicesResult.status === "fulfilled" ? tcpServicesResult.value : [];
const tcpMiddlewares = tcpMiddlewaresResult.status === "fulfilled" ? tcpMiddlewaresResult.value : [];
const udpRouters = udpRoutersResult.status === "fulfilled" ? udpRoutersResult.value : [];
const udpServices = udpServicesResult.status === "fulfilled" ? udpServicesResult.value : [];
return {
version,
entryPoints: entryPoints.map((entryPoint, index) => entryPoint.name ?? `entrypoint-${index + 1}`),
http: this.createProtocolSummary(httpRouters, httpServices, httpMiddlewares),
tcp: this.createProtocolSummary(tcpRouters, tcpServices, tcpMiddlewares),
udp: {
routers: this.summarizeResources(udpRouters),
services: this.summarizeResources(udpServices),
},
};
}
private async getVersionAsync() {
const response = await this.fetchOkAsync("/api/version");
const version = traefikVersionSchema.parse(await response.json());
return version.Version ?? null;
}
private async getResourcesAsync(path: ResourcePath) {
const response = await this.fetchOkAsync(path);
return traefikResourcesSchema.parse(await response.json());
}
private createProtocolSummary(
routers: TraefikResource[],
services: TraefikResource[],
middlewares: TraefikResource[],
): TraefikProtocolSummary {
return {
routers: this.summarizeResources(routers),
services: this.summarizeResources(services),
middlewares: this.summarizeResources(middlewares),
};
}
private summarizeResources(resources: TraefikResource[]): TraefikResourceSummary {
return resources.reduce<TraefikResourceSummary>(
(summary, resource) => {
const status = this.normalizeStatus(resource.status);
summary.total += 1;
if (status === "enabled") summary.enabled += 1;
if (status === "warning") summary.warnings += 1;
if (status === "error") summary.errors += 1;
return summary;
},
{ total: 0, enabled: 0, warnings: 0, errors: 0 },
);
}
private normalizeStatus(status: string | undefined): TraefikResourceStatus {
if (!status) return "unknown";
const normalized = status.toLowerCase();
if (normalized === "enabled" || normalized === "disabled" || normalized === "warning" || normalized === "error") {
return normalized;
}
return "unknown";
}
private async fetchOkAsync(path: ResourcePath | "/api/version") {
const response = await fetchWithTrustedCertificatesAsync(this.url(path), {
headers: this.getAuthHeaders(),
timeout: 10_000,
});
if (!response.ok) {
throw new ResponseError(response);
}
return response;
}
private getAuthHeaders(): Record<string, string> {
if (this.hasSecretValue("username") && this.hasSecretValue("password")) {
const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString(
"base64",
);
return { Authorization: `Basic ${credentials}` };
}
if (this.hasSecretValue("apiKey")) {
return { Authorization: `Bearer ${this.getSecretValue("apiKey")}` };
}
return {};
}
}
@@ -0,0 +1,43 @@
import { z } from "zod";
export const traefikResourceStatusSchema = z.enum(["enabled", "disabled", "warning", "error", "unknown"]);
const traefikResourceSchema = z
.object({
name: z.string().optional(),
provider: z.string().optional(),
status: z.string().optional(),
})
.passthrough();
export const traefikVersionSchema = z
.object({
Version: z.string().optional(),
})
.passthrough();
export const traefikResourcesSchema = z.array(traefikResourceSchema);
export interface TraefikResourceSummary {
total: number;
enabled: number;
warnings: number;
errors: number;
}
export interface TraefikProtocolSummary {
routers: TraefikResourceSummary;
services: TraefikResourceSummary;
middlewares: TraefikResourceSummary;
}
export interface TraefikDashboardData {
version: string | null;
entryPoints: string[];
http: TraefikProtocolSummary;
tcp: TraefikProtocolSummary;
udp: Omit<TraefikProtocolSummary, "middlewares">;
}
export type TraefikResource = z.infer<typeof traefikResourcesSchema>[number];
export type TraefikResourceStatus = z.infer<typeof traefikResourceStatusSchema>;
+3
View File
@@ -1,4 +1,6 @@
export * from "./interfaces/calendar/calendar-types";
export * from "./interfaces/media-organizer/media-organizer-types";
export type { IMediaOrganizerIntegration } from "./interfaces/media-organizer/media-organizer-integration";
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
export * from "./interfaces/network-controller-summary/network-controller-summary-types";
export * from "./interfaces/firewall-summary/firewall-summary-types";
@@ -24,3 +26,4 @@ export * from "./paperless-ngx/paperless-ngx-types";
export * from "./umami/umami-types";
export * from "./gluetun/gluetun-types";
export * from "./beszel/beszel-types";
export * from "./traefik/traefik-types";
@@ -0,0 +1,110 @@
// @vitest-environment node
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecret } from "../src/base/types";
import { BazarrIntegration } from "../src/bazarr/bazarr-integration";
vi.mock("@homarr/db", async (importActual) => {
const actual = await importActual<typeof import("@homarr/db")>();
return { ...actual, db: createDb() };
});
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
return {
...actual,
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
};
});
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_URL = "https://bazarr.example.com";
const API_KEY = "test-api-key";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const createIntegration = (decryptedSecrets: IntegrationSecret[] = [{ kind: "apiKey", value: API_KEY }]) =>
new BazarrIntegration({
id: "test-bazarr",
name: "Test Bazarr",
url: TEST_URL,
externalUrl: null,
decryptedSecrets,
});
beforeEach(() => {
mockFetch.mockReset();
});
describe("BazarrIntegration getBadgesAsync", () => {
test("maps badge counts from the Bazarr API", async () => {
mockFetch.mockImplementation(
() =>
Promise.resolve(
new Response(
JSON.stringify({
episodes: 12,
movies: 4,
providers: 1,
status: 2,
sonarr_signalr: "LIVE",
radarr_signalr: "",
announcements: 0,
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>,
);
const badges = await createIntegration().getBadgesAsync();
expect(badges).toStrictEqual({
episodes: 12,
movies: 4,
providers: 1,
status: 2,
sonarr_signalr: "LIVE",
radarr_signalr: "",
announcements: 0,
});
});
test("sends the API key in the X-API-KEY header", async () => {
mockFetch.mockImplementation(
() =>
Promise.resolve(
new Response(
JSON.stringify({
episodes: 0,
movies: 0,
providers: 0,
status: 0,
sonarr_signalr: "",
radarr_signalr: "",
announcements: 0,
}),
{
status: 200,
headers: { "content-type": "application/json" },
},
),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>,
);
await createIntegration().getBadgesAsync();
const [, requestInit] = mockFetch.mock.calls[0] ?? [];
expect(requestInit?.headers).toMatchObject({ "X-API-KEY": API_KEY });
});
});
+3 -1
View File
@@ -1,5 +1,7 @@
import { z } from "zod/v4";
import { requiredNullableEmailSchema } from "@homarr/validation/email";
const regexEncryptedSchema = z.string().regex(/^[a-f0-9]+\.[a-f0-9]+$/g);
const encryptedSchema = z.custom<`${string}.${string}`>((value) => regexEncryptedSchema.safeParse(value).success);
@@ -7,7 +9,7 @@ const encryptedSchema = z.custom<`${string}.${string}`>((value) => regexEncrypte
export const oldmarrImportUserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email().nullable(),
email: requiredNullableEmailSchema,
emailVerified: z.date().nullable(),
image: z.string().nullable(),
isAdmin: z.boolean(),
+15
View File
@@ -0,0 +1,15 @@
import { createIntegrationAsync } from "@homarr/integrations";
import type { BazarrBadges } from "@homarr/integrations";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const bazarrBadgesRequestHandler = createIntegrationRequestHandler<
BazarrBadges,
"bazarr",
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getBadgesAsync();
},
});
+3
View File
@@ -122,6 +122,9 @@ export const beszelStatsRequestHandler = createIntegrationRequestHandler<
"beszel" | "mock",
{ systemId: string; timePeriod: string; includeDocker: boolean }
>({
// No cache — the widget polls every 5s for live updates;
// a TTL here would serve stale records between polls.
cacheTtlMs: 0,
async requestAsync(integration, input) {
const start = performance.now();
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 };
+17 -4
View File
@@ -300,7 +300,10 @@ async function getContainersWithStatsAsync() {
const stats = await instance
.getContainer(container.Id)
.stats({ stream: false, "one-shot": true })
// Not in one-shot mode: the daemon then includes precpu_stats (the
// previous reading), which is required to compute the current CPU usage
// as a delta. One-shot omits it and would only expose lifetime totals.
.stats({ stream: false })
.catch(
() =>
({
@@ -336,12 +339,22 @@ export function calculateCpuUsage(stats: ContainerStats): number {
}
const numberOfCpus = stats.cpu_stats.online_cpus;
const usage = stats.cpu_stats.system_cpu_usage;
if (!usage || usage === 0) {
// Docker's own formula for the current (instantaneous) CPU usage: it compares
// the latest reading against the previous one (precpu_stats). Dividing the
// cumulative total_usage by the cumulative system_cpu_usage instead would
// report the container's lifetime average, which stays high long after a load
// spike has ended. precpu_stats is only present when the stats are NOT fetched
// in one-shot mode; when it is absent (e.g. Podman) the deltas fall back to the
// cumulative totals, matching the previous behaviour.
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage ?? 0);
const systemDelta = (stats.cpu_stats.system_cpu_usage ?? 0) - (stats.precpu_stats?.system_cpu_usage ?? 0);
if (systemDelta <= 0 || cpuDelta < 0) {
return 0;
}
return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100;
return (cpuDelta / systemDelta) * numberOfCpus * 100;
}
export function calculateMemoryUsage(stats: ContainerStats): number {
@@ -12,6 +12,7 @@ type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
cacheTtlMs?: number;
}
export const createIntegrationRequestHandler = <
@@ -20,11 +21,24 @@ export const createIntegrationRequestHandler = <
TInput extends Record<string, unknown>,
>(
options: Options<TData, TKind, TInput>,
) => ({
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) => {
const inner = createRequestHandler<TData, { integrationId: string; options: TInput }>({
requestAsync: async (input) => options.requestAsync(integration, input.options),
});
return inner.handler({ integrationId: integration.id, options: itemOptions });
},
});
) => {
const integrationMap = new Map<string, IntegrationOfKind<TKind>>();
const inner = createRequestHandler<TData, { integrationId: string; options: TInput }>({
requestAsync: async (input) => {
const integration = integrationMap.get(input.integrationId);
if (!integration) {
throw new Error(`Integration ${input.integrationId} not found in cache`);
}
return options.requestAsync(integration, input.options);
},
cacheTtlMs: options.cacheTtlMs,
});
return {
invalidateCache: inner.invalidateCache,
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) => {
integrationMap.set(integration.id, integration);
return inner.handler({ integrationId: integration.id, options: itemOptions });
},
};
};
@@ -22,6 +22,10 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
const inflight = new Map<string, Promise<CacheEntry<TData>>>();
return {
invalidateCache: () => {
cache.clear();
inflight.clear();
},
handler: (input: TInput) => ({
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
@@ -0,0 +1,36 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { IMediaOrganizerIntegration, MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
interface MediaOrganizerData {
missing: MissingMediaItem[];
missingCount: number;
queued: QueuedMediaItem[];
queuedCount: number;
}
export const mediaOrganizerRequestHandler = createCachedIntegrationRequestHandler<
MediaOrganizerData,
IntegrationKindByCategory<"mediaOrganizer">,
{ pageSize: number }
>({
async requestAsync(integration, input) {
const integrationInstance = (await createIntegrationAsync(integration)) as unknown as IMediaOrganizerIntegration;
const [missingResult, queueResult] = await Promise.all([
integrationInstance.getMissingAsync(input.pageSize),
integrationInstance.getMediaQueueAsync(input.pageSize),
]);
return {
missing: missingResult.items,
missingCount: missingResult.totalCount,
queued: queueResult.items,
queuedCount: queueResult.totalCount,
};
},
cacheDuration: dayjs.duration(1, "minute"),
queryKey: "mediaOrganizer",
});
@@ -134,7 +134,7 @@ const getGithubApi = (baseUrl: string, userAgent: string, token?: string) =>
baseUrl,
auth: token,
request: { fetch: fetchWithTrustedCertificatesAsync },
throttle: { enabled: false },
throttle: { enabled: true },
userAgent,
});
@@ -75,6 +75,26 @@ describe("calculateCpuUsage", () => {
// (500 / 100000) * 2 * 100 = 1
expect(calculateCpuUsage(stats)).toBe(1);
});
test("should use the delta against precpu_stats for the current usage", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_002_000 }, system_cpu_usage: 20_000 },
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
});
// Despite a large lifetime total_usage, only the recent delta counts:
// (2000 / 10000) * 4 * 100 = 80
expect(calculateCpuUsage(stats)).toBe(80);
});
test("should report 0 for a container that was busy over its lifetime but is idle now", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 20_000 },
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
});
// No CPU delta since the last reading -> current usage is 0, even though the
// lifetime average would be high. This is the regression the fix addresses.
expect(calculateCpuUsage(stats)).toBe(0);
});
});
describe("calculateMemoryUsage", () => {
+15
View File
@@ -0,0 +1,15 @@
import { createIntegrationAsync } from "@homarr/integrations";
import type { TraefikDashboardData } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const traefikRequestHandler = createIntegrationRequestHandler<
TraefikDashboardData,
"traefik",
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
},
});
+10
View File
@@ -2284,6 +2284,11 @@
"fileSystem": {
"label": ""
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
},
"defaultTab": {
"label": ""
},
@@ -3538,6 +3543,11 @@
},
"showBackgroundBar": {
"label": ""
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
}
},
"status": {
+10
View File
@@ -2284,6 +2284,11 @@
"fileSystem": {
"label": "显示文件系统信息"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
},
"defaultTab": {
"label": "默认标签"
},
@@ -3538,6 +3543,11 @@
},
"showBackgroundBar": {
"label": "显示背景使用栏"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
}
},
"status": {
+10
View File
@@ -2284,6 +2284,11 @@
"fileSystem": {
"label": "crwdns1082:0crwdne1082:0"
},
"visibleStorageVolumes": {
"label": "crwdns5741:0crwdne5741:0",
"description": "crwdns5743:0crwdne5743:0",
"placeholder": "crwdns5745:0crwdne5745:0"
},
"defaultTab": {
"label": "crwdns2636:0crwdne2636:0"
},
@@ -3538,6 +3543,11 @@
},
"showBackgroundBar": {
"label": "crwdns3842:0crwdne3842:0"
},
"visibleStorageVolumes": {
"label": "crwdns5747:0crwdne5747:0",
"description": "crwdns5749:0crwdne5749:0",
"placeholder": "crwdns5751:0crwdne5751:0"
}
},
"status": {
+10
View File
@@ -2284,6 +2284,11 @@
"fileSystem": {
"label": "Zobrazit informace o souborovém systému"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
},
"defaultTab": {
"label": ""
},
@@ -3538,6 +3543,11 @@
},
"showBackgroundBar": {
"label": ""
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
}
},
"status": {
+10
View File
@@ -2284,6 +2284,11 @@
"fileSystem": {
"label": "Vis information om filsystemet"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
},
"defaultTab": {
"label": "Standard fane"
},
@@ -3538,6 +3543,11 @@
},
"showBackgroundBar": {
"label": "Vis forbrugslinje i baggrunden"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
}
},
"status": {
+10
View File
@@ -2284,6 +2284,11 @@
"fileSystem": {
"label": "Dateisystem Info anzeigen"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
},
"defaultTab": {
"label": "Standard Tab"
},
@@ -3538,6 +3543,11 @@
},
"showBackgroundBar": {
"label": ""
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
}
},
"status": {
+10
View File
@@ -2284,6 +2284,11 @@
"fileSystem": {
"label": "Dateisystem Info anzeigen"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
},
"defaultTab": {
"label": "Standard Tab"
},
@@ -3538,6 +3543,11 @@
},
"showBackgroundBar": {
"label": "Zeige Hintergrundnutzungsleiste"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
}
},
"status": {
+10
View File
@@ -2284,6 +2284,11 @@
"fileSystem": {
"label": "Εμφάνιση Πληροφοριών Συστήματος Αρχείων"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
},
"defaultTab": {
"label": ""
},
@@ -3538,6 +3543,11 @@
},
"showBackgroundBar": {
"label": ""
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
}
},
"status": {
+10
View File
@@ -2284,6 +2284,11 @@
"fileSystem": {
"label": "Show Filesystem Info"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
},
"defaultTab": {
"label": "Default tab"
},
@@ -3538,6 +3543,11 @@
},
"showBackgroundBar": {
"label": ""
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
}
},
"status": {
+113 -1
View File
@@ -1328,9 +1328,17 @@
}
}
},
"traefik": {
"name": "Traefik",
"description": "Cloud-native reverse proxy and load balancer"
},
"paperlessNgx": {
"name": "Paperless-ngx",
"description": "Document management system for archiving and organizing scanned documents"
},
"bazarr": {
"name": "Bazarr",
"description": "Companion application to Sonarr and Radarr that manages and downloads subtitles"
}
},
"media": {
@@ -2754,6 +2762,35 @@
"internalServerError": "Failed to fetch Paperless-ngx data"
}
},
"bazarr": {
"name": "Bazarr",
"description": "Overview of missing subtitles and health status from your Bazarr instance",
"episodes": "Missing Episodes",
"movies": "Missing Movies",
"providers": "Provider Issues",
"status": "Health Issues",
"option": {
"showMissingEpisodes": {
"label": "Show missing episodes",
"description": "Display the number of episodes with missing subtitles"
},
"showMissingMovies": {
"label": "Show missing movies",
"description": "Display the number of movies with missing subtitles"
},
"showProviderIssues": {
"label": "Show provider issues",
"description": "Display the number of subtitle providers with issues"
},
"showHealthIssues": {
"label": "Show health issues",
"description": "Display the number of Bazarr health issues"
}
},
"error": {
"internalServerError": "Failed to fetch Bazarr data"
}
},
"immich-serverStats": {
"name": "Immich Server Stats",
"description": "Information about your Immich instance",
@@ -3093,6 +3130,35 @@
}
}
},
"mediaMissing": {
"name": "Missing & Queued Media",
"description": "Shows missing and queued movies and episodes from Radarr and Sonarr",
"option": {
"showMissing": {
"label": "Show missing items"
},
"showQueued": {
"label": "Show queued items"
},
"pageSize": {
"label": "Items per page"
}
},
"tab": {
"missing": "Missing",
"queued": "Queued"
},
"type": {
"movie": "Movie",
"episode": "Episode"
},
"empty": {
"missing": "No missing items",
"queued": "No queued items",
"noTabsEnabled": "Enable at least one tab in the widget settings"
},
"timeLeft": "Time left"
},
"rssFeed": {
"name": "RSS feeds",
"description": "Monitor and display one or more generic RSS, ATOM or JSON feeds",
@@ -3336,7 +3402,11 @@
"name": "Notifications",
"description": "Display notification history from an integration",
"noItems": "No notifications to display.",
"option": {}
"option": {
"hideLogos": {
"label": "Hide logos"
}
}
},
"speedtestTracker": {
"name": "Speedtest Tracker",
@@ -3417,6 +3487,40 @@
"internalServerError": "Failed to fetch uptime data"
}
},
"traefik": {
"name": "Traefik",
"description": "Overview of Traefik routers, services, middlewares, and entry points",
"option": {
"showTcp": {
"label": "Show TCP"
},
"showUdp": {
"label": "Show UDP"
},
"showEntryPoints": {
"label": "Show entry points"
}
},
"instances": "Traefik instances",
"versionUnknown": "Version unknown",
"summary": {
"routers": "Routers",
"entryPoints": "Entry points"
},
"resource": {
"routers": "Routers",
"services": "Services",
"middlewares": "Middlewares"
},
"status": {
"healthy": "Healthy",
"errors": "{count} errors",
"warnings": "{count} warnings"
},
"error": {
"internalServerError": "Failed to fetch Traefik data"
}
},
"audioStats": {
"name": "Audio stats",
"description": "Library and playback statistics from Navidrome or Audiobookshelf",
@@ -3757,6 +3861,9 @@
},
"showDockerNetwork": {
"label": "Show Docker network chart"
},
"showStorage": {
"label": "Show storage chart"
}
},
"selectSystem": "Select system",
@@ -3792,6 +3899,11 @@
"read": "Read",
"write": "Write"
},
"storage": {
"title": "Storage",
"subtitle": "Per-filesystem disk usage",
"series": "Used"
},
"network": {
"title": "Bandwidth",
"subtitle": "Network traffic of public interfaces",
+10
View File
@@ -2284,6 +2284,11 @@
"fileSystem": {
"label": "Mostrar información del sistema de archivos"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
},
"defaultTab": {
"label": "Seleccionar pestaña predeterminada"
},
@@ -3538,6 +3543,11 @@
},
"showBackgroundBar": {
"label": "Mostrar la barra de uso en segundo plano"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
}
},
"status": {
+10
View File
@@ -2284,6 +2284,11 @@
"fileSystem": {
"label": ""
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
},
"defaultTab": {
"label": ""
},
@@ -3538,6 +3543,11 @@
},
"showBackgroundBar": {
"label": ""
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
}
},
"status": {
+10
View File
@@ -2284,6 +2284,11 @@
"fileSystem": {
"label": ""
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
},
"defaultTab": {
"label": ""
},
@@ -3538,6 +3543,11 @@
},
"showBackgroundBar": {
"label": ""
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
}
},
"status": {
+212 -202
View File
@@ -8,9 +8,9 @@
"action": {
"scratch": "Partir de zéro",
"importOldmarr": "Importer depuis Homarr avant la 1.0",
"restoreSqlite": "",
"restoreSqliteDescription": "",
"backToOptions": ""
"restoreSqlite": "Restaurer depuis une sauvegarde SQLite",
"restoreSqliteDescription": "Téléchargez une sauvegarde complète de la base de données SQLite depuis une instance Homarr précédente. Cela restaurera tous les tableaux, utilisateurs, applications, intégrations et paramètres.",
"backToOptions": "← Retour aux options de configuration"
}
},
"import": {
@@ -110,29 +110,29 @@
"message": "Une erreur s'est produite lors de la configuration de votre tableau de bord. Veuillez réessayer."
},
"helpers": {
"tooltip": "",
"description": ""
"tooltip": "Aides pour les URL",
"description": "Ces aides essaient de deviner l'URL de vos services. Si cela ne fonctionne pas, tapez simplement l'adresse manuellement."
}
},
"urlTemplate": {
"label": "",
"placeholder": "",
"hint": "",
"warning": "",
"label": "URL de base",
"placeholder": "homelab.local ou 192.168.1.1",
"hint": "Entrez votre domaine de base ou votre IP pour le remplissage automatique des URL d'intégration",
"warning": "Définir votre URL de base maintenant remplira automatiquement toutes les URL d'intégration, ce qui vous fera gagner du temps lors de la configuration",
"preview": "ex. {example}",
"mode": {
"subdomain": "Sous-domaine",
"hostPort": ""
"hostPort": "Hôte:Port"
}
},
"docker": {
"detected": "",
"detectedBreakdown": "",
"summary": "",
"appBadge": "",
"appSectionLabel": "",
"scanning": "",
"scanningDescription": ""
"detected": "Docker a détecté {count} services",
"detectedBreakdown": "Intégrations {integrations} · Applications {apps}",
"summary": "Les applications {apps} ont été détectées par Homarr, dont {integrations} ont des intégrations intégrées",
"appBadge": "App",
"appSectionLabel": "Applications à ajouter au tableau de bord ({count} sélectionné)",
"scanning": "Analyse de Docker...",
"scanningDescription": "Recherche de conteneurs en cours d'exécution sur votre système"
}
},
"finish": {
@@ -177,7 +177,7 @@
},
"password": {
"label": "Mot de passe",
"suggestionsTitle": "",
"suggestionsTitle": "Suggestions de mot de passe",
"suggestion": {
"length": "Contient au moins 8 caractères",
"lowercase": "Inclut une lettre minuscule",
@@ -205,7 +205,7 @@
"label": "Ouvrir les résultats de recherche dans un nouvel onglet"
},
"ddgBangs": {
"label": ""
"label": "Activer les bangs DuckDuckGo"
}
},
"error": {
@@ -298,10 +298,10 @@
"changeDdgBangs": {
"notification": {
"success": {
"message": ""
"message": "Bangs DuckDuckGo activés avec succès"
},
"error": {
"message": ""
"message": "Impossible d'activer/désactiver les bangs DuckDuckGo"
}
}
},
@@ -687,45 +687,45 @@
"customWidget": {
"page": {
"list": {
"title": "",
"noResults": "",
"loading": ""
"title": "Widgets personnalisés",
"noResults": "Pas encore de widgets personnalisés. Créez votre premier widget!",
"loading": "Chargement..."
},
"create": {
"title": "",
"subtitle": ""
"title": "Créer un widget personnalisé",
"subtitle": "Définissez une URL de l'API, l'authentification, et comment afficher les données de réponse."
},
"edit": {
"title": ""
"title": "Modifier le widget personnalisé"
}
},
"action": {
"create": "",
"import": "",
"save": "",
"delete": "",
"deleteConfirm": "",
"export": "",
"create": "Créer",
"import": "Importer",
"save": "Sauvegarder",
"delete": "Supprimer le widget personnalisé",
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer \"{name}\" ? Cette action est irréversible.",
"export": "Exporter",
"addMapping": "",
"addColumn": "",
"edit": "",
"addColumn": "Ajouter une colonne",
"edit": "Modifier",
"removeMapping": "",
"removeColumn": "",
"addItem": "",
"removeItem": "",
"addBar": "",
"duplicate": "",
"copyAiPrompt": "",
"enable": "",
"disable": "",
"menu": ""
"removeColumn": "Supprimer la colonne",
"addItem": "Ajouter un élément",
"removeItem": "Supprimer l'élément",
"addBar": "Ajouter une barre",
"duplicate": "Dupliquer",
"copyAiPrompt": "Copier le prompt IA",
"enable": "Activer",
"disable": "Désactiver",
"menu": "Actions"
},
"field": {
"name": "",
"description": "",
"iconUrl": "",
"url": "",
"authType": "",
"name": "Nom",
"description": "Description",
"iconUrl": "URL de l'icône",
"url": "URL",
"authType": "Type d’authentification",
"headerName": "",
"method": "",
"requestBody": "",
@@ -753,7 +753,7 @@
"labelPosition": "",
"kvLayout": "",
"striped": "",
"compact": "",
"compact": "Mode Compact",
"maxHeight": "",
"color": "Couleur",
"buttonLabel": "",
@@ -764,17 +764,17 @@
"successMessageHint": "",
"enabled": "Activé",
"template": {
"label": "",
"label": "Template JSX",
"description": ""
}
},
"placeholder": {
"exampleLabel": "",
"exampleLabelShort": "",
"exampleHeader": "",
"exampleUnit": "",
"iconUrl": "",
"url": "",
"exampleLabelShort": "Température du CPU",
"exampleHeader": "Nom",
"exampleUnit": "°C",
"iconUrl": "https://...",
"url": "https://api.example.com/api/v1/status",
"headerName": "",
"jsonPath": "",
"tablePath": "",
@@ -797,27 +797,27 @@
"sm": "",
"md": "",
"lg": "",
"xl": ""
"xl": "Très grand"
},
"labelPositionOption": {
"above": "",
"below": ""
"above": "Au-dessus",
"below": "En dessous"
},
"fieldset": {
"general": "",
"connection": "",
"display": ""
"general": "Général",
"connection": "Connexion API",
"display": "Configuration de l'affichage"
},
"displayTypeDescription": {
"singleValue": "",
"keyValue": "",
"singleValue": "Afficher une valeur extraite avec une étiquette et une unité",
"keyValue": "Afficher plusieurs valeurs étiquetées côte à côte",
"table": "",
"statGrid": "",
"progressBars": "",
"statusIndicator": "",
"statusIndicator": "Points d'état colorés montrant en ligne/hors ligne ou l'état de santé",
"countGrid": "",
"raw": "",
"actionButton": "",
"actionButton": "Un bouton qui déclenche une requête lorsqu'elle est cliquée",
"customJsx": ""
},
"method": {
@@ -848,8 +848,8 @@
},
"secret": {
"apiKey": "",
"username": "",
"password": "",
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"savedPlaceholder": ""
},
"preview": {
@@ -859,34 +859,34 @@
"rawResponse": ""
},
"notification": {
"created": "",
"updated": "",
"deleted": "",
"imported": "",
"created": "Widget \"{name}\" créé avec succès.",
"updated": "Widget \"{name}\" mis à jour avec succès.",
"deleted": "Widget \"{name}\" supprimé.",
"imported": "Widget importé avec succès.",
"aiPromptCopied": "",
"aiPromptDescription": "",
"aiPromptCopy": "",
"aiPromptCopy": "Copier dans le presse-papiers",
"aiPromptNoResponse": "",
"aiPromptCopyError": "",
"createError": "",
"updateError": "",
"importError": "",
"exportError": "",
"duplicated": "",
"duplicateError": "",
"toggleError": "",
"deleteError": "",
"previewError": "",
"executed": "",
"executeError": ""
"aiPromptCopyError": "Échec de la copie du prompt dans le presse-papiers. Votre navigateur peut ne pas prendre en charge cette fonctionnalité.",
"createError": "Impossible de créer le widget. Vérifiez que votre point de terminaison API est joignable.",
"updateError": "Impossible de mettre à jour le widget. Vos modifications n'ont pas été enregistrées.",
"importError": "Impossible d'importer le widget. Assurez-vous que le fichier est un export JSON de widget personnalisé valide.",
"exportError": "Impossible d'exporter le widget.",
"duplicated": "Widget \"{name}\" dupliqué.",
"duplicateError": "Impossible de dupliquer le widget.",
"toggleError": "Impossible de changer le statut du widget.",
"deleteError": "Impossible de supprimer le widget.",
"previewError": "Impossible de récupérer l'aperçu. Vérifiez l'URL et réessayez.",
"executed": "Action exécutée avec succès.",
"executeError": "Échec de l'action: {error}"
},
"table": {
"name": "",
"url": "",
"display": ""
"name": "Nom",
"url": "URL",
"display": "Affichage"
},
"widget": {
"disabled": ""
"disabled": "Ce widget est désactivé"
}
},
"integration": {
@@ -1007,21 +1007,21 @@
"miscellaneous": "",
"smartHomeServer": "",
"indexerManager": "",
"healthMonitoring": "",
"search": "",
"mediaTranscoding": "",
"networkController": "",
"releasesProvider": "",
"notifications": "",
"firewall": "",
"healthMonitoring": "Santé",
"search": "Rechercher",
"mediaTranscoding": "Transcodage",
"networkController": "Réseau",
"releasesProvider": "Versions",
"notifications": "Notifications",
"firewall": "Pare-feu",
"timetable": "",
"photoService": "",
"documents": "",
"photoService": "Photos",
"documents": "Documents",
"notes": "Notes",
"mediaMonitoring": "Supervision",
"speedtest": "Test de débit",
"analytics": "",
"vpn": "",
"analytics": "Statistiques",
"vpn": "VPN",
"mediaLibrary": "Bibliothèque de médias",
"uptimeMonitoring": "Surveillance des disponibilités"
},
@@ -1299,7 +1299,7 @@
},
"slug": {
"label": "Slug de la page de statut",
"newLabel": ""
"newLabel": "Nouveau slug de page de statut"
}
}
},
@@ -1313,7 +1313,7 @@
"description": ""
},
"navidrome": {
"name": "",
"name": "Navidrome",
"description": ""
},
"uptimeKuma": {
@@ -1385,7 +1385,7 @@
"add": "Ajouter",
"apply": "Appliquer",
"backToOverview": "Retourner à l'aperçu",
"skip": "",
"skip": "Ignorer",
"change": "Modifier",
"create": "Créer",
"createAnother": "Créer et recommencer",
@@ -1407,20 +1407,20 @@
"checkLogs": "Vérifiez les logs pour plus de détails",
"tryAgain": "Réessayer",
"loading": "Chargement",
"show": "",
"hide": ""
"show": "Afficher",
"hide": "Masquer"
},
"here": "ici",
"iconPicker": {
"label": "URL de l'icône :",
"header": "Tapez le nom ou les objets pour filtrer les icônes... Homarr recherchera les icônes {countIcons} pour vous.",
"headerLoading": "",
"noResults": "",
"error": ""
"headerLoading": "Tapez pour rechercher les icônes…",
"noResults": "Aucune icône ne correspond à \"{search}\".",
"error": "Impossible de charger les icônes."
},
"colorScheme": {
"options": {
"auto": "",
"auto": "Auto",
"light": "Clair",
"dark": "Sombre"
}
@@ -1468,7 +1468,7 @@
"switchToDarkMode": "Activer le mode sombre",
"switchToLightMode": "Activer le mode clair",
"management": "Administration",
"docker": "",
"docker": "Docker",
"preferences": "Vos préférences",
"logout": "Se déconnecter",
"login": "Connexion",
@@ -1578,7 +1578,7 @@
"label": {
"create": "Nouvelle catégorie",
"changePosition": "Modifier la position",
"settings": ""
"settings": "Réglages"
}
},
"openAllInNewTabs": {
@@ -1599,9 +1599,9 @@
"menu": {
"label": {
"settings": "Paramètres",
"refresh": "",
"options": "",
"actions": ""
"refresh": "Actualiser",
"options": "Options",
"actions": "Actions"
}
},
"create": {
@@ -1629,11 +1629,11 @@
"edit": {
"title": "Éditer l'élément",
"tab": {
"widget": "",
"app": ""
"widget": "Éditer le widget",
"app": "Modifier l'application"
},
"app": {
"propagationNotice": ""
"propagationNotice": "Les modifications apportées à cette application seront appliquées partout où elle est utilisée sur tous les tableaux de bord."
},
"advancedOptions": {
"label": "Options avancées",
@@ -1661,11 +1661,11 @@
},
"widget": {
"integrationSelect": {
"configureIntegrationFirst": "",
"loading": "",
"placeholder": "",
"noResults": "",
"loadError": ""
"configureIntegrationFirst": "Connectez d'abord une intégration",
"loading": "Chargement...",
"placeholder": "Choisir une option",
"noResults": "Aucun résultat trouvé",
"loadError": "Impossible de charger les options"
},
"app": {
"name": "App",
@@ -1746,7 +1746,7 @@
"label": "Ouvrir dans un nouvel onglet"
},
"withBorder": {
"label": ""
"label": "Afficher la bordure"
},
"items": {
"label": "Signets",
@@ -1759,7 +1759,7 @@
"description": "",
"option": {
"showBroadcastMessage": {
"label": ""
"label": "Afficher les messages de diffusion"
}
}
},
@@ -1899,7 +1899,7 @@
},
"timetable": {
"name": "",
"description": "",
"description": "Affiche les heures de départ pour une station",
"title": "",
"noStation": "",
"option": {
@@ -1997,17 +1997,17 @@
},
"empty": "",
"emptyContent": "",
"notFound": "",
"notFound": "Note introuvable",
"noNotes": "",
"integrationRequired": "",
"updatedAt": "",
"untitled": "",
"edit": "",
"save": "",
"cancel": "",
"edit": "Modifier",
"save": "Sauvegarder",
"cancel": "Annuler",
"readOnlyViewer": "",
"saveForbidden": "",
"saveFailed": ""
"saveForbidden": "Vous n'êtes pas autorisé à modifier cette note.",
"saveFailed": "Impossible d'enregistrer cette note. Veuillez réessayer."
},
"iframe": {
"name": "iFrame",
@@ -2284,6 +2284,11 @@
"fileSystem": {
"label": "Afficher les infos sur le système de fichiers"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
},
"defaultTab": {
"label": "Onglet par défaut"
},
@@ -2430,27 +2435,27 @@
"description": "",
"option": {
"showBattery": {
"label": ""
"label": "Afficher la batterie"
},
"showLoad": {
"label": ""
"label": "Afficher la charge"
},
"showVoltage": {
"label": ""
"label": "Afficher la tension"
}
},
"status": {
"online": "",
"onBattery": "",
"lowBattery": "",
"charging": "",
"unknown": ""
"online": "En ligne",
"onBattery": "Sur batterie",
"lowBattery": "Batterie faible",
"charging": "Chargement",
"unknown": "Inconnu"
},
"field": {
"runtime": "",
"load": "",
"input": "",
"output": ""
"runtime": "Runtime",
"load": "Charger",
"input": "Entrée",
"output": "Sortie"
},
"error": {
"internalServerError": ""
@@ -2461,7 +2466,7 @@
"description": "Surveillance avancée du serveur multimédia avec des données détaillées de flux et de transcodage",
"option": {
"showStreams": {
"label": ""
"label": "Afficher les flux actifs"
},
"showStats": {
"label": "Afficher le résumé des statistiques"
@@ -2474,13 +2479,13 @@
}
},
"stats": {
"activeStreams": "",
"activeStreams": "Flux actif",
"totalUsers": "Utilisateurs totaux",
"transcodes": "",
"transcodes": "Transcodages",
"bandwidth": "Bande passante"
},
"streams": {
"empty": "",
"empty": "Aucun flux actif",
"user": "Utilisateur",
"media": "Média",
"state": "État",
@@ -2522,32 +2527,32 @@
"avgDuration": "",
"option": {
"websiteId": {
"label": "",
"description": "",
"placeholder": "",
"loading": "",
"noWebsites": "",
"configureIntegrationFirst": ""
"label": "Site Web",
"description": "Sélectionnez un site web suivi par votre instance Umami",
"placeholder": "Sélectionnez un site",
"loading": "Chargement des sites Web...",
"noWebsites": "Aucun site web trouvé",
"configureIntegrationFirst": "Connectez d'abord une intégration Umami"
},
"timeFrame": {
"label": "",
"description": "",
"label": "Intervalle de temps",
"description": "La période d'affichage des données du visiteur pour",
"option": {
"today": "",
"24h": "",
"7d": "",
"30d": "",
"month": "",
"lastMonth": ""
"today": "Aujourd'hui",
"24h": "Dernières 24 heures",
"7d": "7 derniers jours",
"30d": "30 derniers jours",
"month": "Ce mois",
"lastMonth": "Le mois dernier"
}
},
"eventName": {
"label": "",
"description": "",
"placeholder": "",
"loading": "",
"noEvents": "",
"none": "",
"label": "Evénement",
"description": "Afficher le total des occurrences d'un événement spécifique",
"placeholder": "Sélectionner un événement",
"loading": "Chargement des événements...",
"noEvents": "Aucun événement trouvé",
"none": "Aucun événement",
"configureWebsiteFirst": ""
},
"chartType": {
@@ -2597,15 +2602,15 @@
"direct": ""
},
"topReferrers": {
"direct": ""
"direct": "Directe"
},
"events": {
"selectPrompt": ""
"selectPrompt": "Sélectionnez les événements à afficher"
},
"error": {
"noIntegration": "",
"noWebsite": "",
"noData": "",
"noWebsite": "Aucun site web configuré",
"noData": "Aucune donnée disponible",
"internalServerError": ""
}
},
@@ -2705,10 +2710,10 @@
}
},
"paperlessNgx": {
"name": "",
"description": "",
"documentsTotal": "",
"documentsInbox": "",
"name": "Paperless-ngx",
"description": "Aperçu de votre archive de documents Paperless-ngx",
"documentsTotal": "Nombre total de documents",
"documentsInbox": "Boîte de réception",
"correspondents": "",
"tags": "",
"documentTypes": "",
@@ -2717,7 +2722,7 @@
"total": "",
"option": {
"showInboxRatio": {
"label": "",
"label": "Afficher le ratio de la boîte de réception",
"description": ""
},
"showInboxRing": {
@@ -3158,7 +3163,7 @@
"label": "Fournisseur"
},
"providerUrl": {
"label": ""
"label": "URL du fournisseur"
},
"identifier": {
"label": "Identifiant",
@@ -3209,11 +3214,11 @@
"tooltip": "Le fournisseur n'a pas pu être analysé, veuillez le définir manuellement après l'importation des images"
},
"tokens": {
"label": "",
"token": "",
"configured": "",
"notConfigured": "",
"save": ""
"label": "Tokens du fournisseur",
"token": "Token",
"configured": "Token configuré",
"notConfigured": "Aucun token défini",
"save": "Sauvegarder"
}
}
},
@@ -3236,9 +3241,9 @@
"invalidIdentifier": "Identifiant non valide",
"noMatchingVersion": "Aucune version correspondante trouvée",
"noReleasesFound": "Aucune publication trouvée",
"noProviderSelected": "",
"noProviderSelected": "Aucun fournisseur sélectionné",
"noProviderResponse": "Aucune réponse du fournisseur",
"unexpected": ""
"unexpected": "Erreur de fournisseur inattendue"
}
}
},
@@ -3334,24 +3339,24 @@
"option": {}
},
"speedtestTracker": {
"name": "",
"name": "Suivi de test de vitesse",
"description": "",
"option": {
"showLatestResult": {
"label": ""
"label": "Afficher le dernier résultat"
},
"showStats": {
"label": ""
"label": "Afficher les moyennes"
},
"showRecentResults": {
"label": ""
"label": "Afficher le dernier résultat"
},
"showPingGraph": {
"label": ""
"label": "Afficher le graphique de ping"
}
},
"latestResult": "",
"averages": "",
"latestResult": "Dernier résultat",
"averages": "Moyennes",
"tests": "",
"recentResults": "",
"recentResultsSpeed": "",
@@ -3386,7 +3391,7 @@
},
"showTotalMonitors": {
"label": "",
"description": ""
"description": "Afficher le nombre total de moniteurs configurés"
},
"showUpCount": {
"label": "",
@@ -3401,35 +3406,35 @@
"description": ""
}
},
"averageUptime": "",
"averageUptime": "Temps de disponibilité moyen (24h)",
"stats": {
"totalMonitors": "",
"totalMonitors": "Nombre total de moniteurs",
"upCount": "",
"downCount": "",
"pausedCount": ""
},
"error": {
"internalServerError": ""
"internalServerError": "Impossible de récupérer les données de disponibilité"
}
},
"audioStats": {
"name": "",
"name": "Statistiques audio",
"description": "",
"option": {
"showArtists": {
"label": "",
"description": ""
"label": "Afficher l'artiste",
"description": "Afficher le nombre total d'artistes dans votre bibliothèque musicale (Navidrome)"
},
"showAlbums": {
"label": "",
"description": ""
"label": "Afficher l'album",
"description": "Afficher le nombre total d'albums dans votre bibliothèque musicale (Navidrome)"
},
"showSongs": {
"label": "",
"label": "Afficher les titres",
"description": ""
},
"showNowPlaying": {
"label": "",
"label": "Afficher la lecture en cours",
"description": ""
},
"showNowPlayingList": {
@@ -3538,6 +3543,11 @@
},
"showBackgroundBar": {
"label": "Afficher la barre d'utilisation en arrière-plan"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
}
},
"status": {
+10
View File
@@ -2284,6 +2284,11 @@
"fileSystem": {
"label": "הצג מידע על מערכת הקבצים"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
},
"defaultTab": {
"label": "כרטיסיית ברירת מחדל"
},
@@ -3538,6 +3543,11 @@
},
"showBackgroundBar": {
"label": ""
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
}
},
"status": {
+10
View File
@@ -2284,6 +2284,11 @@
"fileSystem": {
"label": ""
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
},
"defaultTab": {
"label": ""
},
@@ -3538,6 +3543,11 @@
},
"showBackgroundBar": {
"label": ""
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
}
},
"status": {
+10
View File
@@ -2284,6 +2284,11 @@
"fileSystem": {
"label": "Fájlrendszer-információk megjelenítése"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
},
"defaultTab": {
"label": "Alapértelmezett fül"
},
@@ -3538,6 +3543,11 @@
},
"showBackgroundBar": {
"label": "Háttérhasználati sáv megjelenítése"
},
"visibleStorageVolumes": {
"label": "",
"description": "",
"placeholder": ""
}
},
"status": {
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more