Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68ce7e73c0 | ||
|
|
87f1f329da | ||
|
|
9301e224ec | ||
|
|
e648abf78e | ||
|
|
3d2a2d7b55 | ||
|
|
c076a36fec | ||
|
|
951e707438 | ||
|
|
e1ba193ea3 | ||
|
|
411c809c55 | ||
|
|
576856d634 | ||
|
|
def3ede3bc | ||
|
|
d9c503d01b | ||
|
|
82aa905214 | ||
|
|
4b351bb209 | ||
|
|
13c9e5e181 | ||
|
|
6b047a9c8e | ||
|
|
7a768517ef | ||
|
|
4ae8d527a8 | ||
|
|
05a48e2930 | ||
|
|
33395ae226 | ||
|
|
174c5f98f8 | ||
|
|
a2cf2d6cff | ||
|
|
9fb2e37605 | ||
|
|
713fcd6f3f | ||
|
|
6b10cd6874 | ||
|
|
cabc66cc69 | ||
|
|
2c5368dd4e | ||
|
|
7d93df04dd | ||
|
|
7c152cf4fe | ||
|
|
718f095cb7 | ||
|
|
24c5d04bfa | ||
|
|
0e513fffd5 | ||
|
|
2ada1aa249 | ||
|
|
af89423f66 | ||
|
|
9864fed775 | ||
|
|
bcd36b1b27 | ||
|
|
a2f7b35c5c | ||
|
|
616a444e64 | ||
|
|
65ba866239 | ||
|
|
260b167134 | ||
|
|
7a4de03c0e | ||
|
|
68f8e8146b | ||
|
|
1297e6c2ac | ||
|
|
31288dcbfe | ||
|
|
fc2c251557 | ||
|
|
d067261171 | ||
|
|
ae2f05c834 | ||
|
|
c7fa7cdb50 | ||
|
|
0c28224e82 | ||
|
|
4e6272dbe2 | ||
|
|
f5bc090d36 | ||
|
|
f23e06dff1 | ||
|
|
24188ce51f | ||
|
|
e31f60fdec | ||
|
|
6bb547f3c5 | ||
|
|
b07cc14d0e | ||
|
|
0dc93c1a02 | ||
|
|
00661c9b3d | ||
|
|
57e18f6ce6 | ||
|
|
29369e23c1 | ||
|
|
687edb9226 | ||
|
|
1b3eb1ccc8 | ||
|
|
6f6a989ba6 | ||
|
|
3bb6037d35 | ||
|
|
c5e31513e2 | ||
|
|
dae1cae894 | ||
|
|
41b75ef5a0 | ||
|
|
8e0f8cd46a | ||
|
|
84275bd19a | ||
|
|
b232cc5bb0 | ||
|
|
18afb866b7 | ||
|
|
46454cc0f0 | ||
|
|
d74728c28d | ||
|
|
78bd66e803 | ||
|
|
800caf6e5e | ||
|
|
bc26231d13 | ||
|
|
f61e7539d7 | ||
|
|
005c2e4cd1 |
No files matched your search
+8
-1
@@ -59,4 +59,11 @@ TURBO_TELEMETRY_DISABLED=1
|
||||
# ENABLE_KUBERNETES=true
|
||||
|
||||
# Enable mock integration
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION=true
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION=true
|
||||
|
||||
# Enable demo mode (seeds a demo user "demo"/"demo", a sample board and a mock integration)
|
||||
# DEMO_MODE=true
|
||||
|
||||
# Whether demo mode is read-only. Defaults to true (the public demo blocks all mutations).
|
||||
# Set to false for writable preview deployments so the demo user can modify the dashboard.
|
||||
# DEMO_READ_ONLY=false
|
||||
@@ -33,6 +33,7 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.70.0
|
||||
- 1.69.2
|
||||
- 1.69.0
|
||||
- 1.68.0
|
||||
|
||||
@@ -25,7 +25,7 @@ env:
|
||||
|
||||
jobs:
|
||||
lint-format-autofix:
|
||||
if: github.event_name == 'pull_request'
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
## [1.70.0](https://github.com/homarr-labs/homarr/compare/v1.69.2...v1.70.0) (2026-07-10)
|
||||
|
||||
## [1.69.2](https://github.com/homarr-labs/homarr/compare/v1.69.1...v1.69.2) (2026-07-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
+27
@@ -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
|
||||
@@ -127,6 +135,13 @@ packages/integrations/src/overseerr @manuel-rw
|
||||
apps/docs/docs/integrations/paperless-ngx @ajnart
|
||||
packages/integrations/src/paperless-ngx @ajnart
|
||||
|
||||
# PatchMon - introduced in https://github.com/homarr-labs/homarr/pull/6119 by @mrjoshuap
|
||||
apps/docs/docs/integrations/patchmon @mrjoshuap
|
||||
packages/integrations/src/patchmon @mrjoshuap
|
||||
packages/widgets/src/patchmon @mrjoshuap
|
||||
packages/api/src/router/widgets/patchmon.ts @mrjoshuap
|
||||
packages/request-handler/src/patchmon.ts @mrjoshuap
|
||||
|
||||
# PeaNUT - introduced in https://github.com/homarr-labs/homarr/pull/5862 by @afonsojramos
|
||||
apps/docs/docs/integrations/peanut @afonsojramos
|
||||
packages/integrations/src/peanut @afonsojramos
|
||||
@@ -159,6 +174,10 @@ packages/integrations/src/search-ch @Meierschlumpf
|
||||
apps/docs/docs/integrations/speedtest-tracker @kylegrieder
|
||||
packages/integrations/src/speedtest-tracker @kylegrieder
|
||||
|
||||
# Synology - introduced in https://github.com/homarr-labs/homarr/pull/6121 by @mrjoshuap
|
||||
apps/docs/docs/integrations/synology @mrjoshuap
|
||||
packages/integrations/src/synology @mrjoshuap
|
||||
|
||||
# Technitium DNS - introduced in https://github.com/homarr-labs/homarr/pull/5832 by @lopsch
|
||||
apps/docs/docs/integrations/technitium-dns @lopsch
|
||||
packages/integrations/src/technitium @lopsch
|
||||
@@ -187,6 +206,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
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Helm chart to deploy homarr for Kubernetes
|
||||
|
||||
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
||||
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
||||
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
||||
| image.tag | string | `"v1.60.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| image.tag | string | `"v1.69.2"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
|
||||
| ingress.annotations | object | `{}` | Ingress annotations |
|
||||
| ingress.enabled | bool | `false` | Enable ingress |
|
||||
|
||||
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,39 @@
|
||||
---
|
||||
title: "Open WebUI"
|
||||
description: "Self-hosted AI chat interface for local and remote LLM providers"
|
||||
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 { openWebUiIntegration } from ".";
|
||||
import { openWebUiWidget } from "../../widgets/open-webui";
|
||||
|
||||
<IntegrationHeader integration={openWebUiIntegration} categories={["AI", "Chat"]} />
|
||||
|
||||
[Open WebUI](https://openwebui.com/) is a self-hosted AI chat interface that aggregates multiple LLM providers (Ollama, OpenAI-compatible APIs, and more) behind a single API. Homarr connects to it so you can chat with your models directly from your dashboard.
|
||||
|
||||
### Widgets & Capabilities
|
||||
|
||||
<IntegrationCapabilites items={[{ widget: openWebUiWidget }]} />
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
:::info URL configuration
|
||||
Use the root URL of your Open WebUI instance, for example `http://localhost:8080` (Open WebUI's default port). Homarr calls the Open WebUI API under `/api` automatically.
|
||||
:::
|
||||
|
||||
### Secrets
|
||||
|
||||
<IntegrationSecrets
|
||||
secrets={[
|
||||
{
|
||||
credentials: ["apiKey"],
|
||||
steps: [],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const openWebUiIntegration: IntegrationDefinition = {
|
||||
name: "Open WebUI",
|
||||
description: "Self-hosted AI chat interface for local and remote LLM providers",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/open-webui.svg",
|
||||
path: "../../integrations/open-webui",
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
title: 'PatchMon'
|
||||
description: "PatchMon is a patch management and monitoring solution for Linux hosts."
|
||||
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 { patchmonIntegration } from '.';
|
||||
import { patchmonWidget } from '@site/docs/widgets/patchmon';
|
||||
|
||||
|
||||
<IntegrationHeader
|
||||
integration={patchmonIntegration}
|
||||
categories={['Health Monitoring']}
|
||||
/>
|
||||
|
||||
PatchMon provides host and package update statistics including total hosts, hosts needing updates, and security update counts.
|
||||
|
||||
The PatchMon widget requires users to be **signed in to Homarr**; see the [PatchMon widget documentation](/docs/widgets/patchmon#authentication) for details on authentication and public boards.
|
||||
|
||||
### Widgets & Capabilities
|
||||
<IntegrationCapabilites
|
||||
items={[
|
||||
{ widget: patchmonWidget },
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the integration
|
||||
<AddingIntegration />
|
||||
|
||||
### Secrets
|
||||
<IntegrationSecrets secrets={[{
|
||||
credentials: ['patchmonApiKey', 'patchmonApiSecret'],
|
||||
steps: [
|
||||
'Open your PatchMon instance',
|
||||
'Navigate to Settings and create a new GetHomepage API key',
|
||||
'Copy the API key and secret from the success dialog',
|
||||
'Paste them into Homarr — Basic authentication is handled automatically',
|
||||
]
|
||||
}]} />
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const patchmonIntegration: IntegrationDefinition = {
|
||||
name: "PatchMon",
|
||||
description:
|
||||
"PatchMon is a patch management and monitoring solution for Linux hosts, tracking package updates and security patches across your infrastructure.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/patchmon.svg",
|
||||
path: "../../integrations/patchmon",
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
title: "Synology DiskStation"
|
||||
description: "Monitor CPU, memory, storage volumes, and system health from Synology DSM."
|
||||
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 { synologyIntegration } from ".";
|
||||
import { systemResourcesWidget } from "@site/docs/widgets/system-resources";
|
||||
import { systemDisksWidget } from "@site/docs/widgets/system-disks";
|
||||
import { healthMonitoringWidget } from "@site/docs/widgets/health-monitoring";
|
||||
import Alert from "@theme/Admonition";
|
||||
|
||||
<IntegrationHeader integration={synologyIntegration} categories={["NAS", "Hardware"]} />
|
||||
|
||||
### Widgets & Capabilities
|
||||
|
||||
<IntegrationCapabilites
|
||||
items={[
|
||||
{ widget: healthMonitoringWidget },
|
||||
{ widget: systemResourcesWidget },
|
||||
{ widget: systemDisksWidget },
|
||||
]}
|
||||
/>
|
||||
|
||||
### Supported metrics
|
||||
|
||||
| Widget section | DSM API source | May be unavailable |
|
||||
| ---------------------------- | ----------------------------------------------------- | ------------------------------------------------------- |
|
||||
| CPU ring / chart | `SYNO.Core.System.Utilization` | — |
|
||||
| Memory ring / chart | `SYNO.Core.System.Utilization` | — |
|
||||
| CPU temperature ring | `SYNO.Core.System` system info | Models without a temperature sensor |
|
||||
| Network charts | `SYNO.Core.System.Utilization` network totals | Some DSM builds or permission configurations |
|
||||
| Load average (info modal) | `SYNO.Core.System.Utilization` CPU load fields | — |
|
||||
| Storage volume usage | `SYNO.Core.System` storage info / Storage Manager API | — |
|
||||
| SMART status and temperature | Storage Manager and SMART APIs | Disk-to-volume mapping may be incomplete on some setups |
|
||||
| Reboot / update indicator | `SYNO.Core.System.Status` / upgrade APIs | Permission-dependent |
|
||||
| GPU | Not available | Typical DiskStation models |
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
<Alert type="info" title="Use the correct DSM URL">
|
||||
Enter the HTTP or HTTPS URL of your Synology DSM web interface, for example <code>http://diskstation.local:5000</code>{" "}
|
||||
or <code>https://diskstation.local:5001</code>. If DSM uses a self-signed certificate, add it under{" "}
|
||||
<a href="/docs/management/certificates">Management > Tools > Certificates</a> so Homarr can connect securely.
|
||||
</Alert>
|
||||
|
||||
### Secrets
|
||||
|
||||
<IntegrationSecrets
|
||||
secrets={[
|
||||
{
|
||||
credentials: ["username", "password"],
|
||||
steps: [
|
||||
"Create a dedicated DSM user with a strong password for Homarr.",
|
||||
"Add the user to the default Administrators group so Homarr can read `SYNO.Core.System` metrics.",
|
||||
"Under User Groups and Permissions, deny access to shared folders and most applications.",
|
||||
"Allow login to DSM only, optionally restricted by source IP address.",
|
||||
"Log in once with the service account in DSM to accept terms and conditions.",
|
||||
],
|
||||
body: (
|
||||
<>
|
||||
<Alert type="caution" title="Administrators group required">
|
||||
Homarr reads system, storage, and utilization data from APIs that require membership in the default
|
||||
Administrators group.
|
||||
</Alert>
|
||||
<Alert type="warning" title="Two-factor authentication is not supported">
|
||||
Accounts with 2-step verification enabled cannot authenticate with this integration. Use a dedicated service
|
||||
account without 2FA.
|
||||
</Alert>
|
||||
<Alert type="tip" title="First-time setup">
|
||||
If login fails during testing, temporarily disable automatic blocking for invalid logins under Control Panel
|
||||
> Security > Protection, then re-enable it after Homarr connects successfully.
|
||||
</Alert>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Storage volume selection
|
||||
|
||||
Health Monitoring and System Disks widgets can filter Synology storage volumes when **every** selected integration is a Synology DiskStation. Leave **Visible storage volumes** empty to show all volumes, or pick specific volumes from the list. When multiple Synology integrations are connected, volumes are labeled with the integration name.
|
||||
|
||||
<Alert type="note" title="Synology-only option">
|
||||
The volume filter is hidden when the widget also uses non-Synology integrations such as TrueNAS or Unraid.
|
||||
</Alert>
|
||||
|
||||
<Alert type="note" title="Volume ordering">
|
||||
When no volume filter is configured, the first volume returned by DSM is not guaranteed to be `volume_1`.
|
||||
</Alert>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const synologyIntegration: IntegrationDefinition = {
|
||||
name: "Synology DiskStation",
|
||||
description: "Monitor CPU, memory, storage volumes, and system health from Synology DSM.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/synology.svg",
|
||||
path: "../../integrations/synology",
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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.
|
||||
@@ -141,6 +141,7 @@ Homarr exposes 50+ tools organized by namespace. Each tool is either a **query**
|
||||
| `dnsHole` | DNS hole summary, toggle blocking | GET + POST |
|
||||
| `downloads` | Active downloads, pause/resume/delete | GET + POST |
|
||||
| `healthMonitoring` | Resource usage stats | GET |
|
||||
| `patchmon` | Host patch and security update statistics | GET |
|
||||
| `mediaRequests` | Approve/decline media requests | GET + POST |
|
||||
| `mediaServer` | Media server sessions and stats | GET |
|
||||
| `smartHome` | Execute automations, device states | GET + POST |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -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} />
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -18,7 +18,9 @@ import beszelSystemGrid from './img/beszel-system-grid.png';
|
||||
categories={['System Monitoring']}
|
||||
/>
|
||||
|
||||
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space.
|
||||
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
|
||||
|
||||
Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
|
||||
|
||||
### Screenshots
|
||||
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
---
|
||||
title: 'Beszel System Stats'
|
||||
description: 'Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel.'
|
||||
title: "Beszel System Stats"
|
||||
description: "Time-series charts for CPU, memory, disk, network, and Docker container metrics from Beszel."
|
||||
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 { beszelSystemStatsWidget } from '.';
|
||||
import { beszelIntegration } from '@site/docs/integrations/beszel';
|
||||
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 { beszelSystemStatsWidget } from ".";
|
||||
import { beszelIntegration } from "@site/docs/integrations/beszel";
|
||||
|
||||
import beszelSystemStats from './img/beszel-system-stats.png';
|
||||
import beszelSystemStats from "./img/beszel-system-stats.png";
|
||||
|
||||
<WidgetHeader
|
||||
widget={beszelSystemStatsWidget}
|
||||
categories={['System Monitoring', 'Charts']}
|
||||
/>
|
||||
<WidgetHeader widget={beszelSystemStatsWidget} categories={["System Monitoring", "Charts"]} />
|
||||
|
||||
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. Data is cached and refreshed periodically. You can select a time period ranging from 1 hour to 30 days.
|
||||
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -26,14 +23,19 @@ This widget displays time-series charts for a selected Beszel system, including
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: beszelIntegration,
|
||||
note: "Displays time-series charts for a selected system"
|
||||
}]} />
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: beszelIntegration,
|
||||
note: "Displays time-series charts for a selected system",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={beszelSystemStatsWidget.configuration} />
|
||||
@@ -17,7 +17,9 @@ import { beszelIntegration } from '@site/docs/integrations/beszel';
|
||||
categories={['System Monitoring']}
|
||||
/>
|
||||
|
||||
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column.
|
||||
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
|
||||
|
||||
Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
|
||||
@@ -1,61 +1,71 @@
|
||||
---
|
||||
title: 'System Health Monitoring'
|
||||
description: 'Displays information showing the health and status of your system(s).'
|
||||
title: "System Health Monitoring"
|
||||
description: "Displays information showing the health and status of your system(s)."
|
||||
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 { 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 { IconAd } from "@tabler/icons-react";
|
||||
import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import { healthMonitoringWidget } from '.';
|
||||
import { dashDotIntegration } from '@site/docs/integrations/dash-dot';
|
||||
import { openMediaVaultIntegration } from '@site/docs/integrations/open-media-vault';
|
||||
import { proxmoxIntegration } from '@site/docs/integrations/proxmox';
|
||||
import { glancesIntegration } from '@site/docs/integrations/glances';
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
import { healthMonitoringWidget } from ".";
|
||||
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
|
||||
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
|
||||
import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
|
||||
import { glancesIntegration } from "@site/docs/integrations/glances";
|
||||
|
||||
|
||||
<WidgetHeader
|
||||
widget={healthMonitoringWidget}
|
||||
categories={['Health monitoring']}
|
||||
/>
|
||||
<WidgetHeader widget={healthMonitoringWidget} categories={["Health monitoring"]} />
|
||||
|
||||
This widget displays information about the health and status of your system(s), including CPU, memory, disk usage, and more.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import systemView from './img/system-view.png';
|
||||
import clusterView from './img/cluster-view.png';
|
||||
import { truenasIntegration } from '@site/docs/integrations/truenas';
|
||||
import systemView from "./img/system-view.png";
|
||||
import clusterView from "./img/cluster-view.png";
|
||||
import { truenasIntegration } from "@site/docs/integrations/truenas";
|
||||
import { synologyIntegration } from "@site/docs/integrations/synology";
|
||||
|
||||
<img src={systemView} alt="health-monitoring system-view" width={256} height={256} />
|
||||
<img src={clusterView} alt="health-monitoring cluster-view" width={256} height={256} />
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: dashDotIntegration,
|
||||
note: "Will display information on the system tab"
|
||||
}, {
|
||||
integration: openMediaVaultIntegration,
|
||||
note: "Will display information on the system tab"
|
||||
}, {
|
||||
integration: truenasIntegration,
|
||||
note: "Will display information on the system tab"
|
||||
}, {
|
||||
integration: glancesIntegration,
|
||||
note: "Will display information on the system tab"
|
||||
}, {
|
||||
integration: proxmoxIntegration,
|
||||
note: "Will display information on the cluster tab, only supports one integration at a time"
|
||||
}]} />
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: dashDotIntegration,
|
||||
note: "Will display information on the system tab",
|
||||
},
|
||||
{
|
||||
integration: openMediaVaultIntegration,
|
||||
note: "Will display information on the system tab",
|
||||
},
|
||||
{
|
||||
integration: truenasIntegration,
|
||||
note: "Will display information on the system tab",
|
||||
},
|
||||
{
|
||||
integration: synologyIntegration,
|
||||
note: "Will display information on the system tab",
|
||||
},
|
||||
{
|
||||
integration: glancesIntegration,
|
||||
note: "Will display information on the system tab",
|
||||
},
|
||||
{
|
||||
integration: proxmoxIntegration,
|
||||
note: "Will display information on the cluster tab, only supports one integration at a time",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={healthMonitoringWidget.configuration} />
|
||||
@@ -38,6 +38,13 @@ export const healthMonitoringWidget: WidgetDefinition = {
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Visible storage volumes",
|
||||
description:
|
||||
"Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.",
|
||||
values: "List of storage volumes",
|
||||
defaultValue: "Empty list",
|
||||
},
|
||||
{
|
||||
name: "Default tab",
|
||||
description: "Tab that should be visible by default. Only used when multiple integrations are available.",
|
||||
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: "AI Chat"
|
||||
description: "Chat with AI models through Open WebUI, with model picking and history."
|
||||
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 { openWebUiWidget } from ".";
|
||||
import { openWebUiIntegration } from "@site/docs/integrations/open-webui";
|
||||
|
||||
<WidgetHeader widget={openWebUiWidget} categories={["AI", "Chat"]} />
|
||||
|
||||
The AI Chat widget lets you chat with AI models served by Open WebUI directly from your dashboard. Responses stream in token-by-token, you can pick any model your Open WebUI instance exposes (Ollama, OpenAI, and other configured providers), and you can browse and reopen your Open WebUI chat history. A full attachment menu lets you ground a conversation on images, uploaded or stored files, web pages, knowledge bases, notes, and previous chats.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: openWebUiIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={openWebUiWidget.configuration} />
|
||||
|
||||
### Features
|
||||
|
||||
| Feature | Description |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Model picker** | Lists every model exposed by Open WebUI's `/api/models` endpoint, selectable from the chat input. The first model is selected by default. |
|
||||
| **Streaming responses** | Assistant replies stream in live as they are generated. |
|
||||
| **Chat history** | Browse and reopen previous chats. New conversations are saved back to Open WebUI. |
|
||||
| **Image attachments** | Attach or capture images for vision-capable models (sent inline as multimodal content). A hint warns when the model has no vision support. |
|
||||
| **File attachments** | Upload new documents or attach files already stored in Open WebUI; their content is retrieved as context. |
|
||||
| **Web pages** | Attach a URL; Open WebUI ingests the page and the relevant parts are retrieved as context. |
|
||||
| **Knowledge bases** | Ground replies on any RAG knowledge collection your account owns, with an expandable picker to inspect a base's files. |
|
||||
| **Notes** | Attach the contents of your Open WebUI notes. |
|
||||
| **Reference chats** | Bring a previous conversation in as context for the current one. |
|
||||
| **Voice messages** | Record from your microphone and have Open WebUI transcribe it into the message box (requires STT configured in Open WebUI). |
|
||||
|
||||
### Notes
|
||||
|
||||
- The widget uses a single Open WebUI integration.
|
||||
- Conversations are stored in Open WebUI itself, under the account that owns the API key, so they also appear in the Open WebUI app.
|
||||
- Knowledge bases and attached web pages stay active for the whole conversation until you remove them; images apply only to the message you attach them to.
|
||||
- Conversation text is saved to Open WebUI history, but attached image data is not persisted.
|
||||
@@ -0,0 +1,25 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconRobot } from "@tabler/icons-react";
|
||||
|
||||
export const openWebUiWidget: WidgetDefinition = {
|
||||
icon: IconRobot,
|
||||
name: "AI Chat",
|
||||
description: "Chat with AI models through Open WebUI, with model picking and history.",
|
||||
path: "../../widgets/open-webui",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "System prompt",
|
||||
description: "Optional instruction prepended to every conversation.",
|
||||
values: { type: "string" },
|
||||
defaultValue: "empty",
|
||||
},
|
||||
{
|
||||
name: "Show chat history",
|
||||
description: "Show a button to browse and reopen previous Open WebUI chats.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: 'PatchMon'
|
||||
description: 'Displays host patch statistics from PatchMon including update and security 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 { patchmonWidget } from '.';
|
||||
import { patchmonIntegration } from '@site/docs/integrations/patchmon';
|
||||
|
||||
|
||||
<WidgetHeader
|
||||
widget={patchmonWidget}
|
||||
categories={['Health Monitoring']}
|
||||
/>
|
||||
|
||||
This widget displays patch statistics from your PatchMon instance, including total hosts, hosts needing updates, and security update counts. Each stat is shown with a dedicated icon and severity-tinted card background for quick visual scanning.
|
||||
|
||||
PatchMon does not render a built-in widget title. To add a label on your board, open **Edit item → Advanced options → Title** — the same optional overlay badge used by other Homarr widgets.
|
||||
|
||||
### Authentication
|
||||
|
||||
:::note
|
||||
This widget requires a **signed-in Homarr session**. Anonymous visitors cannot load PatchMon data, even when the board is [public](/docs/management/boards#access-control). Placing this widget on a public board does not expose patch or security statistics to guests.
|
||||
|
||||
Stat visibility toggles in the widget settings control **display only**. Authenticated users with integration query access still receive the full API payload regardless of which stats are hidden in the UI.
|
||||
:::
|
||||
|
||||
### Compliance hero
|
||||
|
||||
When **Show compliance hero** is enabled (default), the widget displays a ring showing the percentage of hosts that are fully up to date. The ring color follows the same threshold rules as the "Up-to-Date Hosts" stat. When the widget is **2 or more columns wide**, the hero also shows the "Patch Compliance" label and exact host count (e.g. "27 / 42") beside the ring.
|
||||
|
||||
In **1-column-wide** layouts (such as 1×1), the compliance hero switches to a ring-only view so useful compliance data remains visible in tight spaces. If the compliance hero is disabled, compact widgets fall back to the next selected content: OS distribution when enabled, then the first enabled stat.
|
||||
|
||||
### Threshold colors
|
||||
|
||||
When **Enable threshold colors** is on (default), stat values and card backgrounds are colored for at-a-glance severity:
|
||||
|
||||
| Stat | Green | Yellow | Red |
|
||||
|------|-------|--------|-----|
|
||||
| Needs Updates | 0 | 1–4 | 5+ |
|
||||
| Security Updates | 0 | 1–9 | 10+ |
|
||||
| Hosts with Security Updates | 0 | — | 1+ |
|
||||
| Up-to-Date Hosts | 100% of total | 90–99% | below 90% |
|
||||
| Outdated Packages | 0 | 1–49 | 50+ |
|
||||
|
||||
Enable **Advanced thresholds** to override warning and critical values per stat, using either absolute counts or percentages of total hosts.
|
||||
|
||||
### OS distribution
|
||||
|
||||
When **Show OS distribution** is enabled, the widget shows a breakdown of monitored hosts by operating system. Two display modes are available:
|
||||
|
||||
- **Horizontal bars** (default) — a scrollable list of colored progress bars showing each OS's share
|
||||
- **Donut chart** — a compact donut chart visualization that scales down in narrow layouts
|
||||
|
||||
The OS distribution limit defaults to **All**. A widget size of **2×3** or larger is recommended when using this section alongside the default stats, but compact 1×1 and 2×1 layouts can still show multiple OS rows when OS distribution is the primary selected content. Wider donut layouts can also show a compact legend beside the chart.
|
||||
|
||||
### Responsive sizing
|
||||
|
||||
The widget adapts its layout based on the available space:
|
||||
|
||||
| Widget size | Layout |
|
||||
|-------------|--------|
|
||||
| **1×1** | Primary selected content: compliance ring (no label), compact OS distribution, or up to two enabled stats |
|
||||
| **2×1** / **1×2** | Compact primary content; **2×1** shows compliance label and host ratio beside the ring; **1×2** uses ring-only hero |
|
||||
| **2×2** (default) | Compliance hero, stat grid with icons, and tinted cards |
|
||||
| **2×3** / **3×2** | Hero, stats, OS distribution, and "last updated" footer |
|
||||
| **3×3** or larger | Full layout with up to 4-column stat grid, donut chart, and optional OS legend |
|
||||
|
||||
### Last updated
|
||||
|
||||
When the widget has enough space, a "last updated" timestamp appears at the bottom showing how recently PatchMon refreshed its data. Narrow layouts use a shortened timestamp such as `5m ago`; wider short layouts can show the full translated text.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: patchmonIntegration,
|
||||
note: "Displays host patch and security update statistics"
|
||||
}]} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
<WidgetConfig configuration={patchmonWidget.configuration} />
|
||||
@@ -0,0 +1,200 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconShieldCheck } from "@tabler/icons-react";
|
||||
|
||||
export const patchmonWidget: WidgetDefinition = {
|
||||
icon: IconShieldCheck,
|
||||
name: "PatchMon",
|
||||
description:
|
||||
"Displays host patch statistics including total hosts, hosts needing updates, and security update counts.",
|
||||
path: "../../widgets/patchmon",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show compliance hero",
|
||||
description: "Display a compliance ring showing the percentage of up-to-date hosts",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show total hosts",
|
||||
description: "Displays the total number of monitored hosts",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show hosts needing updates",
|
||||
description: "Displays hosts with at least one outdated package",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show security updates",
|
||||
description: "Displays the total number of security updates available",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Show up-to-date hosts",
|
||||
description: "Displays hosts with zero outdated packages",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Show hosts with security updates",
|
||||
description: "Displays hosts requiring security patches",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Show recent updates (24h)",
|
||||
description: "Displays the number of updates applied in the last 24 hours",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Show outdated packages",
|
||||
description: "Displays the total number of outdated packages across all hosts",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Show repositories",
|
||||
description: "Displays the number of active repositories being monitored",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Enable threshold colors",
|
||||
description: "Colors stat values green, yellow, or red based on built-in severity presets",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
{
|
||||
name: "Advanced thresholds",
|
||||
description: "Customize warning and critical thresholds per stat (absolute count or % of total hosts)",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "Needs updates threshold mode",
|
||||
description:
|
||||
"Compare the needs-updates count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
|
||||
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
|
||||
defaultValue: "Absolute count",
|
||||
},
|
||||
{
|
||||
name: "Needs updates warning threshold",
|
||||
description: "Enter yellow when the value reaches this threshold",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "1",
|
||||
},
|
||||
{
|
||||
name: "Needs updates critical threshold",
|
||||
description: "Enter red when the value reaches this threshold",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "5",
|
||||
},
|
||||
{
|
||||
name: "Security updates threshold mode",
|
||||
description:
|
||||
"Compare the security-updates count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
|
||||
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
|
||||
defaultValue: "Absolute count",
|
||||
},
|
||||
{
|
||||
name: "Security updates warning threshold",
|
||||
description: "Enter yellow when the value reaches this threshold",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "1",
|
||||
},
|
||||
{
|
||||
name: "Security updates critical threshold",
|
||||
description: "Enter red when the value reaches this threshold",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "10",
|
||||
},
|
||||
{
|
||||
name: "Hosts with security updates threshold mode",
|
||||
description:
|
||||
"Compare the host count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
|
||||
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
|
||||
defaultValue: "Absolute count",
|
||||
},
|
||||
{
|
||||
name: "Hosts with security updates warning threshold",
|
||||
description: "Enter yellow when the value reaches this threshold",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "1",
|
||||
},
|
||||
{
|
||||
name: "Hosts with security updates critical threshold",
|
||||
description: "Enter red when the value reaches this threshold",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "1",
|
||||
},
|
||||
{
|
||||
name: "Up-to-date hosts threshold mode",
|
||||
description:
|
||||
"Compare the up-to-date host count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
|
||||
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
|
||||
defaultValue: "Percent of total hosts",
|
||||
},
|
||||
{
|
||||
name: "Up-to-date hosts warning threshold",
|
||||
description: "Enter yellow when the value is at or above this threshold but below full coverage",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "90",
|
||||
},
|
||||
{
|
||||
name: "Up-to-date hosts critical threshold",
|
||||
description: "Enter red when the value falls below this threshold",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "90",
|
||||
},
|
||||
{
|
||||
name: "Outdated packages threshold mode",
|
||||
description:
|
||||
"Compare the outdated-packages count as an absolute value or percentage of total hosts. Shown when threshold colors, advanced thresholds, and Show outdated packages are enabled.",
|
||||
values: { type: "select", options: ["Absolute count", "Percent of total hosts"] },
|
||||
defaultValue: "Absolute count",
|
||||
},
|
||||
{
|
||||
name: "Outdated packages warning threshold",
|
||||
description: "Enter yellow when the value reaches this threshold",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "1",
|
||||
},
|
||||
{
|
||||
name: "Outdated packages critical threshold",
|
||||
description: "Enter red when the value reaches this threshold",
|
||||
values: "Integer ≥ 0",
|
||||
defaultValue: "50",
|
||||
},
|
||||
{
|
||||
name: "Show OS distribution",
|
||||
description: "Displays a horizontal bar breakdown of monitored hosts by operating system",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "no",
|
||||
},
|
||||
{
|
||||
name: "OS display mode",
|
||||
description:
|
||||
"Choose how to display the operating system distribution. Shown when Show OS distribution is enabled.",
|
||||
values: { type: "select", options: ["Horizontal bars", "Donut chart"] },
|
||||
defaultValue: "Horizontal bars",
|
||||
},
|
||||
{
|
||||
name: "OS distribution limit",
|
||||
description: "Maximum number of operating systems to display in the distribution section",
|
||||
values: { type: "select", options: ["Top 3", "Top 5", "Top 10", "All"] },
|
||||
defaultValue: "All",
|
||||
},
|
||||
{
|
||||
name: "Show OS version",
|
||||
description: "Append the OS version to each distribution label when available",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
title: "System disks"
|
||||
description: "Disk usage of your system"
|
||||
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 { systemDisksWidget } from ".";
|
||||
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
|
||||
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
|
||||
import { truenasIntegration } from "@site/docs/integrations/truenas";
|
||||
import { unraidIntegration } from "@site/docs/integrations/unraid";
|
||||
import { synologyIntegration } from "@site/docs/integrations/synology";
|
||||
|
||||
<WidgetHeader widget={systemDisksWidget} categories={["Storage", "Disks"]} />
|
||||
|
||||
This widget displays filesystem and SMART disk usage for supported server and NAS integrations.
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: dashDotIntegration,
|
||||
},
|
||||
{
|
||||
integration: openMediaVaultIntegration,
|
||||
},
|
||||
{
|
||||
integration: truenasIntegration,
|
||||
},
|
||||
{
|
||||
integration: unraidIntegration,
|
||||
},
|
||||
{
|
||||
integration: synologyIntegration,
|
||||
note: "Supports storage volume filtering when only Synology integrations are selected",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={systemDisksWidget.configuration} />
|
||||
@@ -0,0 +1,41 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconServer2 } from "@tabler/icons-react";
|
||||
|
||||
export const systemDisksWidget: WidgetDefinition = {
|
||||
icon: IconServer2,
|
||||
name: "System disks",
|
||||
description: "Disk usage of your system",
|
||||
path: "../../widgets/system-disks",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show temperature if available",
|
||||
description: "Display disk temperature when the integration provides it",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "Yes",
|
||||
},
|
||||
{
|
||||
name: "Display mode",
|
||||
description: "How disk usage values are shown",
|
||||
values: {
|
||||
type: "select",
|
||||
options: ["Percentage (e.g., 76%)", "Absolute values (e.g., 800GB / 1TB)", "Free space percentage (e.g., 24% free)"],
|
||||
},
|
||||
defaultValue: "Percentage (e.g., 76%)",
|
||||
},
|
||||
{
|
||||
name: "Show background usage bar",
|
||||
description: "Fill the area below the usage value with a background bar",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "Yes",
|
||||
},
|
||||
{
|
||||
name: "Visible storage volumes",
|
||||
description:
|
||||
"Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.",
|
||||
values: "List of storage volumes",
|
||||
defaultValue: "All volumes",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,57 +1,69 @@
|
||||
---
|
||||
title: 'System Resources'
|
||||
description: 'Displays CPU, RAM and network of your host in real time'
|
||||
title: "System Resources"
|
||||
description: "Displays CPU, RAM and network of your host in real time"
|
||||
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 { systemResourcesWidget } from '.';
|
||||
import { dashDotIntegration } from '@site/docs/integrations/dash-dot';
|
||||
import { glancesIntegration } from '@site/docs/integrations/glances';
|
||||
import { openMediaVaultIntegration } from '@site/docs/integrations/open-media-vault';
|
||||
import { proxmoxIntegration } from '@site/docs/integrations/proxmox';
|
||||
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 { systemResourcesWidget } from ".";
|
||||
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
|
||||
import { glancesIntegration } from "@site/docs/integrations/glances";
|
||||
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
|
||||
import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
|
||||
|
||||
|
||||
<WidgetHeader
|
||||
widget={systemResourcesWidget}
|
||||
categories={['CPU', 'RAM', 'Memory', 'Network', 'Interface']}
|
||||
/>
|
||||
<WidgetHeader widget={systemResourcesWidget} categories={["CPU", "RAM", "Memory", "Network", "Interface"]} />
|
||||
|
||||
Displays CPU, RAM and network of your host in real time
|
||||
|
||||
### Screenshots
|
||||
|
||||
import network from './img/network.png';
|
||||
import cpu from './img/cpu.png';
|
||||
import memory from './img/memory.png';
|
||||
import cpuAndMemory from './img/cpu-and-memory.png';
|
||||
import { truenasIntegration } from '@site/docs/integrations/truenas';
|
||||
import network from "./img/network.png";
|
||||
import cpu from "./img/cpu.png";
|
||||
import memory from "./img/memory.png";
|
||||
import cpuAndMemory from "./img/cpu-and-memory.png";
|
||||
import { truenasIntegration } from "@site/docs/integrations/truenas";
|
||||
import { synologyIntegration } from "@site/docs/integrations/synology";
|
||||
|
||||
<div className='flex gap-1'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<img src={network} alt="system-resources-widget network-only icon-label" width={256 + 4} height={128} />
|
||||
<div className='flex gap-1'>
|
||||
<div className="flex gap-1">
|
||||
<img src={cpu} alt="system-resources-widget cpu-only no-label item-title" width={128} height={128} />
|
||||
<img src={memory} alt="system-resources-widget memory-only text-label" width={128} height={128} />
|
||||
</div>
|
||||
</div>
|
||||
<img src={cpuAndMemory} alt="system-resources-widget cpu-and-memory icon-text-label" width={256 + 4} height={256 + 4} />
|
||||
<img
|
||||
src={cpuAndMemory}
|
||||
alt="system-resources-widget cpu-and-memory icon-text-label"
|
||||
width={256 + 4}
|
||||
height={256 + 4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: dashDotIntegration
|
||||
}, {
|
||||
integration: openMediaVaultIntegration
|
||||
}, {
|
||||
integration: truenasIntegration
|
||||
}, {
|
||||
integration: glancesIntegration
|
||||
}]} />
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{
|
||||
integration: dashDotIntegration,
|
||||
},
|
||||
{
|
||||
integration: openMediaVaultIntegration,
|
||||
},
|
||||
{
|
||||
integration: truenasIntegration,
|
||||
},
|
||||
{
|
||||
integration: synologyIntegration,
|
||||
},
|
||||
{
|
||||
integration: glancesIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the widget
|
||||
|
||||
@@ -59,4 +71,4 @@ import { truenasIntegration } from '@site/docs/integrations/truenas';
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={systemResourcesWidget.configuration} />
|
||||
<WidgetConfig configuration={systemResourcesWidget.configuration} />
|
||||
@@ -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} />
|
||||
@@ -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,18 +1,25 @@
|
||||
// Algolia DocSearch crawler for homarr.dev
|
||||
// Crawler ID: cd77a285-2756-4557-bf21-ee703748df15
|
||||
// DocSearch-managed crawlers: paste this in the Algolia crawler editor or send to DocSearch support.
|
||||
// Template: https://docsearch.algolia.com/docs/templates/#docusaurus-v3-template
|
||||
// Markdown indexing: https://www.algolia.com/doc/guides/algolia-ai/askai/guides/markdown-indexing
|
||||
// Paste this in the Algolia crawler editor.
|
||||
|
||||
new Crawler({
|
||||
appId: "N69WSPZTID",
|
||||
apiKey: "YOUR_CRAWLER_ADMIN_API_KEY",
|
||||
rateLimit: 8,
|
||||
maxDepth: 10,
|
||||
indexPrefix: "",
|
||||
rateLimit: 32,
|
||||
maxDepth: 4,
|
||||
startUrls: ["https://homarr.dev/"],
|
||||
sitemaps: ["https://homarr.dev/sitemap.xml"],
|
||||
ignoreCanonicalTo: true,
|
||||
discoveryPatterns: ["https://homarr.dev/**"],
|
||||
exclusionPatterns: [
|
||||
"https://homarr.dev/docs/tags/**",
|
||||
"https://homarr.dev/blog/tags/**",
|
||||
"https://homarr.dev/blog/authors/**",
|
||||
"https://homarr.dev/blog/archive/**",
|
||||
"https://homarr.dev/search/**",
|
||||
"https://homarr.dev/docs/category/**",
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
indexName: "Docusaurus",
|
||||
@@ -105,7 +112,6 @@ new Crawler({
|
||||
advancedSyntax: true,
|
||||
attributeCriteriaComputedByMinProximity: true,
|
||||
removeWordsIfNoResults: "allOptional",
|
||||
separatorsToIndex: "_",
|
||||
},
|
||||
markdown: {
|
||||
attributesForFaceting: ["lang", "language", "version", "docusaurus_tag"],
|
||||
@@ -119,4 +125,10 @@ new Crawler({
|
||||
advancedSyntax: false,
|
||||
},
|
||||
},
|
||||
apiKey: "YOUR_CRAWLER_ADMIN_API_KEY",
|
||||
schedule: "on saturday",
|
||||
renderJavaScript: false,
|
||||
maxUrls: null,
|
||||
cache: { enabled: false },
|
||||
safetyChecks: { beforeIndexPublishing: { maxLostRecordsPercentage: 99 } },
|
||||
});
|
||||
@@ -8,7 +8,7 @@ const config: Config = {
|
||||
tagline: "A simple yet powerful dashboard for your server.",
|
||||
url: "https://homarr.dev",
|
||||
baseUrl: "/",
|
||||
trailingSlash: true,
|
||||
trailingSlash: undefined,
|
||||
favicon: "img/logo.png",
|
||||
organizationName: "homarr-labs",
|
||||
projectName: "homarr",
|
||||
@@ -79,8 +79,27 @@ const config: Config = {
|
||||
sitemap: {
|
||||
changefreq: "weekly",
|
||||
priority: 0.5,
|
||||
ignorePatterns: ["/tags/**"],
|
||||
ignorePatterns: ["/tags/**", "/docs/category/**"],
|
||||
filename: "sitemap.xml",
|
||||
createSitemapItems: async ({ routes, siteConfig, defaultCreateSitemapItems }) => {
|
||||
const items = await defaultCreateSitemapItems({ routes, siteConfig });
|
||||
return items.map((item) => {
|
||||
const path = new URL(item.url).pathname;
|
||||
if (path === "/" || path === "") {
|
||||
return { ...item, priority: 1.0, changefreq: "weekly" };
|
||||
}
|
||||
if (path.startsWith("/docs/")) {
|
||||
return { ...item, priority: 0.7, changefreq: "monthly" };
|
||||
}
|
||||
if (path.startsWith("/blog/")) {
|
||||
return { ...item, priority: 0.3, changefreq: "never" };
|
||||
}
|
||||
if (path.startsWith("/about-us")) {
|
||||
return { ...item, priority: 0.4, changefreq: "yearly" };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
},
|
||||
},
|
||||
} satisfies Preset.Options,
|
||||
],
|
||||
|
||||
@@ -47,6 +47,16 @@ const secretKinds = {
|
||||
description: "The Secret part of the API Key for authentication.",
|
||||
icon: IconPassword,
|
||||
},
|
||||
patchmonApiKey: {
|
||||
name: "API Key",
|
||||
description: "The API key from PatchMon's GetHomepage integration.",
|
||||
icon: IconKey,
|
||||
},
|
||||
patchmonApiSecret: {
|
||||
name: "API Secret",
|
||||
description: "The API secret from PatchMon's GetHomepage integration.",
|
||||
icon: IconPassword,
|
||||
},
|
||||
githubAppId: { name: "App ID", description: "The ID of the GitHub App", icon: IconCode },
|
||||
githubInstallationId: {
|
||||
name: "Installation ID",
|
||||
|
||||
+1
-1
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const OpenWebUiChat = dynamic(() => import("~/components/open-webui-chat").then((mod) => mod.OpenWebUiChat), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export const OpenWebUiChatProvider = () => {
|
||||
return <OpenWebUiChat />;
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createWSClient,
|
||||
httpBatchStreamLink,
|
||||
httpLink,
|
||||
httpSubscriptionLink,
|
||||
isNonJsonSerializable,
|
||||
loggerLink,
|
||||
splitLink,
|
||||
@@ -101,9 +102,17 @@ export function TRPCReactProvider(props: PropsWithChildren) {
|
||||
}),
|
||||
splitLink({
|
||||
condition: ({ type }) => type === "subscription",
|
||||
true: wsLink<AppRouter>({
|
||||
client: wsClient,
|
||||
transformer: superjson,
|
||||
true: splitLink({
|
||||
condition: ({ path }) => path === "widget.beszel.subscribeSystemStats",
|
||||
true: httpSubscriptionLink({
|
||||
url: getTrpcUrl(),
|
||||
transformer: superjson,
|
||||
eventSourceOptions: { withCredentials: true },
|
||||
}),
|
||||
false: wsLink<AppRouter>({
|
||||
client: wsClient,
|
||||
transformer: superjson,
|
||||
}),
|
||||
}),
|
||||
false: splitLink({
|
||||
condition: ({ input }) => isNonJsonSerializable(input),
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getI18n } from "@homarr/translation/server";
|
||||
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
|
||||
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { env } from "~/env";
|
||||
import { createBoardLayout } from "../_layout-creator";
|
||||
import type { Board, Item } from "../_types";
|
||||
import { DynamicClientBoard } from "./_dynamic-client";
|
||||
@@ -40,7 +41,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
}: Props<TParams>) => {
|
||||
return {
|
||||
layout: createBoardLayout({
|
||||
headerActions: <BoardContentHeaderActions />,
|
||||
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
|
||||
getInitialBoardAsync: getInitialBoard,
|
||||
withTour: true,
|
||||
}),
|
||||
@@ -79,14 +80,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> => {
|
||||
@@ -123,9 +126,9 @@ async function QueryCacheHydration({ userId, boardId }: { userId: string; boardI
|
||||
const serialized = await getQueryCacheAsync(userId, boardId);
|
||||
if (!serialized) return null;
|
||||
|
||||
const persisted = superjson.parse<PersistedClient>(serialized);
|
||||
if (persisted.buster !== queryCacheBuster) return null;
|
||||
if (!persisted?.clientState?.queries?.length) return null;
|
||||
const persisted = superjson.parse<PersistedClient | undefined>(serialized);
|
||||
if (!persisted || persisted.buster !== queryCacheBuster) return null;
|
||||
if (!persisted.clientState?.queries?.length) return null;
|
||||
|
||||
return <HydrationBoundary state={persisted.clientState} />;
|
||||
} catch (error) {
|
||||
|
||||
@@ -41,13 +41,12 @@ import { useDynamicSectionActions } from "~/components/board/sections/dynamic/dy
|
||||
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
|
||||
export const BoardContentHeaderActions = () => {
|
||||
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
|
||||
|
||||
if (!hasChangeAccess || isLoading) {
|
||||
if (!hasChangeAccess) {
|
||||
return <SelectBoardsMenu />;
|
||||
}
|
||||
|
||||
@@ -55,9 +54,9 @@ export const BoardContentHeaderActions = () => {
|
||||
<>
|
||||
{isEditMode && <AddMenu />}
|
||||
|
||||
<EditModeMenu demoMode={demoMode ?? false} />
|
||||
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||
|
||||
{!demoMode && (
|
||||
{!demoReadOnly && (
|
||||
<OnboardingTour.Target id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
<IconSettings stroke={1.5} />
|
||||
@@ -158,7 +157,7 @@ const AddMenu = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
||||
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
|
||||
const [isEditMode, { open, close }] = useEditMode();
|
||||
const board = useRequiredBoard();
|
||||
const utils = clientApi.useUtils();
|
||||
@@ -188,11 +187,11 @@ const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (isEditMode) {
|
||||
if (demoMode) return discardDemoChanges();
|
||||
if (demoReadOnly) return discardDemoChanges();
|
||||
return saveBoard(board);
|
||||
}
|
||||
open();
|
||||
}, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
|
||||
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]);
|
||||
|
||||
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
|
||||
usePreventLeaveWithDirty(isEditMode);
|
||||
|
||||
@@ -32,6 +32,7 @@ import { SearchEngineOptimization } from "~/components/layout/search-engine-opti
|
||||
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
import { DayJsLoader } from "./_client-providers/dayjs-loader";
|
||||
import { OpenWebUiChatProvider } from "./_client-providers/open-webui-chat";
|
||||
import { JotaiProvider } from "./_client-providers/jotai";
|
||||
import { CustomMantineProvider } from "./_client-providers/mantine";
|
||||
import { AuthProvider } from "./_client-providers/session";
|
||||
@@ -142,11 +143,12 @@ 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" />
|
||||
<ServiceWorkerRegistration />
|
||||
<OpenWebUiChatProvider />
|
||||
{props.children}
|
||||
</StackedProvider>
|
||||
</body>
|
||||
|
||||
+2
@@ -26,6 +26,8 @@ export const integrationSecretIcons = {
|
||||
url: IconLink,
|
||||
opnsenseApiKey: IconKey,
|
||||
opnsenseApiSecret: IconPassword,
|
||||
patchmonApiKey: IconKey,
|
||||
patchmonApiSecret: IconPassword,
|
||||
githubAppId: IconCode,
|
||||
githubInstallationId: IconPlug,
|
||||
privateKey: IconKey,
|
||||
|
||||
+2
-1
@@ -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,10 +2,10 @@
|
||||
|
||||
import type { MutableRefObject, ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Group, Menu, Switch, Text } from "@mantine/core";
|
||||
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
|
||||
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
@@ -49,9 +49,13 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
const { gridstack } = useSectionContext().refs;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]);
|
||||
const widgetQueryKey = useMemo(
|
||||
() => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]),
|
||||
[currentDefinition, item.kind],
|
||||
);
|
||||
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
|
||||
const handleRefetch = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: widgetQueryKey });
|
||||
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
|
||||
}, [queryClient, widgetQueryKey]);
|
||||
|
||||
const options = useMemo(
|
||||
@@ -223,11 +227,15 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
|
||||
<>
|
||||
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
|
||||
<Menu.Item closeMenuOnClick leftSection={<IconRefresh size={16} />} onClick={handleRefetch}>
|
||||
<Menu.Item
|
||||
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
|
||||
onClick={handleRefetch}
|
||||
disabled={isWidgetFetching}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
{tMenu("refresh")}
|
||||
<Text size="xs" c="dimmed">
|
||||
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} />
|
||||
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
|
||||
</Text>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
@@ -265,13 +273,24 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
||||
);
|
||||
};
|
||||
|
||||
const WidgetCacheAge = ({ queryClient, queryKey }: { queryClient: QueryClient; queryKey: unknown[] }) => {
|
||||
const WidgetCacheAge = ({
|
||||
queryClient,
|
||||
queryKey,
|
||||
isFetching,
|
||||
}: {
|
||||
queryClient: QueryClient;
|
||||
queryKey: QueryKey;
|
||||
isFetching: boolean;
|
||||
}) => {
|
||||
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((n) => n + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
if (isFetching) return <Loader size={12} />;
|
||||
|
||||
const timestamps = queryClient
|
||||
.getQueryCache()
|
||||
.findAll({ queryKey })
|
||||
@@ -279,8 +298,8 @@ const WidgetCacheAge = ({ queryClient, queryKey }: { queryClient: QueryClient; q
|
||||
.filter(Boolean);
|
||||
if (timestamps.length === 0) return null;
|
||||
|
||||
const oldest = Math.min(...timestamps);
|
||||
const seconds = Math.floor((Date.now() - oldest) / 1000);
|
||||
const latest = Math.max(...timestamps);
|
||||
const seconds = Math.floor((Date.now() - latest) / 1000);
|
||||
if (seconds < 5) return "just now";
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
return `${Math.floor(seconds / 60)}m ago`;
|
||||
|
||||
@@ -105,7 +105,7 @@ export const ManageTourProvider = ({ children, isAdmin }: ManageTourProviderProp
|
||||
content: (
|
||||
<TourStepContent
|
||||
description={t("welcome.description")}
|
||||
documentationHref={createDocumentationLink("/docs/category/management")}
|
||||
documentationHref={createDocumentationLink("/docs/management/boards")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Box, Drawer } from "@mantine/core";
|
||||
import { useHotkeys, useMediaQuery } from "@mantine/hooks";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { openWebUiChatEvent } from "@homarr/spotlight";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Chat } from "@homarr/widgets/_open-webui/chat";
|
||||
|
||||
/**
|
||||
* App-wide launcher for the Open WebUI chat. Renders a right-side panel that can
|
||||
* be opened from the header user menu, the {@link hotkeys.toggleAiChat} keybind,
|
||||
* or the spotlight "ask AI" mode (via the {@link openWebUiChatEvent} window
|
||||
* event). Renders nothing unless the signed-in user owns an Open WebUI
|
||||
* integration.
|
||||
*/
|
||||
export const OpenWebUiChat = () => {
|
||||
const t = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [initialQuery, setInitialQuery] = useState<string | undefined>(undefined);
|
||||
// Bumped on every open so the chat remounts and a fresh initial query is sent.
|
||||
const [openNonce, setOpenNonce] = useState(0);
|
||||
|
||||
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: Boolean(session?.user?.id),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const myOpenWebUiIntegration = useMemo(() => {
|
||||
if (!session?.user?.id) return null;
|
||||
return integrations.find(
|
||||
(integration) => integration.kind === "openWebUi" && integration.creatorId === session.user.id,
|
||||
);
|
||||
}, [integrations, session?.user?.id]);
|
||||
|
||||
const open = useCallback((query?: string) => {
|
||||
setInitialQuery(query?.trim() ? query.trim() : undefined);
|
||||
setOpenNonce((nonce) => nonce + 1);
|
||||
setOpened(true);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setOpened((previous) => {
|
||||
if (previous) return false;
|
||||
setInitialQuery(undefined);
|
||||
setOpenNonce((nonce) => nonce + 1);
|
||||
return true;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Open (with an optional query) when the spotlight "ask AI" mode dispatches.
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
if (!myOpenWebUiIntegration) return;
|
||||
open((event as CustomEvent<{ query?: string }>).detail?.query);
|
||||
};
|
||||
window.addEventListener(openWebUiChatEvent, handler);
|
||||
return () => window.removeEventListener(openWebUiChatEvent, handler);
|
||||
}, [open, myOpenWebUiIntegration]);
|
||||
|
||||
useHotkeys([[hotkeys.toggleAiChat, () => myOpenWebUiIntegration && toggle()]]);
|
||||
|
||||
if (!myOpenWebUiIntegration) return null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
position="right"
|
||||
size={isMobile ? "100%" : 480}
|
||||
title={t("widget.openWebUi.name")}
|
||||
keepMounted={false}
|
||||
styles={{
|
||||
body: {
|
||||
height: "calc(100dvh - 60px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
|
||||
<Chat
|
||||
key={openNonce}
|
||||
options={{ systemPrompt: "", showHistory: true }}
|
||||
integrationIds={[myOpenWebUiIntegration.id]}
|
||||
itemId={undefined}
|
||||
boardId={undefined}
|
||||
isEditMode={false}
|
||||
setOptions={() => {
|
||||
// No-op: options are fixed for the launcher chat.
|
||||
}}
|
||||
width={480}
|
||||
height={600}
|
||||
initialQuery={initialQuery}
|
||||
/>
|
||||
</Box>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
@@ -11,14 +11,17 @@ import {
|
||||
IconHome,
|
||||
IconLogin,
|
||||
IconLogout,
|
||||
IconRobot,
|
||||
IconSettings,
|
||||
IconTool,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { signOut, useSession } from "@homarr/auth/client";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { openOpenWebUiChat } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { Link } from "@homarr/ui";
|
||||
|
||||
@@ -46,6 +49,15 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
|
||||
const { openModal } = useModalAction(LogoutModal);
|
||||
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
||||
|
||||
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: Boolean(session.data?.user?.id),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const hasOpenWebUiIntegration = integrations.some(
|
||||
(integration) => integration.kind === "openWebUi" && integration.creatorId === session.data?.user?.id,
|
||||
);
|
||||
|
||||
const handleSignout = useCallback(async () => {
|
||||
await signOut({
|
||||
redirect: false,
|
||||
@@ -96,6 +108,11 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnab
|
||||
{t("docker")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{hasOpenWebUiIntegration && (
|
||||
<Menu.Item leftSection={<IconRobot size="1rem" />} onClick={() => openOpenWebUiChat()}>
|
||||
{t("aiChat")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
|
||||
@@ -4,6 +4,7 @@ export const env = createEnv({
|
||||
server: {
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
|
||||
DEMO_MODE: createBooleanSchema(false),
|
||||
DEMO_READ_ONLY: createBooleanSchema(true),
|
||||
},
|
||||
experimental__runtimeEnv: process.env,
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
+50
-22
@@ -95,20 +95,27 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/bazarr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/bazarr.svg" alt="Bazarr" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Bazarr</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/beszel" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg" alt="Beszel" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Beszel</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/coolify" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg" alt="Coolify" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Coolify</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/dash-dot" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png" alt="Dash." width="90" height="90" />
|
||||
<br/>
|
||||
@@ -149,15 +156,15 @@
|
||||
<br/>
|
||||
<p align="center">Gotify</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/home-assistant" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg" alt="Home Assistant" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Home<br/>Assistant</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/ical" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ical.svg" alt="iCal" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -198,15 +205,15 @@
|
||||
<br/>
|
||||
<p align="center">Navidrome</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/nextcloud" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg" alt="Nextcloud" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Nextcloud</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/ntfy" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg" alt="ntfy" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -247,6 +254,13 @@
|
||||
<br/>
|
||||
<p align="center">Paperless-ngx</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/patchmon" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/patchmon.svg" alt="PatchMon" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">PatchMon</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="null" target="_blank" rel="noreferrer noopener">
|
||||
@@ -254,8 +268,8 @@
|
||||
<br/>
|
||||
<p align="center">PeaNUT</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/pi-hole" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg" alt="Pi-hole" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -289,8 +303,8 @@
|
||||
<br/>
|
||||
<p align="center">qBittorrent</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/radarr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg" alt="Radarr" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -303,8 +317,8 @@
|
||||
<br/>
|
||||
<p align="center">Readarr</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -338,6 +352,13 @@
|
||||
<br/>
|
||||
<p align="center">Speedtest<br/>Tracker</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><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">
|
||||
@@ -352,8 +373,8 @@
|
||||
<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 +382,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/>
|
||||
@@ -373,8 +401,8 @@
|
||||
<br/>
|
||||
<p align="center">TrueNAS</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/umami" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/umami.svg" alt="Umami" width="90" height="90" />
|
||||
<br/>
|
||||
|
||||
+3
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.69.2",
|
||||
"version": "1.70.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -43,7 +43,6 @@
|
||||
"@semantic-release/release-notes-generator": "catalog:",
|
||||
"@testcontainers/redis": "catalog:",
|
||||
"@turbo/gen": "catalog:",
|
||||
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"@vitest/ui": "catalog:",
|
||||
@@ -63,9 +62,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:"
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ export const env = createEnv({
|
||||
server: {
|
||||
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
|
||||
DEMO_MODE: createBooleanSchema(false),
|
||||
DEMO_READ_ONLY: createBooleanSchema(true),
|
||||
},
|
||||
runtimeEnv: {
|
||||
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
|
||||
DEMO_MODE: process.env.DEMO_MODE,
|
||||
DEMO_READ_ONLY: process.env.DEMO_READ_ONLY,
|
||||
},
|
||||
});
|
||||
@@ -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";
|
||||
@@ -16,6 +18,7 @@ import { downloadsRouter } from "./router/widgets/downloads";
|
||||
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
|
||||
import { mediaRequestsRouter } from "./router/widgets/media-requests";
|
||||
import { mediaServerRouter } from "./router/widgets/media-server";
|
||||
import { patchmonRouter } from "./router/widgets/patchmon";
|
||||
import { smartHomeRouter } from "./router/widgets/smart-home";
|
||||
import { widgetSecretsRouter } from "./router/widgets/widget-secrets";
|
||||
|
||||
@@ -28,6 +31,8 @@ export const mcpRouter = createTRPCRouter({
|
||||
info: infoRouter,
|
||||
integration: integrationRouter,
|
||||
invite: inviteRouter,
|
||||
bazarr: bazarrRouter,
|
||||
serverSettings: serverSettingsRouter,
|
||||
beszel: beszelRouter,
|
||||
calendar: calendarRouter,
|
||||
dnsHole: dnsHoleRouter,
|
||||
@@ -35,6 +40,7 @@ export const mcpRouter = createTRPCRouter({
|
||||
healthMonitoring: healthMonitoringRouter,
|
||||
mediaRequests: mediaRequestsRouter,
|
||||
mediaServer: mediaServerRouter,
|
||||
patchmon: patchmonRouter,
|
||||
smartHome: smartHomeRouter,
|
||||
widgetSecrets: widgetSecretsRouter,
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -16,7 +16,7 @@ export const setActiveQueryCacheBoardId = (boardId: string | null) => {
|
||||
export const getActiveQueryCacheBoardId = () => activeQueryCacheBoardId;
|
||||
|
||||
const persistableQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]);
|
||||
const excludedWidgetPaths = new Set(["widget.app.ping"]);
|
||||
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
||||
|
||||
const getTrpcPathFromQueryKey = (queryKey: QueryKey) => {
|
||||
const first = queryKey[0];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import z from "zod/v4";
|
||||
|
||||
import packageJson from "../../../../package.json";
|
||||
import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc";
|
||||
import { createTRPCRouter, isDemoMode, isDemoReadOnly, publicProcedure, protectedProcedure } from "../trpc";
|
||||
|
||||
export const infoRouter = createTRPCRouter({
|
||||
getInfo: protectedProcedure
|
||||
@@ -20,4 +20,5 @@ export const infoRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
isDemoMode: publicProcedure.query(() => isDemoMode),
|
||||
isDemoReadOnly: publicProcedure.query(() => isDemoReadOnly),
|
||||
});
|
||||
@@ -99,6 +99,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
creatorId: integration.creatorId,
|
||||
permissions: {
|
||||
hasUseAccess:
|
||||
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
|
||||
@@ -344,23 +345,66 @@ export const integrationRouter = createTRPCRouter({
|
||||
const appId = await createAppIfNecessaryAsync(ctx.db, input.app);
|
||||
|
||||
const integrationId = createId();
|
||||
await ctx.db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
appId,
|
||||
});
|
||||
const userId = ctx.session.user.id;
|
||||
const secretValues = input.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: encryptSecret(secret.value),
|
||||
integrationId,
|
||||
}));
|
||||
|
||||
if (input.secrets.length >= 1) {
|
||||
await ctx.db.insert(integrationSecrets).values(
|
||||
input.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: encryptSecret(secret.value),
|
||||
integrationId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
// Create the integration, its creator permission and secrets atomically so
|
||||
// a failure part-way through can't leave a committed integration the
|
||||
// creator can neither use nor manage.
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
handleAsync: async (db, schema) => {
|
||||
await db.transaction(async (transaction) => {
|
||||
await transaction.insert(schema.integrations).values({
|
||||
id: integrationId,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
appId,
|
||||
creatorId: userId,
|
||||
});
|
||||
// Auto-grant the creator "full" permission so they can immediately
|
||||
// use and manage the integration they just created.
|
||||
await transaction.insert(schema.integrationUserPermissions).values({
|
||||
integrationId,
|
||||
userId,
|
||||
permission: "full",
|
||||
});
|
||||
if (secretValues.length >= 1) {
|
||||
await transaction.insert(schema.integrationSecrets).values(secretValues);
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync: (db) => {
|
||||
db.transaction((transaction) => {
|
||||
transaction
|
||||
.insert(integrations)
|
||||
.values({
|
||||
id: integrationId,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
appId,
|
||||
creatorId: userId,
|
||||
})
|
||||
.run();
|
||||
transaction
|
||||
.insert(integrationUserPermissions)
|
||||
.values({
|
||||
integrationId,
|
||||
userId,
|
||||
permission: "full",
|
||||
})
|
||||
.run();
|
||||
if (secretValues.length >= 1) {
|
||||
transaction.insert(integrationSecrets).values(secretValues).run();
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("Created integration", {
|
||||
id: integrationId,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, test, vi } from "vitest";
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { encryptSecret } from "@homarr/common/server";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
@@ -301,6 +302,10 @@ describe("create should create a new integration", () => {
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-create"]),
|
||||
});
|
||||
|
||||
// The creator must exist in the DB for the creatorId foreign key
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
|
||||
const input = {
|
||||
name: "Jellyfin",
|
||||
kind: "jellyfin" as const,
|
||||
@@ -317,10 +322,23 @@ describe("create should create a new integration", () => {
|
||||
|
||||
const dbIntegration = await db.query.integrations.findFirst();
|
||||
const dbSecret = await db.query.integrationSecrets.findFirst();
|
||||
const dbPermission = await db.query.integrationUserPermissions.findFirst({
|
||||
where: and(
|
||||
eq(integrationUserPermissions.integrationId, dbIntegration!.id),
|
||||
eq(integrationUserPermissions.userId, defaultUserId),
|
||||
),
|
||||
});
|
||||
expect(dbIntegration).toBeDefined();
|
||||
expect(dbIntegration!.name).toBe(input.name);
|
||||
expect(dbIntegration!.kind).toBe(input.kind);
|
||||
expect(dbIntegration!.url).toBe(input.url);
|
||||
expect(dbIntegration!.creatorId).toBe(defaultUserId);
|
||||
|
||||
// Creator should be auto-granted "full" permission
|
||||
expect(dbPermission).toBeDefined();
|
||||
expect(dbPermission!.integrationId).toBe(dbIntegration!.id);
|
||||
expect(dbPermission!.userId).toBe(defaultUserId);
|
||||
expect(dbPermission!.permission).toBe("full");
|
||||
|
||||
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
|
||||
expect(dbSecret).toBeDefined();
|
||||
@@ -336,6 +354,10 @@ describe("create should create a new integration", () => {
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-create"]),
|
||||
});
|
||||
|
||||
// The creator must exist in the DB for the creatorId foreign key
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
|
||||
const input = {
|
||||
name: "Jellyseerr",
|
||||
kind: "jellyseerr" as const,
|
||||
@@ -357,6 +379,7 @@ describe("create should create a new integration", () => {
|
||||
expect(dbIntegration!.name).toBe(input.name);
|
||||
expect(dbIntegration!.kind).toBe(input.kind);
|
||||
expect(dbIntegration!.url).toBe(input.url);
|
||||
expect(dbIntegration!.creatorId).toBe(defaultUserId);
|
||||
|
||||
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
|
||||
expect(dbSecret).toBeDefined();
|
||||
@@ -374,6 +397,10 @@ describe("create should create a new integration", () => {
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-create", "app-create"]),
|
||||
});
|
||||
|
||||
// The creator must exist in the DB for the creatorId foreign key
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
|
||||
const input = {
|
||||
name: "Jellyfin",
|
||||
kind: "jellyfin" as const,
|
||||
@@ -405,6 +432,7 @@ describe("create should create a new integration", () => {
|
||||
expect(dbIntegration!.name).toBe(input.name);
|
||||
expect(dbIntegration!.kind).toBe(input.kind);
|
||||
expect(dbIntegration!.url).toBe(input.url);
|
||||
expect(dbIntegration!.creatorId).toBe(defaultUserId);
|
||||
expect(dbIntegration!.app!.name).toBe(input.app.name);
|
||||
expect(dbIntegration!.app!.pingUrl).toBe(input.app.pingUrl);
|
||||
expect(dbIntegration!.app!.href).toBe(input.app.href);
|
||||
@@ -431,6 +459,10 @@ describe("create should create a new integration", () => {
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-create"]),
|
||||
});
|
||||
|
||||
// The creator must exist in the DB for the creatorId foreign key
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
|
||||
const input = {
|
||||
name: "Jellyfin",
|
||||
kind: "jellyfin" as const,
|
||||
@@ -455,6 +487,7 @@ describe("create should create a new integration", () => {
|
||||
expect(dbIntegration!.kind).toBe(input.kind);
|
||||
expect(dbIntegration!.url).toBe(input.url);
|
||||
expect(dbIntegration!.appId).toBe(appId);
|
||||
expect(dbIntegration!.creatorId).toBe(defaultUserId);
|
||||
|
||||
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
|
||||
expect(dbSecret).toBeDefined();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { boards, integrationItems, integrations, integrationUserPermissions, items, users } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { PatchMonStats } from "@homarr/integrations";
|
||||
|
||||
import { patchmonRouter } from "../../widgets/patchmon";
|
||||
|
||||
const samplePatchMonStats = {
|
||||
totalHosts: 42,
|
||||
hostsNeedingUpdates: 15,
|
||||
securityUpdates: 23,
|
||||
upToDateHosts: 27,
|
||||
hostsWithSecurityUpdates: 8,
|
||||
recentUpdates24h: 34,
|
||||
totalOutdatedPackages: 156,
|
||||
totalRepos: 12,
|
||||
lastUpdated: "2025-10-11T12:34:56.789Z",
|
||||
osDistribution: [],
|
||||
} satisfies PatchMonStats;
|
||||
|
||||
vi.mock("@homarr/request-handler/patchmon", () => ({
|
||||
patchmonStatsRequestHandler: {
|
||||
handler: () => ({
|
||||
getDataAsync: async () => ({
|
||||
data: samplePatchMonStats,
|
||||
timestamp: new Date(),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const createSession = (userId: string): Session => ({
|
||||
user: {
|
||||
id: userId,
|
||||
permissions: [],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const createCaller = (db: Database, session: Session | null) =>
|
||||
patchmonRouter.createCaller({
|
||||
db,
|
||||
deviceType: undefined,
|
||||
session,
|
||||
});
|
||||
|
||||
const createPatchMonIntegrationOnBoardAsync = async (
|
||||
db: Database,
|
||||
options: {
|
||||
isPublic: boolean;
|
||||
boardCreatorId: string;
|
||||
},
|
||||
) => {
|
||||
const integrationId = createId();
|
||||
const boardId = createId();
|
||||
const itemId = createId();
|
||||
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
kind: "patchmon",
|
||||
name: "PatchMon",
|
||||
url: "https://patchmon.example.com",
|
||||
});
|
||||
|
||||
await db.insert(boards).values({
|
||||
id: boardId,
|
||||
name: "patchmon-board",
|
||||
creatorId: options.boardCreatorId,
|
||||
isPublic: options.isPublic,
|
||||
});
|
||||
|
||||
await db.insert(items).values({
|
||||
id: itemId,
|
||||
kind: "patchmon",
|
||||
boardId,
|
||||
options: "{}",
|
||||
});
|
||||
|
||||
await db.insert(integrationItems).values({
|
||||
integrationId,
|
||||
itemId,
|
||||
});
|
||||
|
||||
return { integrationId, boardId, itemId };
|
||||
};
|
||||
|
||||
describe("patchmonRouter.getStats access control", () => {
|
||||
test("should throw UNAUTHORIZED for unauthenticated users even when integration is on a public board", async () => {
|
||||
const db = createDb();
|
||||
const otherUserId = createId();
|
||||
await db.insert(users).values({ id: otherUserId });
|
||||
const { integrationId } = await createPatchMonIntegrationOnBoardAsync(db, {
|
||||
isPublic: true,
|
||||
boardCreatorId: otherUserId,
|
||||
});
|
||||
const caller = createCaller(db, null);
|
||||
|
||||
await expect(caller.getStats({ integrationId })).rejects.toThrow(
|
||||
new TRPCError({ code: "UNAUTHORIZED" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw FORBIDDEN for authenticated users without board or integration access", async () => {
|
||||
const db = createDb();
|
||||
const boardOwnerId = createId();
|
||||
const sessionUserId = createId();
|
||||
await db.insert(users).values([{ id: boardOwnerId }, { id: sessionUserId }]);
|
||||
const { integrationId } = await createPatchMonIntegrationOnBoardAsync(db, {
|
||||
isPublic: false,
|
||||
boardCreatorId: boardOwnerId,
|
||||
});
|
||||
const caller = createCaller(db, createSession(sessionUserId));
|
||||
|
||||
await expect(caller.getStats({ integrationId })).rejects.toThrow(
|
||||
new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User does not have permission to query at least one of the specified integration",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("should return stats for authenticated users when integration is on a public board", async () => {
|
||||
const db = createDb();
|
||||
const boardOwnerId = createId();
|
||||
const sessionUserId = createId();
|
||||
await db.insert(users).values([{ id: boardOwnerId }, { id: sessionUserId }]);
|
||||
const { integrationId } = await createPatchMonIntegrationOnBoardAsync(db, {
|
||||
isPublic: true,
|
||||
boardCreatorId: boardOwnerId,
|
||||
});
|
||||
const caller = createCaller(db, createSession(sessionUserId));
|
||||
|
||||
const result = await caller.getStats({ integrationId });
|
||||
|
||||
expect(result).toStrictEqual(samplePatchMonStats);
|
||||
});
|
||||
|
||||
test("should return stats for authenticated users with integration use permission and no board placement", async () => {
|
||||
const db = createDb();
|
||||
const sessionUserId = createId();
|
||||
const integrationId = createId();
|
||||
await db.insert(users).values({ id: sessionUserId });
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
kind: "patchmon",
|
||||
name: "PatchMon",
|
||||
url: "https://patchmon.example.com",
|
||||
});
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId,
|
||||
userId: sessionUserId,
|
||||
permission: "use",
|
||||
});
|
||||
const caller = createCaller(db, createSession(sessionUserId));
|
||||
|
||||
const result = await caller.getStats({ integrationId });
|
||||
|
||||
expect(result).toStrictEqual(samplePatchMonStats);
|
||||
});
|
||||
|
||||
test("should throw NOT_FOUND when integration id does not exist", async () => {
|
||||
const db = createDb();
|
||||
const sessionUserId = createId();
|
||||
await db.insert(users).values({ id: sessionUserId });
|
||||
const caller = createCaller(db, createSession(sessionUserId));
|
||||
|
||||
await expect(caller.getStats({ integrationId: createId() })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw NOT_FOUND when integration is not a PatchMon integration", async () => {
|
||||
const db = createDb();
|
||||
const sessionUserId = createId();
|
||||
const integrationId = createId();
|
||||
await db.insert(users).values({ id: sessionUserId });
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
kind: "sonarr",
|
||||
name: "Sonarr",
|
||||
url: "https://sonarr.example.com",
|
||||
});
|
||||
await db.insert(integrationUserPermissions).values({
|
||||
integrationId,
|
||||
userId: sessionUserId,
|
||||
permission: "use",
|
||||
});
|
||||
const caller = createCaller(db, createSession(sessionUserId));
|
||||
|
||||
await expect(caller.getStats({ integrationId })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
@@ -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,110 @@ 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;
|
||||
let emittedEventCount = 0;
|
||||
|
||||
logger.debug("Beszel realtime subscription started", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationIds: ctx.integrations.map((integration) => integration.id),
|
||||
systemId: input.systemId,
|
||||
});
|
||||
|
||||
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) return;
|
||||
emittedEventCount += 1;
|
||||
if (emittedEventCount <= 2 || emittedEventCount % 60 === 0) {
|
||||
logger.debug("Forwarding Beszel realtime events", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
eventType: event.type,
|
||||
emittedEventCount,
|
||||
statsCount: Array.isArray(event.record.stats) ? event.record.stats.length : undefined,
|
||||
});
|
||||
}
|
||||
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) {
|
||||
logger.warn("Beszel realtime subscription failed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
emittedEventCount,
|
||||
error: errorMessage(error),
|
||||
});
|
||||
emit.error(
|
||||
error instanceof TRPCError
|
||||
? error
|
||||
: new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
logger.debug("Beszel realtime subscription completed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
emittedEventCount,
|
||||
});
|
||||
emit.complete();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
logger.debug("Beszel realtime subscription cancelled", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationIds: ctx.integrations.map((integration) => integration.id),
|
||||
systemId: input.systemId,
|
||||
emittedEventCount,
|
||||
});
|
||||
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();
|
||||
}),
|
||||
});
|
||||
@@ -1,21 +1,31 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { SynologyIntegration } from "@homarr/integrations";
|
||||
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const healthMonitoringIntegrationKinds = [
|
||||
"openmediavault",
|
||||
"dashDot",
|
||||
"truenas",
|
||||
"unraid",
|
||||
"glances",
|
||||
"synology",
|
||||
"mock",
|
||||
] as const;
|
||||
|
||||
export const healthMonitoringRouter = createTRPCRouter({
|
||||
getSystemHealthStatus: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
|
||||
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Synology/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(
|
||||
createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
|
||||
)
|
||||
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
|
||||
@@ -27,6 +37,23 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
};
|
||||
});
|
||||
}),
|
||||
listStorageVolumes: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"List storage volumes from a Synology DiskStation integration for widget configuration. REQUIRED: integrationId from integration_all (Synology integration only)",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", "synology"))
|
||||
.query(async ({ ctx }) => {
|
||||
const integrationInstance = await createIntegrationAsync(ctx.integration);
|
||||
if (!(integrationInstance instanceof SynologyIntegration)) {
|
||||
throw new Error("Expected Synology integration");
|
||||
}
|
||||
|
||||
return await integrationInstance.listStorageVolumesAsync();
|
||||
}),
|
||||
getClusterHealthStatus: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
|
||||
@@ -9,6 +9,8 @@ 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)),
|
||||
patchmon: lazy(() => import("./patchmon").then((mod) => mod.patchmonRouter)),
|
||||
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 +22,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 +41,8 @@ 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)),
|
||||
openWebUi: lazy(() => import("./open-webui").then((mod) => mod.openWebUiRouter)),
|
||||
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.getDataAsync();
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
...data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import {
|
||||
openWebUiChatRequestHandler,
|
||||
openWebUiChatsRequestHandler,
|
||||
openWebUiFilesRequestHandler,
|
||||
openWebUiKnowledgeFilesRequestHandler,
|
||||
openWebUiKnowledgeRequestHandler,
|
||||
openWebUiModelsRequestHandler,
|
||||
openWebUiNoteRequestHandler,
|
||||
openWebUiNotesRequestHandler,
|
||||
} from "@homarr/request-handler/open-webui";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "openWebUiRouter" });
|
||||
|
||||
/**
|
||||
* Open WebUI integrations hold a personal API key that grants access to a
|
||||
* single user's data (chat history, files, notes, knowledge bases). To prevent
|
||||
* other Homarr users from reading that data, every procedure that touches the
|
||||
* integration verifies that the caller is the integration's creator.
|
||||
*
|
||||
* The check is intentionally placed **after** the integration middleware (which
|
||||
* already validates the caller's permission level) so the error message does
|
||||
* not leak whether the integration exists.
|
||||
*/
|
||||
const throwIfNotCreator = (integration: { creatorId?: string | null }, userId: string | undefined) => {
|
||||
// Existing integrations that predate the creatorId column have a null
|
||||
// creatorId; we skip the check for backward compatibility.
|
||||
if (integration.creatorId && integration.creatorId !== userId) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Only the integration creator can access this data" });
|
||||
}
|
||||
};
|
||||
|
||||
// Hosts that should never be fetched server-side. processWeb is already
|
||||
// creator-gated, but we still refuse loopback and the cloud metadata endpoint so
|
||||
// a stray URL can't turn Open WebUI's fetcher into an SSRF vector. Private LAN
|
||||
// ranges stay allowed on purpose (self-hosters routinely ingest internal wikis).
|
||||
const isBlockedWebHost = (hostname: string): boolean => {
|
||||
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
||||
return (
|
||||
host === "localhost" ||
|
||||
host.endsWith(".localhost") ||
|
||||
host === "0.0.0.0" ||
|
||||
host === "::" ||
|
||||
host === "::1" ||
|
||||
host.startsWith("127.") ||
|
||||
host === "169.254.169.254" || // AWS/GCP/Azure instance metadata
|
||||
host === "metadata.google.internal"
|
||||
);
|
||||
};
|
||||
|
||||
// A user-provided URL for web-page ingestion: must be http(s) and not target a
|
||||
// loopback/metadata host.
|
||||
const webUrlSchema = z
|
||||
.string()
|
||||
.url()
|
||||
.refine((value) => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return (parsed.protocol === "http:" || parsed.protocol === "https:") && !isBlockedWebHost(parsed.hostname);
|
||||
}, "URL must be an http(s) address and must not point at a loopback or metadata host");
|
||||
|
||||
// Roughly 18 MB of raw bytes once base64-decoded (base64 inflates by ~4/3).
|
||||
// Bounds the upload/transcription payloads so a huge request can't pressure
|
||||
// memory here or upstream before we even start decoding.
|
||||
const MAX_BASE64_LENGTH = 25_000_000;
|
||||
const boundedBase64Schema = z.string().min(1).max(MAX_BASE64_LENGTH);
|
||||
const uploadFilenameSchema = z.string().min(1).max(255);
|
||||
const contentTypeSchema = z.string().min(1).max(255);
|
||||
|
||||
// Tag persisted conversations with the owning Homarr user so they can be found
|
||||
// back in Open WebUI even when an integration/account is shared between users.
|
||||
const homarrUserTag = (userId: string) => `homarr-user:${userId}`;
|
||||
|
||||
const messageSchema = z.object({
|
||||
role: z.enum(["system", "user", "assistant"]),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
// Outbound completion messages may carry multimodal parts (text + images),
|
||||
// so users can attach images to a message.
|
||||
const contentPartSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("text"), text: z.string() }),
|
||||
z.object({ type: z.literal("image_url"), image_url: z.object({ url: z.string() }) }),
|
||||
]);
|
||||
|
||||
const completionMessageSchema = z.object({
|
||||
role: z.enum(["system", "user", "assistant"]),
|
||||
content: z.union([z.string(), z.array(contentPartSchema)]),
|
||||
});
|
||||
|
||||
const chatPayloadSchema = z.object({
|
||||
title: z.string(),
|
||||
models: z.array(z.string()),
|
||||
messages: z.array(messageSchema),
|
||||
});
|
||||
|
||||
type StreamEvent = { type: "delta"; content: string } | { type: "done" } | { type: "error"; message: string };
|
||||
|
||||
type CompletionMessage = z.infer<typeof completionMessageSchema>;
|
||||
|
||||
// Extract the plain text of a message, flattening multimodal content parts.
|
||||
const messageText = (message: CompletionMessage): string =>
|
||||
typeof message.content === "string"
|
||||
? message.content
|
||||
: message.content
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join(" ");
|
||||
|
||||
// Number of chunks to retrieve per collection when grounding a reply.
|
||||
const RETRIEVAL_K = 4;
|
||||
|
||||
// Instruction prepended to the retrieved context so the model knows how to use
|
||||
// it. Model-facing, so intentionally not localized.
|
||||
const RETRIEVAL_INSTRUCTION =
|
||||
"Use the following context from the user's attached sources (web pages, knowledge bases, " +
|
||||
"files, notes and chats) to answer. If the answer is not in the context, say so.";
|
||||
|
||||
// Retrieve relevant chunks from the attached collections and prepend them as a
|
||||
// system context message so the model can ground its reply on them.
|
||||
const groundMessagesAsync = async (
|
||||
client: { queryCollectionsAsync: (names: string[], query: string, k?: number) => Promise<string[]> },
|
||||
messages: CompletionMessage[],
|
||||
collections: string[],
|
||||
contextTexts: string[],
|
||||
): Promise<CompletionMessage[]> => {
|
||||
if (collections.length === 0 && contextTexts.length === 0) return messages;
|
||||
|
||||
// Use the most recent user message that has text. The latest turn can be
|
||||
// image-only (no text), in which case we fall back to the prior question so
|
||||
// attached collections still get a meaningful retrieval query.
|
||||
const query =
|
||||
messages
|
||||
.filter((message) => message.role === "user")
|
||||
.map(messageText)
|
||||
.findLast((text) => text.trim() !== "") ?? "";
|
||||
|
||||
let documents: string[] = [];
|
||||
if (collections.length > 0) {
|
||||
try {
|
||||
documents = await client.queryCollectionsAsync(collections, query, RETRIEVAL_K);
|
||||
} catch (error) {
|
||||
logger.warn("Open WebUI context retrieval failed; continuing without it", { error });
|
||||
}
|
||||
}
|
||||
|
||||
const blocks = [...documents, ...contextTexts].filter((block) => block.trim() !== "");
|
||||
if (blocks.length === 0) return messages;
|
||||
|
||||
const contextMessage: CompletionMessage = {
|
||||
role: "system",
|
||||
content: `${RETRIEVAL_INSTRUCTION}\n\n${blocks.join("\n\n")}`,
|
||||
};
|
||||
return [contextMessage, ...messages];
|
||||
};
|
||||
|
||||
export const openWebUiRouter = createTRPCRouter({
|
||||
getModels: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
|
||||
const handler = openWebUiModelsRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
|
||||
// Chat history exposes private user data, so we require "interact" access
|
||||
// (not just "query") and verify the caller is the integration's creator.
|
||||
getChats: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiChatsRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
|
||||
// Knowledge bases, files and notes expose private source content/metadata, so
|
||||
// gate them like the interactive chat paths (protected + "interact" access,
|
||||
// creator-only) instead of leaving them reachable by anonymous board viewers.
|
||||
getKnowledge: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiKnowledgeRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
|
||||
getFiles: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiFilesRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
|
||||
getNotes: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiNotesRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
|
||||
getNote: protectedProcedure
|
||||
.input(z.object({ noteId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
|
||||
const { data } = await handler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
|
||||
getKnowledgeFiles: protectedProcedure
|
||||
.input(z.object({ knowledgeId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiKnowledgeFilesRequestHandler.handler(ctx.integration, {
|
||||
knowledgeId: input.knowledgeId,
|
||||
});
|
||||
const { data } = await handler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
|
||||
// Ingest a web page so its content can ground later messages. Returns the
|
||||
// collection to retrieve from plus a display title.
|
||||
processWeb: protectedProcedure
|
||||
.input(z.object({ url: webUrlSchema }))
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const client = await createIntegrationAsync(ctx.integration);
|
||||
return await client.processWebAsync(input.url);
|
||||
}),
|
||||
|
||||
// Upload a file (e.g. a document) so it can be retrieved from later. Returns
|
||||
// the file id; its vector collection is `file-{id}`.
|
||||
uploadFile: protectedProcedure
|
||||
.input(
|
||||
z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const client = await createIntegrationAsync(ctx.integration);
|
||||
return await client.uploadFileAsync(input.filename, input.contentBase64, input.contentType);
|
||||
}),
|
||||
|
||||
// Transcribe recorded audio to text for voice messages.
|
||||
transcribe: protectedProcedure
|
||||
.input(
|
||||
z.object({ filename: uploadFilenameSchema, contentBase64: boundedBase64Schema, contentType: contentTypeSchema }),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const client = await createIntegrationAsync(ctx.integration);
|
||||
const text = await client.transcribeAudioAsync(input.filename, input.contentBase64, input.contentType);
|
||||
return { text };
|
||||
}),
|
||||
|
||||
// Full chat messages contain private user data; restrict to the creator.
|
||||
getChat: protectedProcedure
|
||||
.input(z.object({ chatId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiChatRequestHandler.handler(ctx.integration, { chatId: input.chatId });
|
||||
const { data } = await handler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
|
||||
createChat: protectedProcedure
|
||||
.input(chatPayloadSchema)
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const client = await createIntegrationAsync(ctx.integration);
|
||||
const chat = await client.createChatAsync({
|
||||
title: input.title,
|
||||
models: input.models,
|
||||
messages: input.messages,
|
||||
meta: { tags: [homarrUserTag(ctx.session.user.id)] },
|
||||
});
|
||||
openWebUiChatsRequestHandler.invalidateCache();
|
||||
return chat;
|
||||
}),
|
||||
|
||||
updateChat: protectedProcedure
|
||||
.input(z.object({ chatId: z.string(), chat: chatPayloadSchema }))
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const client = await createIntegrationAsync(ctx.integration);
|
||||
const chat = await client.updateChatAsync(input.chatId, {
|
||||
...input.chat,
|
||||
meta: { tags: [homarrUserTag(ctx.session.user.id)] },
|
||||
});
|
||||
openWebUiChatsRequestHandler.invalidateCache();
|
||||
openWebUiChatRequestHandler.invalidateCache();
|
||||
return chat;
|
||||
}),
|
||||
|
||||
// Streaming chat completion. Delivered over the WebSocket subscription link so
|
||||
// tokens can be emitted as they arrive. A completion spends the owner's upstream
|
||||
// LLM credits, so it must not be reachable by anonymous viewers of a public
|
||||
// board: protectedProcedure requires a session (the WS context resolves it from
|
||||
// the session-token cookie) and the "interact" middleware checks integration
|
||||
// access, matching processWeb/uploadFile/transcribe above.
|
||||
sendMessage: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
model: z.string(),
|
||||
messages: z.array(completionMessageSchema),
|
||||
// Collections to ground the reply on (web pages, knowledge bases, files).
|
||||
// We retrieve relevant chunks and inject them before streaming.
|
||||
collections: z.array(z.string()).optional(),
|
||||
// Verbatim context blocks (notes, referenced chats) injected as-is.
|
||||
contextTexts: z.array(z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.subscription(({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
|
||||
return observable<StreamEvent>((emit) => {
|
||||
const controller = new AbortController();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const client = await createIntegrationAsync(ctx.integration);
|
||||
const messages = await groundMessagesAsync(
|
||||
client,
|
||||
input.messages,
|
||||
input.collections ?? [],
|
||||
input.contextTexts ?? [],
|
||||
);
|
||||
await client.streamChatCompletionAsync(
|
||||
{ model: input.model, messages },
|
||||
(delta) => emit.next({ type: "delta", content: delta }),
|
||||
controller.signal,
|
||||
);
|
||||
emit.next({ type: "done" });
|
||||
emit.complete();
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
logger.error("Open WebUI chat completion failed", {
|
||||
integrationId: ctx.integration.id,
|
||||
error,
|
||||
});
|
||||
emit.next({
|
||||
type: "error",
|
||||
message: error instanceof Error ? error.message : "Failed to stream chat completion",
|
||||
});
|
||||
}
|
||||
emit.complete();
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { patchmonStatsRequestHandler } from "@homarr/request-handler/patchmon";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, protectedProcedure } from "../../trpc";
|
||||
|
||||
export const patchmonRouter = createTRPCRouter({
|
||||
getStats: protectedProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get PatchMon host patch statistics including total hosts, hosts needing updates, security update counts, up-to-date hosts, outdated packages, repositories, and OS distribution. REQUIRED: integrationId (single PatchMon integration ID from integration_all). Requires authenticated session (API key or browser login) and integration query access.",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", "patchmon"))
|
||||
.query(async ({ ctx }) => {
|
||||
const innerHandler = patchmonStatsRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
return data;
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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 [];
|
||||
});
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -67,6 +67,8 @@ describe("isPersistableWidgetQueryKey", () => {
|
||||
[[["docker", "getContainers"], { type: "query" }], true],
|
||||
[[["integration", "byIds"], { type: "query" }], true],
|
||||
[[["widget", "app", "ping"], { type: "query", input: { id: "abc" } }], false],
|
||||
[[["widget", "beszel", "getSystemStats"], { type: "query" }], false],
|
||||
[[["widget", "beszel", "getSystems"], { type: "query" }], true],
|
||||
[[["app", "selectable"], { type: "query" }], false],
|
||||
[[["board", "getBoardByName"], { type: "query" }], false],
|
||||
[["widget"], false],
|
||||
|
||||
@@ -95,8 +95,12 @@ export const createTRPCRouter = t.router;
|
||||
|
||||
export const isDemoMode = env.DEMO_MODE;
|
||||
|
||||
// The public "real demo" (demo + mock integrations) stays read-only, while preview
|
||||
// deployments run with DEMO_MODE + DEMO_READ_ONLY=false to allow mutations.
|
||||
export const isDemoReadOnly = env.DEMO_MODE && env.DEMO_READ_ONLY;
|
||||
|
||||
const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
|
||||
if (env.DEMO_MODE && type === "mutation") {
|
||||
if (isDemoReadOnly && type === "mutation") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Mutations are disabled in demo mode",
|
||||
@@ -105,7 +109,7 @@ const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
|
||||
return next({ ctx });
|
||||
});
|
||||
|
||||
const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
|
||||
const baseProcedure = isDemoReadOnly ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
|
||||
|
||||
/**
|
||||
* Public (unauthed) procedure
|
||||
|
||||
@@ -11,6 +11,7 @@ interface IntegrationContextProps {
|
||||
name: string;
|
||||
url: string;
|
||||
kind: IntegrationKind;
|
||||
creatorId: string | null;
|
||||
permissions: {
|
||||
hasFullAccess: boolean;
|
||||
hasInteractAccess: boolean;
|
||||
|
||||
@@ -15,6 +15,7 @@ export const getIntegrationsWithPermissionsAsync = async (session: Session | nul
|
||||
name: true,
|
||||
url: true,
|
||||
kind: true,
|
||||
creatorId: true,
|
||||
},
|
||||
with: {
|
||||
userPermissions: {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AgentOptions } from "node:https";
|
||||
import { Agent as HttpsAgent } from "node:https";
|
||||
import { checkServerIdentity } from "node:tls";
|
||||
import axios from "axios";
|
||||
import type { RequestInfo, RequestInit, Response } from "undici";
|
||||
import type { Agent as UndiciAgent, RequestInfo, RequestInit, Response } from "undici";
|
||||
import { fetch } from "undici";
|
||||
|
||||
import {
|
||||
@@ -29,11 +29,15 @@ export const createCustomCheckServerIdentity = (
|
||||
};
|
||||
};
|
||||
|
||||
export const createCertificateAgentAsync = async (override?: {
|
||||
ca: string | string[];
|
||||
checkServerIdentity: typeof checkServerIdentity;
|
||||
}) => {
|
||||
export const createCertificateAgentAsync = async (
|
||||
override?: {
|
||||
ca: string | string[];
|
||||
checkServerIdentity: typeof checkServerIdentity;
|
||||
},
|
||||
agentOptions?: Pick<UndiciAgent.Options, "bodyTimeout">,
|
||||
) => {
|
||||
return new UndiciHttpAgent({
|
||||
...agentOptions,
|
||||
connect: override ?? {
|
||||
ca: await getAllTrustedCertificatesAsync(),
|
||||
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
|
||||
@@ -61,14 +65,18 @@ export const createAxiosCertificateInstanceAsync = async (
|
||||
|
||||
export const fetchWithTrustedCertificatesAsync = async (
|
||||
url: RequestInfo,
|
||||
options?: RequestInit & { timeout?: number },
|
||||
options?: RequestInit & { timeout?: number; bodyTimeout?: number },
|
||||
): Promise<Response> => {
|
||||
const agent = await createCertificateAgentAsync(undefined);
|
||||
const agent = await createCertificateAgentAsync(
|
||||
undefined,
|
||||
options?.bodyTimeout !== undefined ? { bodyTimeout: options.bodyTimeout } : undefined,
|
||||
);
|
||||
if (options?.timeout) {
|
||||
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options;
|
||||
return await withTimeoutAsync(
|
||||
async (signal) =>
|
||||
fetch(url, {
|
||||
...options,
|
||||
...fetchOptions,
|
||||
signal,
|
||||
dispatcher: agent,
|
||||
}),
|
||||
@@ -76,8 +84,9 @@ export const fetchWithTrustedCertificatesAsync = async (
|
||||
);
|
||||
}
|
||||
|
||||
const { bodyTimeout: _bodyTimeout, ...fetchOptions } = options ?? {};
|
||||
return fetch(url, {
|
||||
...options,
|
||||
...fetchOptions,
|
||||
dispatcher: agent,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `integration` ADD `creator_id` varchar(64);--> statement-breakpoint
|
||||
ALTER TABLE `integration` ADD CONSTRAINT `integration_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -302,6 +302,13 @@
|
||||
"when": 1783176100870,
|
||||
"tag": "0042_add_widget_secret",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "5",
|
||||
"when": 1783786572320,
|
||||
"tag": "0043_add_integration_creator_id",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "integration" ADD COLUMN "creator_id" varchar(64);--> statement-breakpoint
|
||||
ALTER TABLE "integration" ADD CONSTRAINT "integration_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -64,6 +64,13 @@
|
||||
"when": 1783176100870,
|
||||
"tag": "0008_add_widget_secret",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1783786378808,
|
||||
"tag": "0009_add_integration_creator_id",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `integration` ADD `creator_id` text REFERENCES user(id) ON DELETE SET NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -288,6 +288,13 @@
|
||||
"when": 1783176100870,
|
||||
"tag": "0040_add_widget_secret",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 41,
|
||||
"version": "6",
|
||||
"when": 1783786326756,
|
||||
"tag": "0041_add_integration_creator_id",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Loaded 100 of 274 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user