Compare commits

..
Author SHA1 Message Date
ajnart 03688f7cd2 fix: increase default widget preview dimensions for fullscreen view
Changes default widget preview dimensions from 128x128 to 960x640 so
table-based widgets like Docker containers aren't squished into a tiny
strip when viewed via the /widgets route in fullscreen mode.

Closes #6230
2026-07-06 21:07:35 +02:00
50 changed files with 317 additions and 2694 deletions

No files matched your search

-7
View File
@@ -60,10 +60,3 @@ TURBO_TELEMETRY_DISABLED=1
# Enable mock integration # 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
+1 -1
View File
@@ -25,7 +25,7 @@ env:
jobs: jobs:
lint-format-autofix: lint-format-autofix:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id if: github.event_name == 'pull_request'
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
permissions: permissions:
-4
View File
@@ -159,10 +159,6 @@ packages/integrations/src/search-ch @Meierschlumpf
apps/docs/docs/integrations/speedtest-tracker @kylegrieder apps/docs/docs/integrations/speedtest-tracker @kylegrieder
packages/integrations/src/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 # Technitium DNS - introduced in https://github.com/homarr-labs/homarr/pull/5832 by @lopsch
apps/docs/docs/integrations/technitium-dns @lopsch apps/docs/docs/integrations/technitium-dns @lopsch
packages/integrations/src/technitium @lopsch packages/integrations/src/technitium @lopsch
@@ -1,96 +0,0 @@
---
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 &gt; Tools &gt; 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
&gt; Security &gt; 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>
@@ -1,8 +0,0 @@
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",
};
@@ -1,71 +1,61 @@
--- ---
title: "System Health Monitoring" title: 'System Health Monitoring'
description: "Displays information showing the health and status of your system(s)." description: 'Displays information showing the health and status of your system(s).'
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from "@site/src/components/widgets/header"; import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { IconAd } from "@tabler/icons-react"; import { IconAd } from "@tabler/icons-react";
import Tabs from "@theme/Tabs"; import Tabs from '@theme/Tabs';
import TabItem from "@theme/TabItem"; import TabItem from '@theme/TabItem';
import { healthMonitoringWidget } from "."; import { healthMonitoringWidget } from '.';
import { dashDotIntegration } from "@site/docs/integrations/dash-dot"; import { dashDotIntegration } from '@site/docs/integrations/dash-dot';
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault"; import { openMediaVaultIntegration } from '@site/docs/integrations/open-media-vault';
import { proxmoxIntegration } from "@site/docs/integrations/proxmox"; import { proxmoxIntegration } from '@site/docs/integrations/proxmox';
import { glancesIntegration } from "@site/docs/integrations/glances"; 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. This widget displays information about the health and status of your system(s), including CPU, memory, disk usage, and more.
### Screenshots ### Screenshots
import systemView from "./img/system-view.png"; import systemView from './img/system-view.png';
import clusterView from "./img/cluster-view.png"; import clusterView from './img/cluster-view.png';
import { truenasIntegration } from "@site/docs/integrations/truenas"; 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={systemView} alt="health-monitoring system-view" width={256} height={256} />
<img src={clusterView} alt="health-monitoring cluster-view" width={256} height={256} /> <img src={clusterView} alt="health-monitoring cluster-view" width={256} height={256} />
### Supported Integrations ### Supported Integrations
<WidgetIntegrations <WidgetIntegrations items={[{
items={[ integration: dashDotIntegration,
{ note: "Will display information on the system tab"
integration: dashDotIntegration, }, {
note: "Will display information on the system tab", integration: openMediaVaultIntegration,
}, note: "Will display information on the system tab"
{ }, {
integration: openMediaVaultIntegration, integration: truenasIntegration,
note: "Will display information on the system tab", note: "Will display information on the system tab"
}, }, {
{ integration: glancesIntegration,
integration: truenasIntegration, note: "Will display information on the system tab"
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"
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 ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={healthMonitoringWidget.configuration} /> <WidgetConfig configuration={healthMonitoringWidget.configuration} />
@@ -38,13 +38,6 @@ export const healthMonitoringWidget: WidgetDefinition = {
values: { type: "boolean" }, values: { type: "boolean" },
defaultValue: "yes", 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", name: "Default tab",
description: "Tab that should be visible by default. Only used when multiple integrations are available.", description: "Tab that should be visible by default. Only used when multiple integrations are available.",
@@ -1,51 +0,0 @@
---
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} />
@@ -1,41 +0,0 @@
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,69 +1,57 @@
--- ---
title: "System Resources" title: 'System Resources'
description: "Displays CPU, RAM and network of your host in real time" description: 'Displays CPU, RAM and network of your host in real time'
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from "@site/src/components/widgets/header"; import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { systemResourcesWidget } from "."; import { systemResourcesWidget } from '.';
import { dashDotIntegration } from "@site/docs/integrations/dash-dot"; import { dashDotIntegration } from '@site/docs/integrations/dash-dot';
import { glancesIntegration } from "@site/docs/integrations/glances"; import { glancesIntegration } from '@site/docs/integrations/glances';
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault"; import { openMediaVaultIntegration } from '@site/docs/integrations/open-media-vault';
import { proxmoxIntegration } from "@site/docs/integrations/proxmox"; 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 Displays CPU, RAM and network of your host in real time
### Screenshots ### Screenshots
import network from "./img/network.png"; import network from './img/network.png';
import cpu from "./img/cpu.png"; import cpu from './img/cpu.png';
import memory from "./img/memory.png"; import memory from './img/memory.png';
import cpuAndMemory from "./img/cpu-and-memory.png"; import cpuAndMemory from './img/cpu-and-memory.png';
import { truenasIntegration } from "@site/docs/integrations/truenas"; import { truenasIntegration } from '@site/docs/integrations/truenas';
import { synologyIntegration } from "@site/docs/integrations/synology";
<div className="flex gap-1"> <div className='flex gap-1'>
<div className="flex flex-col 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} /> <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={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} /> <img src={memory} alt="system-resources-widget memory-only text-label" width={128} height={128} />
</div> </div>
</div> </div>
<img <img src={cpuAndMemory} alt="system-resources-widget cpu-and-memory icon-text-label" width={256 + 4} height={256 + 4} />
src={cpuAndMemory}
alt="system-resources-widget cpu-and-memory icon-text-label"
width={256 + 4}
height={256 + 4}
/>
</div> </div>
### Supported Integrations ### Supported Integrations
<WidgetIntegrations <WidgetIntegrations items={[{
items={[ integration: dashDotIntegration
{ }, {
integration: dashDotIntegration, integration: openMediaVaultIntegration
}, }, {
{ integration: truenasIntegration
integration: openMediaVaultIntegration, }, {
}, integration: glancesIntegration
{ }]} />
integration: truenasIntegration,
},
{
integration: synologyIntegration,
},
{
integration: glancesIntegration,
},
]}
/>
### Adding the widget ### Adding the widget
+6 -18
View File
@@ -1,25 +1,18 @@
// Algolia DocSearch crawler for homarr.dev // Algolia DocSearch crawler for homarr.dev
// Crawler ID: cd77a285-2756-4557-bf21-ee703748df15 // Crawler ID: cd77a285-2756-4557-bf21-ee703748df15
// Paste this in the Algolia crawler editor. // 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
new Crawler({ new Crawler({
appId: "N69WSPZTID", appId: "N69WSPZTID",
apiKey: "YOUR_CRAWLER_ADMIN_API_KEY", apiKey: "YOUR_CRAWLER_ADMIN_API_KEY",
indexPrefix: "", rateLimit: 8,
rateLimit: 32, maxDepth: 10,
maxDepth: 4,
startUrls: ["https://homarr.dev/"], startUrls: ["https://homarr.dev/"],
sitemaps: ["https://homarr.dev/sitemap.xml"], sitemaps: ["https://homarr.dev/sitemap.xml"],
ignoreCanonicalTo: true, ignoreCanonicalTo: true,
discoveryPatterns: ["https://homarr.dev/**"], 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: [ actions: [
{ {
indexName: "Docusaurus", indexName: "Docusaurus",
@@ -112,6 +105,7 @@ new Crawler({
advancedSyntax: true, advancedSyntax: true,
attributeCriteriaComputedByMinProximity: true, attributeCriteriaComputedByMinProximity: true,
removeWordsIfNoResults: "allOptional", removeWordsIfNoResults: "allOptional",
separatorsToIndex: "_",
}, },
markdown: { markdown: {
attributesForFaceting: ["lang", "language", "version", "docusaurus_tag"], attributesForFaceting: ["lang", "language", "version", "docusaurus_tag"],
@@ -125,10 +119,4 @@ new Crawler({
advancedSyntax: false, advancedSyntax: false,
}, },
}, },
apiKey: "YOUR_CRAWLER_ADMIN_API_KEY",
schedule: "on saturday",
renderJavaScript: false,
maxUrls: null,
cache: { enabled: false },
safetyChecks: { beforeIndexPublishing: { maxLostRecordsPercentage: 99 } },
}); });
+2 -21
View File
@@ -8,7 +8,7 @@ const config: Config = {
tagline: "A simple yet powerful dashboard for your server.", tagline: "A simple yet powerful dashboard for your server.",
url: "https://homarr.dev", url: "https://homarr.dev",
baseUrl: "/", baseUrl: "/",
trailingSlash: undefined, trailingSlash: true,
favicon: "img/logo.png", favicon: "img/logo.png",
organizationName: "homarr-labs", organizationName: "homarr-labs",
projectName: "homarr", projectName: "homarr",
@@ -79,27 +79,8 @@ const config: Config = {
sitemap: { sitemap: {
changefreq: "weekly", changefreq: "weekly",
priority: 0.5, priority: 0.5,
ignorePatterns: ["/tags/**", "/docs/category/**"], ignorePatterns: ["/tags/**"],
filename: "sitemap.xml", 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, } satisfies Preset.Options,
], ],
@@ -123,9 +123,9 @@ async function QueryCacheHydration({ userId, boardId }: { userId: string; boardI
const serialized = await getQueryCacheAsync(userId, boardId); const serialized = await getQueryCacheAsync(userId, boardId);
if (!serialized) return null; if (!serialized) return null;
const persisted = superjson.parse<PersistedClient | undefined>(serialized); const persisted = superjson.parse<PersistedClient>(serialized);
if (!persisted || persisted.buster !== queryCacheBuster) return null; if (persisted.buster !== queryCacheBuster) return null;
if (!persisted.clientState?.queries?.length) return null; if (!persisted?.clientState?.queries?.length) return null;
return <HydrationBoundary state={persisted.clientState} />; return <HydrationBoundary state={persisted.clientState} />;
} catch (error) { } catch (error) {
@@ -45,9 +45,7 @@ export const BoardContentHeaderActions = () => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board); const { hasChangeAccess } = useBoardPermissions(board);
// Fall back to read-only if the query has no data (e.g. errored) so we never expose const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
// edit/save/settings UI that would then fail server-side.
const { data: demoReadOnly = true, isLoading } = clientApi.info.isDemoReadOnly.useQuery();
if (!hasChangeAccess || isLoading) { if (!hasChangeAccess || isLoading) {
return <SelectBoardsMenu />; return <SelectBoardsMenu />;
@@ -57,9 +55,9 @@ export const BoardContentHeaderActions = () => {
<> <>
{isEditMode && <AddMenu />} {isEditMode && <AddMenu />}
<EditModeMenu demoReadOnly={demoReadOnly} /> <EditModeMenu demoMode={demoMode ?? false} />
{!demoReadOnly && ( {!demoMode && (
<OnboardingTour.Target id="board-settings"> <OnboardingTour.Target id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`}> <HeaderButton href={`/boards/${board.name}/settings`}>
<IconSettings stroke={1.5} /> <IconSettings stroke={1.5} />
@@ -160,7 +158,7 @@ const AddMenu = () => {
); );
}; };
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => { const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
const [isEditMode, { open, close }] = useEditMode(); const [isEditMode, { open, close }] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const utils = clientApi.useUtils(); const utils = clientApi.useUtils();
@@ -190,11 +188,11 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const toggle = useCallback(() => { const toggle = useCallback(() => {
if (isEditMode) { if (isEditMode) {
if (demoReadOnly) return discardDemoChanges(); if (demoMode) return discardDemoChanges();
return saveBoard(board); return saveBoard(board);
} }
open(); open();
}, [board, isEditMode, demoReadOnly, saveBoard, open, discardDemoChanges]); }, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
useHotkeys([[hotkeys.toggleBoardEdit, toggle]]); useHotkeys([[hotkeys.toggleBoardEdit, toggle]]);
usePreventLeaveWithDirty(isEditMode); usePreventLeaveWithDirty(isEditMode);
@@ -37,8 +37,8 @@ export const WidgetPreviewPageContent = ({ kind, integrationData }: WidgetPrevie
const currentDefinition = useMemo(() => widgetImports[kind].definition, [kind]); const currentDefinition = useMemo(() => widgetImports[kind].definition, [kind]);
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [dimensions, setDimensions] = useState<Dimensions>({ const [dimensions, setDimensions] = useState<Dimensions>({
width: 128, width: 960,
height: 128, height: 640,
}); });
const [state, setState] = useState<{ const [state, setState] = useState<{
options: Record<string, unknown>; options: Record<string, unknown>;
@@ -2,10 +2,10 @@
import type { MutableRefObject, ReactNode } from "react"; import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text } from "@mantine/core"; import { Group, Menu, Switch, Text } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react"; import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import { useIsFetching, useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client"; import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client"; import { useSession } from "@homarr/auth/client";
@@ -50,9 +50,8 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]); const widgetQueryKey = useMemo(() => [["widget", item.kind]], [item.kind]);
const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
const handleRefetch = useCallback(() => { const handleRefetch = useCallback(() => {
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" }); void queryClient.invalidateQueries({ queryKey: widgetQueryKey });
}, [queryClient, widgetQueryKey]); }, [queryClient, widgetQueryKey]);
const options = useMemo( const options = useMemo(
@@ -224,15 +223,11 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
<> <>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />} {(toggleOptions.length > 0 || visibleWidgetActions.length > 0 || isEditMode) && <Menu.Divider />}
<Menu.Item <Menu.Item closeMenuOnClick leftSection={<IconRefresh size={16} />} onClick={handleRefetch}>
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
onClick={handleRefetch}
disabled={isWidgetFetching}
>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
{tMenu("refresh")} {tMenu("refresh")}
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} /> <WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} />
</Text> </Text>
</Group> </Group>
</Menu.Item> </Menu.Item>
@@ -270,24 +265,13 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
); );
}; };
const WidgetCacheAge = ({ const WidgetCacheAge = ({ queryClient, queryKey }: { queryClient: QueryClient; queryKey: unknown[] }) => {
queryClient,
queryKey,
isFetching,
}: {
queryClient: QueryClient;
queryKey: unknown[];
isFetching: boolean;
}) => {
// 1s tick for "just now" → "1s ago" label transition; isFetching prop handles refetch reactivity
const [, setTick] = useState(0); const [, setTick] = useState(0);
useEffect(() => { useEffect(() => {
const id = setInterval(() => setTick((n) => n + 1), 1000); const id = setInterval(() => setTick((n) => n + 1), 1000);
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, []);
if (isFetching) return <Loader size={12} />;
const timestamps = queryClient const timestamps = queryClient
.getQueryCache() .getQueryCache()
.findAll({ queryKey }) .findAll({ queryKey })
-2
View File
@@ -6,11 +6,9 @@ export const env = createEnv({
server: { server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(), KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
DEMO_MODE: createBooleanSchema(false), DEMO_MODE: createBooleanSchema(false),
DEMO_READ_ONLY: createBooleanSchema(true),
}, },
runtimeEnv: { runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME, KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
DEMO_MODE: process.env.DEMO_MODE, DEMO_MODE: process.env.DEMO_MODE,
DEMO_READ_ONLY: process.env.DEMO_READ_ONLY,
}, },
}); });
+1 -2
View File
@@ -1,7 +1,7 @@
import z from "zod/v4"; import z from "zod/v4";
import packageJson from "../../../../package.json"; import packageJson from "../../../../package.json";
import { createTRPCRouter, isDemoMode, isDemoReadOnly, publicProcedure, protectedProcedure } from "../trpc"; import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc";
export const infoRouter = createTRPCRouter({ export const infoRouter = createTRPCRouter({
getInfo: protectedProcedure getInfo: protectedProcedure
@@ -20,5 +20,4 @@ export const infoRouter = createTRPCRouter({
}; };
}), }),
isDemoMode: publicProcedure.query(() => isDemoMode), isDemoMode: publicProcedure.query(() => isDemoMode),
isDemoReadOnly: publicProcedure.query(() => isDemoReadOnly),
}); });
@@ -1,31 +1,21 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { SynologyIntegration } from "@homarr/integrations";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations"; import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
const healthMonitoringIntegrationKinds = [
"openmediavault",
"dashDot",
"truenas",
"unraid",
"glances",
"synology",
"mock",
] as const;
export const healthMonitoringRouter = createTRPCRouter({ export const healthMonitoringRouter = createTRPCRouter({
getSystemHealthStatus: publicProcedure getSystemHealthStatus: publicProcedure
.meta({ .meta({
mcp: { mcp: {
enabled: true, enabled: true,
description: description:
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Synology/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)", "Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
}, },
}) })
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds)) .concat(
createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
)
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => { return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync(); const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
@@ -37,23 +27,6 @@ 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 getClusterHealthStatus: publicProcedure
.meta({ .meta({
mcp: { mcp: {
+2 -6
View File
@@ -95,12 +95,8 @@ export const createTRPCRouter = t.router;
export const isDemoMode = env.DEMO_MODE; 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 }) => { const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
if (isDemoReadOnly && type === "mutation") { if (env.DEMO_MODE && type === "mutation") {
throw new TRPCError({ throw new TRPCError({
code: "FORBIDDEN", code: "FORBIDDEN",
message: "Mutations are disabled in demo mode", message: "Mutations are disabled in demo mode",
@@ -109,7 +105,7 @@ const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
return next({ ctx }); return next({ ctx });
}); });
const baseProcedure = isDemoReadOnly ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure; const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
/** /**
* Public (unauthed) procedure * Public (unauthed) procedure
@@ -33,7 +33,6 @@ const integrationAliases: Partial<Record<IntegrationKind, readonly string[]>> =
paperlessNgx: ["paperless-ngx", "paperless"], paperlessNgx: ["paperless-ngx", "paperless"],
coolify: ["coolify"], coolify: ["coolify"],
truenas: ["truenas"], truenas: ["truenas"],
synology: ["synology", "diskstation"],
}; };
export const matchIntegrationKind = (search: string): IntegrationKind | null => { export const matchIntegrationKind = (search: string): IntegrationKind | null => {
@@ -60,8 +60,8 @@ export type HomarrDocumentationPath =
| "/docs/tags/connections" | "/docs/tags/connections"
| "/docs/tags/custom-widgets" | "/docs/tags/custom-widgets"
| "/docs/tags/customization" | "/docs/tags/customization"
| "/docs/tags/data"
| "/docs/tags/data-sources" | "/docs/tags/data-sources"
| "/docs/tags/data"
| "/docs/tags/database" | "/docs/tags/database"
| "/docs/tags/developer" | "/docs/tags/developer"
| "/docs/tags/development" | "/docs/tags/development"
@@ -99,8 +99,8 @@ export type HomarrDocumentationPath =
| "/docs/tags/redis" | "/docs/tags/redis"
| "/docs/tags/responsive" | "/docs/tags/responsive"
| "/docs/tags/roles" | "/docs/tags/roles"
| "/docs/tags/search"
| "/docs/tags/search-engines" | "/docs/tags/search-engines"
| "/docs/tags/search"
| "/docs/tags/secrets" | "/docs/tags/secrets"
| "/docs/tags/security" | "/docs/tags/security"
| "/docs/tags/self-signed" | "/docs/tags/self-signed"
@@ -132,6 +132,15 @@ export type HomarrDocumentationPath =
| "/docs/advanced/running-as-different-user" | "/docs/advanced/running-as-different-user"
| "/docs/advanced/single-sign-on" | "/docs/advanced/single-sign-on"
| "/docs/advanced/styling" | "/docs/advanced/styling"
| "/docs/category/advanced"
| "/docs/category/community"
| "/docs/category/developer-guides"
| "/docs/category/getting-started"
| "/docs/category/installation-1"
| "/docs/category/installation"
| "/docs/category/integrations"
| "/docs/category/management"
| "/docs/category/widgets"
| "/docs/community/donate" | "/docs/community/donate"
| "/docs/community/faq" | "/docs/community/faq"
| "/docs/community/get-in-touch" | "/docs/community/get-in-touch"
@@ -196,7 +205,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/slskd" | "/docs/integrations/slskd"
| "/docs/integrations/sonarr" | "/docs/integrations/sonarr"
| "/docs/integrations/speedtest-tracker" | "/docs/integrations/speedtest-tracker"
| "/docs/integrations/synology"
| "/docs/integrations/tdarr" | "/docs/integrations/tdarr"
| "/docs/integrations/technitium-dns" | "/docs/integrations/technitium-dns"
| "/docs/integrations/tracearr" | "/docs/integrations/tracearr"
@@ -258,7 +266,6 @@ export type HomarrDocumentationPath =
| "/docs/widgets/smart-home-execute-automation" | "/docs/widgets/smart-home-execute-automation"
| "/docs/widgets/speedtest-tracker" | "/docs/widgets/speedtest-tracker"
| "/docs/widgets/stock-price" | "/docs/widgets/stock-price"
| "/docs/widgets/system-disks"
| "/docs/widgets/system-resources" | "/docs/widgets/system-resources"
| "/docs/widgets/timetable" | "/docs/widgets/timetable"
| "/docs/widgets/tracearr" | "/docs/widgets/tracearr"
@@ -305,7 +312,6 @@ export type HomarrDocumentationPath =
| "/docs/integrations/ical" | "/docs/integrations/ical"
| "/docs/integrations/anchor" | "/docs/integrations/anchor"
| "/docs/integrations/truenas" | "/docs/integrations/truenas"
| "/docs/integrations/synology"
| "/docs/integrations/unraid" | "/docs/integrations/unraid"
| "/docs/integrations/coolify" | "/docs/integrations/coolify"
| "/docs/integrations/immich" | "/docs/integrations/immich"
@@ -351,7 +357,6 @@ export type HomarrDocumentationPath =
| "/docs/widgets/notifications" | "/docs/widgets/notifications"
| "/docs/widgets/system-resources" | "/docs/widgets/system-resources"
| "/docs/widgets/coolify" | "/docs/widgets/coolify"
| "/docs/widgets/system-disks"
| "/docs/widgets/timetable" | "/docs/widgets/timetable"
| "/docs/widgets/immich-server-stats" | "/docs/widgets/immich-server-stats"
| "/docs/widgets/immich-album-carousel" | "/docs/widgets/immich-album-carousel"
@@ -36,7 +36,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
ical: "ical", ical: "ical",
anchor: "anchor", anchor: "anchor",
truenas: "truenas", truenas: "truenas",
synology: "synology",
unraid: "unraid", unraid: "unraid",
coolify: "coolify", coolify: "coolify",
immich: "immich", immich: "immich",
@@ -33,7 +33,7 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
notifications: "notifications", notifications: "notifications",
systemResources: "system-resources", systemResources: "system-resources",
coolify: "coolify", coolify: "coolify",
systemDisks: "system-disks", systemDisks: null,
timetable: "timetable", timetable: "timetable",
"immich-serverStats": "immich-server-stats", "immich-serverStats": "immich-server-stats",
"immich-albumCarousel": "immich-album-carousel", "immich-albumCarousel": "immich-album-carousel",
-8
View File
@@ -321,14 +321,6 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/truenas"), documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
defaultPort: 80, defaultPort: 80,
}, },
synology: {
name: "Synology DiskStation",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/synology.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/synology"),
defaultPort: 5000,
},
unraid: { unraid: {
name: "Unraid", name: "Unraid",
secretKinds: [["apiKey"]], secretKinds: [["apiKey"]],
@@ -27,8 +27,8 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
firewall: getIntegrationKindsByCategory("firewall"), firewall: getIntegrationKindsByCategory("firewall"),
notifications: getIntegrationKindsByCategory("notifications"), notifications: getIntegrationKindsByCategory("notifications"),
mediaReleases: ["emby", "jellyfin", "plex"], mediaReleases: ["emby", "jellyfin", "plex"],
systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"], systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances"],
systemDisks: ["dashDot", "openmediavault", "truenas", "unraid", "synology"], systemDisks: ["dashDot", "openmediavault", "truenas", "unraid"],
coolify: ["coolify"], coolify: ["coolify"],
"immich-serverStats": ["immich"], "immich-serverStats": ["immich"],
"immich-albumCarousel": ["immich"], "immich-albumCarousel": ["immich"],
+69 -73
View File
@@ -45,7 +45,6 @@ import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
import { SeerrIntegration } from "../seerr/seerr-integration"; import { SeerrIntegration } from "../seerr/seerr-integration";
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration"; import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
import { TracearrIntegration } from "../tracearr/tracearr-integration"; import { TracearrIntegration } from "../tracearr/tracearr-integration";
import { SynologyIntegration } from "../synology/synology-integration";
import { TrueNasIntegration } from "../truenas/truenas-integration"; import { TrueNasIntegration } from "../truenas/truenas-integration";
import { UmamiIntegration } from "../umami/umami-integration"; import { UmamiIntegration } from "../umami/umami-integration";
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration"; import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
@@ -55,86 +54,83 @@ import type { Integration, IntegrationInput } from "./integration";
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>( export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
integration: IntegrationInput & { kind: TKind }, integration: IntegrationInput & { kind: TKind },
): Promise<IntegrationInstanceByKind[TKind]> => { ) => {
if (!(integration.kind in integrationCreators)) { if (!(integration.kind in integrationCreators)) {
throw new Error( throw new Error(
`Unknown integration kind ${integration.kind}. Did you forget to add it to the integration creator?`, `Unknown integration kind ${integration.kind}. Did you forget to add it to the integration creator?`,
); );
} }
return (await integrationCreators[integration.kind].create(integration)) as IntegrationInstanceByKind[TKind]; const creator = integrationCreators[integration.kind];
// factories are an array, to differentiate in js between class constructors and functions
if (Array.isArray(creator)) {
return (await creator[0](integration)) as IntegrationInstanceOfKind<TKind>;
}
return new creator(integration) as IntegrationInstanceOfKind<TKind>;
}; };
type IntegrationConstructor<TIntegration extends Integration = Integration> = new ( type IntegrationInstance = new (integration: IntegrationInput) => Integration;
integration: IntegrationInput,
) => TIntegration;
type IntegrationFactory<TIntegration extends Integration = Integration> = (
input: IntegrationInput,
) => Promise<TIntegration>;
type IntegrationCreator<TIntegration extends Integration = Integration> = {
create: (input: IntegrationInput) => TIntegration | Promise<TIntegration>;
};
const fromClass = <TIntegration extends Integration>(Constructor: IntegrationConstructor<TIntegration>) => ({
create: (input: IntegrationInput) => new Constructor(input),
});
const fromFactory = <TIntegration extends Integration>(create: IntegrationFactory<TIntegration>) => ({ create });
// factories are an array, to differentiate in js between class constructors and functions
export const integrationCreators = { export const integrationCreators = {
anchor: fromClass(AnchorIntegration), anchor: AnchorIntegration,
piHole: fromFactory(createPiHoleIntegrationAsync), piHole: [createPiHoleIntegrationAsync],
adGuardHome: fromClass(AdGuardHomeIntegration), adGuardHome: AdGuardHomeIntegration,
technitiumDns: fromFactory(createTechnitiumDnsIntegrationAsync), technitiumDns: [createTechnitiumDnsIntegrationAsync],
homeAssistant: fromClass(HomeAssistantIntegration), homeAssistant: HomeAssistantIntegration,
jellyfin: fromClass(JellyfinIntegration), jellyfin: JellyfinIntegration,
plex: fromClass(PlexIntegration), plex: PlexIntegration,
sonarr: fromClass(SonarrIntegration), sonarr: SonarrIntegration,
radarr: fromClass(RadarrIntegration), radarr: RadarrIntegration,
sabNzbd: fromClass(SabnzbdIntegration), sabNzbd: SabnzbdIntegration,
nzbGet: fromClass(NzbGetIntegration), nzbGet: NzbGetIntegration,
qBittorrent: fromClass(QBitTorrentIntegration), qBittorrent: QBitTorrentIntegration,
deluge: fromClass(DelugeIntegration), deluge: DelugeIntegration,
transmission: fromClass(TransmissionIntegration), transmission: TransmissionIntegration,
slskd: fromClass(SlskdIntegration), slskd: SlskdIntegration,
aria2: fromClass(Aria2Integration), aria2: Aria2Integration,
jellyseerr: fromClass(JellyseerrIntegration), jellyseerr: JellyseerrIntegration,
seerr: fromClass(SeerrIntegration), seerr: SeerrIntegration,
overseerr: fromClass(OverseerrIntegration), overseerr: OverseerrIntegration,
prowlarr: fromClass(ProwlarrIntegration), prowlarr: ProwlarrIntegration,
openmediavault: fromClass(OpenMediaVaultIntegration), openmediavault: OpenMediaVaultIntegration,
lidarr: fromClass(LidarrIntegration), lidarr: LidarrIntegration,
readarr: fromClass(ReadarrIntegration), readarr: ReadarrIntegration,
dashDot: fromClass(DashDotIntegration), dashDot: DashDotIntegration,
tdarr: fromClass(TdarrIntegration), tdarr: TdarrIntegration,
proxmox: fromClass(ProxmoxIntegration), proxmox: ProxmoxIntegration,
emby: fromClass(EmbyIntegration), emby: EmbyIntegration,
nextcloud: fromClass(NextcloudIntegration), nextcloud: NextcloudIntegration,
unifiController: fromClass(UnifiControllerIntegration), unifiController: UnifiControllerIntegration,
opnsense: fromClass(OPNsenseIntegration), opnsense: OPNsenseIntegration,
ical: fromClass(ICalIntegration), ical: ICalIntegration,
ntfy: fromClass(NTFYIntegration), ntfy: NTFYIntegration,
gotify: fromClass(GotifyIntegration), gotify: GotifyIntegration,
mock: fromClass(MockIntegration), mock: MockIntegration,
truenas: fromClass(TrueNasIntegration), truenas: TrueNasIntegration,
synology: fromClass(SynologyIntegration), unraid: UnraidIntegration,
unraid: fromClass(UnraidIntegration), coolify: CoolifyIntegration,
coolify: fromClass(CoolifyIntegration), tracearr: TracearrIntegration,
tracearr: fromClass(TracearrIntegration), glances: GlancesIntegration,
glances: fromClass(GlancesIntegration), immich: ImmichIntegration,
immich: fromClass(ImmichIntegration), paperlessNgx: PaperlessNgxIntegration,
paperlessNgx: fromClass(PaperlessNgxIntegration), speedtestTracker: SpeedtestTrackerIntegration,
speedtestTracker: fromClass(SpeedtestTrackerIntegration), audiobookshelf: AudiobookshelfIntegration,
audiobookshelf: fromClass(AudiobookshelfIntegration), navidrome: NavidromeIntegration,
navidrome: fromClass(NavidromeIntegration), umami: UmamiIntegration,
umami: fromClass(UmamiIntegration), gluetun: GluetunIntegration,
gluetun: fromClass(GluetunIntegration), archiveTeamWarrior: ArchiveTeamWarriorIntegration,
archiveTeamWarrior: fromClass(ArchiveTeamWarriorIntegration), uptimeKuma: UptimeKumaIntegration,
uptimeKuma: fromClass(UptimeKumaIntegration), peaNut: PeaNutIntegration,
peaNut: fromClass(PeaNutIntegration), beszel: BeszelIntegration,
beszel: fromClass(BeszelIntegration), } satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
} satisfies Record<IntegrationKind, IntegrationCreator>;
type IntegrationInstanceByKind = { type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
[TKind in keyof typeof integrationCreators]: Awaited<ReturnType<(typeof integrationCreators)[TKind]["create"]>>; [kind in TKind]: (typeof integrationCreators)[kind] extends [(input: IntegrationInput) => Promise<Integration>]
}; ? Awaited<ReturnType<(typeof integrationCreators)[kind][0]>>
: (typeof integrationCreators)[kind] extends IntegrationInstance
? InstanceType<(typeof integrationCreators)[kind]>
: never;
}[TKind];
@@ -46,7 +46,7 @@ const gluetunServerSelectionSchema = z.object({
isps: z.array(z.string()).nullable(), isps: z.array(z.string()).nullable(),
names: z.array(z.string()).nullable(), names: z.array(z.string()).nullable(),
numbers: z.array(z.number()).nullable(), numbers: z.array(z.number()).nullable(),
hostnames: z.array(z.string()).nullable(), hostnames: z.array(z.string()),
owned_only: z.boolean(), owned_only: z.boolean(),
free_only: z.boolean(), free_only: z.boolean(),
premium_only: z.boolean(), premium_only: z.boolean(),
@@ -65,7 +65,7 @@ const gluetunPortForwardingSchema = z.object({
status_file_path: z.string(), status_file_path: z.string(),
up_command: z.string(), up_command: z.string(),
down_command: z.string(), down_command: z.string(),
listening_port: z.union([z.number(), z.array(z.number())]), listening_port: z.number(),
username: z.string(), username: z.string(),
password: z.string(), password: z.string(),
}); });
@@ -98,7 +98,7 @@ const gluetunOpenvpnSettingsSchema = z.object({
const gluetunWireguardSettingsSchema = z.object({ const gluetunWireguardSettingsSchema = z.object({
private_key: z.string(), private_key: z.string(),
pre_shared_key: z.string(), pre_shared_key: z.string(),
addresses: z.array(z.string()).nullable(), addresses: z.array(z.string()),
allowed_ips: z.array(z.string()), allowed_ips: z.array(z.string()),
interface: z.string(), interface: z.string(),
persistent_keep_alive_interval: z.number(), persistent_keep_alive_interval: z.number(),
@@ -124,126 +124,6 @@ const VPN_SETTINGS_PAYLOAD = {
}, },
}; };
const ALT_VPN_SETTINGS_PAYLOAD = {
type: "openvpn",
provider: {
name: "custom",
server_selection: {
vpn: "openvpn",
countries: null,
categories: null,
regions: null,
cities: null,
isps: null,
names: null,
numbers: null,
hostnames: null,
owned_only: false,
free_only: false,
premium_only: false,
stream_only: false,
multi_hop_only: false,
port_forward_only: false,
secure_core_only: false,
tor_only: false,
openvpn: {
config_file_path: "/gluetun/custom.conf",
protocol: "tcp",
endpoint_ip: "0.0.0.0",
custom_port: 0,
pia_encryption_preset: "",
},
wireguard: {
endpoint_ip: "0.0.0.0",
endpoint_port: 0,
public_key: "",
},
},
port_forwarding: {
enabled: false,
provider: "",
status_file_path: "/tmp/gluetun/forwarded_port",
up_command: "",
down_command: "",
listening_port: [0],
ports_count: 0,
username: "",
password: "",
},
},
amneziawg: {
wireguard: {
private_key: "",
pre_shared_key: "",
addresses: null,
allowed_ips: ["0.0.0.0/0", "::/0"],
interface: "tun0",
persistent_keep_alive_interval: 0,
mtu: 0,
implementation: "userspace",
},
junk_packet_count: 0,
junk_packet_min: 0,
junk_packet_max: 0,
padding_s1: 0,
padding_s2: 0,
padding_s3: 0,
padding_s4: 0,
header_h1: "",
header_h2: "",
header_h3: "",
header_h4: "",
init_packet_i1: "",
init_packet_i2: "",
init_packet_i3: "",
init_packet_i4: "",
init_packet_i5: "",
},
openvpn: {
version: "2.6",
user: "",
password: "",
config_file_path: "/gluetun/custom.conf",
ciphers: null,
auth: "",
cert: "",
key: "",
encrypted_key: "",
key_passphrase: "",
pia_encryption_preset: "",
mssfix: 0,
interface: "tun0",
process_user: "nonrootuser",
verbosity: 1,
flags: null,
},
wireguard: {
private_key: "",
pre_shared_key: "",
addresses: null,
allowed_ips: ["0.0.0.0/0", "::/0"],
interface: "tun0",
persistent_keep_alive_interval: 0,
mtu: 0,
implementation: "auto",
},
pmtud: {
icmp_addresses: ["1.1.1.1", "8.8.8.8"],
tcp_addresses: [
"1.1.1.1:443",
"8.8.8.8:443",
"1.1.1.1:53",
"8.8.8.8:53",
"[2606:4700:4700::1111]:53",
"[2001:4860:4860::8888]:53",
"[2606:4700:4700::1111]:443",
"[2001:4860:4860::8888]:443",
],
},
up_command: "",
down_command: "",
};
const jsonResponse = (body: unknown, init: ResponseInit = { status: 200 }): FetchResponse => const jsonResponse = (body: unknown, init: ResponseInit = { status: 200 }): FetchResponse =>
new Response(JSON.stringify(body), { new Response(JSON.stringify(body), {
...init, ...init,
@@ -488,54 +368,4 @@ describe("gluetun schemas", () => {
test("vpn settings schema rejects a non-string type", () => { test("vpn settings schema rejects a non-string type", () => {
expect(() => gluetunVpnSettingsSchema.parse({ ...VPN_SETTINGS_PAYLOAD, type: 1 })).toThrow(); expect(() => gluetunVpnSettingsSchema.parse({ ...VPN_SETTINGS_PAYLOAD, type: 1 })).toThrow();
}); });
test("vpn settings schema parses the live gluetun-latest custom/OpenVPN payload", () => {
const parsed = gluetunVpnSettingsSchema.parse(ALT_VPN_SETTINGS_PAYLOAD);
expect(parsed.type).toBe("openvpn");
expect(parsed.provider.name).toBe("custom");
});
test("vpn settings schema accepts null server-selection hostnames", () => {
const payload = {
...VPN_SETTINGS_PAYLOAD,
provider: {
...VPN_SETTINGS_PAYLOAD.provider,
server_selection: {
...VPN_SETTINGS_PAYLOAD.provider.server_selection,
hostnames: null,
},
},
};
expect(() => gluetunVpnSettingsSchema.parse(payload)).not.toThrow();
});
test("vpn settings schema accepts listening_port as a number or a list", () => {
const asList = {
...VPN_SETTINGS_PAYLOAD,
provider: {
...VPN_SETTINGS_PAYLOAD.provider,
port_forwarding: {
...VPN_SETTINGS_PAYLOAD.provider.port_forwarding,
listening_port: [0],
},
},
};
// The happy-path fixture already exercises the scalar form (listening_port: 0).
expect(() => gluetunVpnSettingsSchema.parse(VPN_SETTINGS_PAYLOAD)).not.toThrow();
expect(() => gluetunVpnSettingsSchema.parse(asList)).not.toThrow();
});
test("vpn settings schema accepts null wireguard addresses", () => {
const payload = {
...VPN_SETTINGS_PAYLOAD,
wireguard: {
...VPN_SETTINGS_PAYLOAD.wireguard,
addresses: null,
},
};
expect(() => gluetunVpnSettingsSchema.parse(payload)).not.toThrow();
});
}); });
-1
View File
@@ -29,7 +29,6 @@ export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
export { PlexIntegration } from "./plex/plex-integration"; export { PlexIntegration } from "./plex/plex-integration";
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration"; export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
export { TrueNasIntegration } from "./truenas/truenas-integration"; export { TrueNasIntegration } from "./truenas/truenas-integration";
export { SynologyIntegration } from "./synology/synology-integration";
export { UnraidIntegration } from "./unraid/unraid-integration"; export { UnraidIntegration } from "./unraid/unraid-integration";
export { OPNsenseIntegration } from "./opnsense/opnsense-integration"; export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
export { ICalIntegration } from "./ical/ical-integration"; export { ICalIntegration } from "./ical/ical-integration";
@@ -1,411 +0,0 @@
import type { Headers } from "undici";
import type { z } from "zod";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { SessionStore } from "../base/session-store";
import {
AUTH_API_NAME,
INFO_API_NAME,
REQUEST_TIMEOUT_MS,
SESSION_ERROR_CODES,
synologyApiInfoResponseSchema,
synologyAuthResponseSchema,
synologyEnvelopeSchema,
synologyLegacyStorageDataSchema,
synologySmartInfoDataSchema,
synologyStorageLoadInfoDataSchema,
synologyStorageV2DataSchema,
synologySystemInfoDataSchema,
synologySystemStatusDataSchema,
synologyUpgradeCheckDataSchema,
synologyUpgradeStatusDataSchema,
synologyUtilizationDataSchema,
type SynologyDiskRecord,
type SynologyVolumeRecord,
} from "./synology-types";
const logger = createLogger({ module: "synologyClient" });
const AUTH_SESSION_NAME = "DownloadStation";
type ApiDefinition = {
cgiPath: string;
maxVersion: number;
};
type StoredSession = {
cookieHeader: string;
};
type SynologyClientOptions = {
integrationId: string;
baseUrl: string;
username: string;
password: string;
sessionStore: SessionStore<StoredSession>;
};
const apiDefinitionCache = new Map<string, ApiDefinition>();
export class SynologyClient {
private readonly integrationId: string;
private readonly baseUrl: string;
private readonly username: string;
private readonly password: string;
private readonly sessionStore: SessionStore<StoredSession>;
constructor(options: SynologyClientOptions) {
this.integrationId = options.integrationId;
this.baseUrl = options.baseUrl.replace(/\/$/, "");
this.username = options.username;
this.password = options.password;
this.sessionStore = options.sessionStore;
}
public async getSystemInfoAsync() {
const response = await this.requestAsync("SYNO.Core.System", { method: "info" });
return synologySystemInfoDataSchema.parse(response.data);
}
public async getUtilizationAsync() {
const response = await this.requestAsync("SYNO.Core.System.Utilization", { method: "get" });
return synologyUtilizationDataSchema.parse(response.data);
}
public async getStorageVolumesAsync(): Promise<SynologyVolumeRecord[]> {
try {
const storageV2Response = await this.requestAsync("SYNO.Core.System", {
method: "info",
extraParams: { type: "storage_v2" },
});
const storageV2 = synologyStorageV2DataSchema.parse(storageV2Response.data);
const volumesFromV2 = this.mapStorageV2Volumes(storageV2);
if (volumesFromV2.length > 0) {
return volumesFromV2;
}
} catch (error) {
logger.debug("storage_v2 unavailable, falling back to legacy storage info", {
integrationId: this.integrationId,
error,
});
}
const legacyResponse = await this.requestAsync("SYNO.Core.System", {
method: "info",
extraParams: { type: "storage" },
});
const legacyStorage = synologyLegacyStorageDataSchema.parse(legacyResponse.data);
return this.mapLegacyVolumes(legacyStorage.vol_info ?? []);
}
public async getStorageLoadInfoAsync() {
const response = await this.requestOptionalAsync("SYNO.Storage.CGI.Storage", { method: "load_info" });
if (!response) {
return null;
}
return synologyStorageLoadInfoDataSchema.parse(response.data);
}
public async getSmartInfoAsync() {
const response = await this.requestOptionalAsync("SYNO.Storage.CGI.Smart", { method: "get_smart_info" });
if (!response) {
return null;
}
return synologySmartInfoDataSchema.parse(response.data);
}
public async getSystemStatusAsync() {
const response = await this.requestOptionalAsync("SYNO.Core.System.Status", { method: "get" });
if (!response) {
return null;
}
return synologySystemStatusDataSchema.parse(response.data);
}
public async getUpgradeStatusAsync() {
const response = await this.requestOptionalAsync("SYNO.Core.Upgrade", { method: "status" });
if (!response) {
return null;
}
return synologyUpgradeStatusDataSchema.parse(response.data);
}
public async getUpgradeCheckAsync() {
const response = await this.requestOptionalAsync("SYNO.Core.Upgrade.Server", { method: "check" });
if (!response) {
return null;
}
return synologyUpgradeCheckDataSchema.parse(response.data);
}
public async testConnectionAsync() {
await this.getSystemInfoAsync();
}
private mapLegacyVolumes(volumes: z.infer<typeof synologyLegacyStorageDataSchema>["vol_info"]) {
return (volumes ?? [])
.map((volume) => this.mapVolumeRecord(volume.name, volume.used_size, volume.total_size, volume.status))
.filter((volume): volume is SynologyVolumeRecord => volume !== null);
}
private mapStorageV2Volumes(storage: z.infer<typeof synologyStorageV2DataSchema>) {
return (storage.volumes ?? [])
.map((volume) => {
const name = volume.id ?? volume.vol_path ?? volume.display_name;
if (!name) {
return null;
}
return this.mapVolumeRecord(
name,
volume.size?.used,
volume.size?.total,
volume.status,
volume.display_name,
);
})
.filter((volume): volume is SynologyVolumeRecord => volume !== null);
}
private mapVolumeRecord(
name: string,
usedValue: number | string | undefined,
totalValue: number | string | undefined,
status: string | undefined,
displayName?: string,
): SynologyVolumeRecord | null {
const usedBytes = parseNumericValue(usedValue);
const totalBytes = parseNumericValue(totalValue);
if (usedBytes === null || totalBytes === null || totalBytes <= 0) {
return null;
}
return {
name,
displayName: displayName && displayName !== name ? displayName : undefined,
usedBytes,
totalBytes,
status,
};
}
public mapDisksFromLoadInfo(loadInfo: z.infer<typeof synologyStorageLoadInfoDataSchema>): SynologyDiskRecord[] {
return (loadInfo.disks ?? []).map((disk) => ({
identifier: disk.id ?? disk.name ?? disk.display_name ?? "unknown-disk",
name: disk.display_name ?? disk.name ?? disk.id ?? "unknown-disk",
status: disk.smart_status ?? disk.status,
temperature: parseOptionalNumericValue(disk.temp ?? disk.temperature),
volumeName: disk.volume_id ?? disk.vol_path,
}));
}
public mapDisksFromSmartInfo(smartInfo: z.infer<typeof synologySmartInfoDataSchema>): SynologyDiskRecord[] {
const disks = smartInfo.disks ?? smartInfo.items ?? [];
return disks.map((disk) => ({
identifier: disk.disk_id ?? disk.id ?? disk.name ?? "unknown-disk",
name: disk.name ?? disk.disk_id ?? disk.id ?? "unknown-disk",
status: disk.overall_status ?? disk.status,
temperature: parseOptionalNumericValue(disk.temp ?? disk.temperature),
volumeName: undefined,
}));
}
private async requestOptionalAsync(
apiName: string,
options: { method: string; extraParams?: Record<string, string> },
) {
try {
return await this.requestAsync(apiName, options);
} catch (error) {
logger.debug("Optional Synology API request failed", {
integrationId: this.integrationId,
apiName,
method: options.method,
error,
});
return null;
}
}
private async requestAsync(
apiName: string,
options: { method: string; extraParams?: Record<string, string> },
): Promise<z.infer<typeof synologyEnvelopeSchema>> {
return await this.withAuthenticatedSessionAsync(async (session) => {
const apiDefinition = await this.getApiDefinitionAsync(apiName);
const requestUrl = new URL(`${this.baseUrl}/webapi/${apiDefinition.cgiPath}`);
requestUrl.searchParams.set("api", apiName);
requestUrl.searchParams.set("version", String(apiDefinition.maxVersion));
requestUrl.searchParams.set("method", options.method);
for (const [key, value] of Object.entries(options.extraParams ?? {})) {
requestUrl.searchParams.set(key, value);
}
const response = await fetchWithTrustedCertificatesAsync(requestUrl, {
headers: {
Cookie: session.cookieHeader,
"User-Agent": "Homarr",
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw new ResponseError(response);
}
const payload = synologyEnvelopeSchema.parse(await response.json());
if (!payload.success) {
const errorCode = payload.error?.code;
if (errorCode !== undefined && SESSION_ERROR_CODES.has(errorCode)) {
throw new SynologySessionError(errorCode);
}
throw new ResponseError({
status: 502,
url: requestUrl.toString(),
});
}
return payload;
});
}
private async withAuthenticatedSessionAsync<T>(callback: (session: StoredSession) => Promise<T>): Promise<T> {
const storedSession = await this.sessionStore.getAsync();
if (storedSession) {
try {
return await callback(storedSession);
} catch (error) {
if (!(error instanceof SynologySessionError)) {
throw error;
}
logger.debug("Synology session expired, re-authenticating", {
integrationId: this.integrationId,
errorCode: error.errorCode,
});
await this.sessionStore.clearAsync();
}
}
const session = await this.loginAsync();
await this.sessionStore.setAsync(session);
return await callback(session);
}
private async loginAsync(): Promise<StoredSession> {
const authDefinition = await this.getApiDefinitionAsync(AUTH_API_NAME);
const loginUrl = new URL(`${this.baseUrl}/webapi/${authDefinition.cgiPath}`);
loginUrl.searchParams.set("api", AUTH_API_NAME);
loginUrl.searchParams.set("version", String(authDefinition.maxVersion));
loginUrl.searchParams.set("method", "login");
const safeLoginUrl = loginUrl.toString();
const response = await fetchWithTrustedCertificatesAsync(loginUrl, {
method: "POST",
headers: {
"User-Agent": "Homarr",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
account: this.username,
passwd: this.password,
session: AUTH_SESSION_NAME,
format: "cookie",
}).toString(),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw new ResponseError({ status: response.status, url: safeLoginUrl });
}
const payload = synologyAuthResponseSchema.parse(await response.json());
if (!payload.success) {
throw new ResponseError({
status: 401,
url: safeLoginUrl,
});
}
const cookieHeader = extractCookieHeader(response.headers);
if (!cookieHeader) {
throw new ResponseError({
status: 401,
url: safeLoginUrl,
});
}
return { cookieHeader };
}
private async getApiDefinitionAsync(apiName: string): Promise<ApiDefinition> {
const cacheKey = `${this.integrationId}:${apiName}`;
const cachedDefinition = apiDefinitionCache.get(cacheKey);
if (cachedDefinition) {
return cachedDefinition;
}
const infoUrl = new URL(`${this.baseUrl}/webapi/query.cgi`);
infoUrl.searchParams.set("api", INFO_API_NAME);
infoUrl.searchParams.set("version", "1");
infoUrl.searchParams.set("method", "query");
infoUrl.searchParams.set("query", apiName);
const response = await fetchWithTrustedCertificatesAsync(infoUrl, {
headers: {
"User-Agent": "Homarr",
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
throw new ResponseError(response);
}
const payload = synologyApiInfoResponseSchema.parse(await response.json());
const apiDefinition = payload.data?.[apiName];
if (!payload.success || !apiDefinition) {
throw new ResponseError({
status: 502,
url: infoUrl.toString(),
});
}
const definition = {
cgiPath: apiDefinition.path,
maxVersion: apiDefinition.maxVersion,
};
apiDefinitionCache.set(cacheKey, definition);
return definition;
}
}
class SynologySessionError extends Error {
constructor(public readonly errorCode: number) {
super(`Synology session error ${String(errorCode)}`);
this.name = SynologySessionError.name;
}
}
function extractCookieHeader(headers: Headers): string | null {
const cookies = headers.getSetCookie();
if (cookies.length === 0) {
return null;
}
return cookies.map((cookie) => cookie.split(";")[0]).join("; ");
}
function parseNumericValue(value: number | string | undefined): number | null {
if (value === undefined) {
return null;
}
const parsed = typeof value === "number" ? value : Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseOptionalNumericValue(value: number | string | undefined): number | null {
const parsed = parseNumericValue(value);
return parsed === null ? null : parsed;
}
@@ -1,323 +0,0 @@
import type { z } from "zod";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { HandleIntegrationErrors } from "../base/errors/decorator";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { createSessionStore } from "../base/session-store";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
import { SynologyClient } from "./synology-client";
import type { SynologyDiskRecord, SynologyVolumeRecord } from "./synology-types";
import type { synologySystemInfoDataSchema, synologyUtilizationDataSchema } from "./synology-types";
const logger = createLogger({ module: "synologyIntegration" });
type StoredSession = {
cookieHeader: string;
};
@HandleIntegrationErrors([])
export class SynologyIntegration extends Integration implements ISystemHealthMonitoringIntegration {
private readonly sessionStore = createSessionStore<StoredSession>(this.integration);
private client?: SynologyClient;
protected async testingAsync(_input: IntegrationTestingInput): Promise<TestingResult> {
await this.getClient().testConnectionAsync();
return { success: true };
}
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
const client = this.getClient();
const [systemInfo, utilization, volumes] = await Promise.all([
client.getSystemInfoAsync(),
client.getUtilizationAsync(),
client.getStorageVolumesAsync(),
]);
const [storageLoadInfoResult, smartInfoResult, systemStatusResult, upgradeStatusResult, upgradeCheckResult] =
await Promise.allSettled([
client.getStorageLoadInfoAsync(),
client.getSmartInfoAsync(),
client.getSystemStatusAsync(),
client.getUpgradeStatusAsync(),
client.getUpgradeCheckAsync(),
]);
const storageLoadInfo = getSettledValue(storageLoadInfoResult);
const smartInfo = getSettledValue(smartInfoResult);
const systemStatus = getSettledValue(systemStatusResult);
const upgradeStatus = getSettledValue(upgradeStatusResult);
const upgradeCheck = getSettledValue(upgradeCheckResult);
const diskRecords = mergeDiskRecords(
storageLoadInfo ? client.mapDisksFromLoadInfo(storageLoadInfo) : [],
smartInfo ? client.mapDisksFromSmartInfo(smartInfo) : [],
);
const memory = mapMemoryBytes(utilization);
const network = mapNetworkTraffic(utilization);
const loadAverage = mapLoadAverage(utilization);
const smart = mapVolumeSmartEntries(volumes, diskRecords);
const rebootRequired = Boolean(systemStatus?.reboot_required ?? upgradeStatus?.reboot_required ?? false);
const availablePkgUpdates = upgradeCheck?.update_count ?? (upgradeCheck?.available ? 1 : 0);
return {
version: mapVersion(systemInfo),
cpuModelName: systemInfo.model ?? "Unknown CPU",
cpuUtilization: mapCpuUtilization(utilization),
memUsedInBytes: memory.usedBytes,
memAvailableInBytes: memory.availableBytes,
uptime: mapUptimeSeconds(systemInfo),
network,
loadAverage,
rebootRequired,
availablePkgUpdates,
cpuTemp: mapCpuTemperature(systemInfo),
fileSystem: mapFileSystemEntries(volumes),
smart,
gpu: [],
};
}
public async listStorageVolumesAsync(): Promise<{ value: string; label: string }[]> {
const volumes = await this.getClient().getStorageVolumesAsync();
const { id: integrationId, name: integrationName } = this.integration;
return volumes.map((volume) => ({
value: `${integrationId}:${volume.name}`,
label: `${volume.displayName ?? volume.name} (${integrationName})`,
}));
}
private getClient(): SynologyClient {
return (this.client ??= new SynologyClient({
integrationId: this.integration.id,
baseUrl: this.integration.url,
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
sessionStore: this.sessionStore,
}));
}
}
function getSettledValue<T>(result: PromiseSettledResult<T>): T | null {
if (result.status === "rejected") {
logger.debug("Optional Synology API call failed", { reason: result.reason });
return null;
}
return result.value;
}
function mapVersion(systemInfo: z.infer<typeof synologySystemInfoDataSchema>): string {
if (systemInfo.version_string) {
return systemInfo.version_string;
}
if (systemInfo.firmware_ver) {
return systemInfo.firmware_ver;
}
if (systemInfo.version !== undefined) {
return String(systemInfo.version);
}
return "Unknown";
}
function mapCpuUtilization(utilization: z.infer<typeof synologyUtilizationDataSchema>): number {
const userLoad = parseNumericValue(utilization.cpu?.user_load) ?? 0;
const systemLoad = parseNumericValue(utilization.cpu?.system_load) ?? 0;
const totalLoad = userLoad + systemLoad;
return Math.min(totalLoad, 100);
}
function mapMemoryBytes(utilization: z.infer<typeof synologyUtilizationDataSchema>) {
const memory = utilization.memory;
const totalRealKib = parseNumericValue(memory?.total_real);
const realUsagePercent = parseNumericValue(memory?.real_usage);
// DSM utilization API reports memory sizes in KiB, not bytes.
if (totalRealKib !== null && realUsagePercent !== null) {
const totalBytes = totalRealKib * 1024;
const usedBytes = Math.round((totalBytes * realUsagePercent) / 100);
return {
usedBytes,
availableBytes: Math.max(totalBytes - usedBytes, 0),
};
}
const availableKib = parseNumericValue(memory?.avail_real) ?? 0;
const cachedKib = parseNumericValue(memory?.cached) ?? 0;
const totalKib = totalRealKib ?? availableKib + cachedKib;
const usedKib = Math.max(totalKib - availableKib, 0);
return {
usedBytes: usedKib * 1024,
availableBytes: availableKib * 1024,
};
}
function mapNetworkTraffic(utilization: z.infer<typeof synologyUtilizationDataSchema>) {
const totalNetwork = utilization.network?.find((entry) => entry.device === "total");
if (!totalNetwork) {
return null;
}
const received = parseNumericValue(totalNetwork.rx);
const transmitted = parseNumericValue(totalNetwork.tx);
if (received === null || transmitted === null) {
return null;
}
return {
up: transmitted,
down: received,
};
}
function mapLoadAverage(utilization: z.infer<typeof synologyUtilizationDataSchema>) {
const oneMinute = parseNumericValue(utilization.cpu?.["1min_load"]);
const fiveMinute = parseNumericValue(utilization.cpu?.["5min_load"]);
const fifteenMinute = parseNumericValue(utilization.cpu?.["15min_load"]);
if (oneMinute === null && fiveMinute === null && fifteenMinute === null) {
return null;
}
return {
"1min": oneMinute ?? 0,
"5min": fiveMinute ?? 0,
"15min": fifteenMinute ?? 0,
};
}
function mapUptimeSeconds(systemInfo: z.infer<typeof synologySystemInfoDataSchema>): number {
if (typeof systemInfo.uptime === "number") {
return systemInfo.uptime;
}
if (!systemInfo.up_time) {
return 0;
}
const parts = systemInfo.up_time.split(":").map((part) => Number.parseInt(part, 10));
if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) {
return 0;
}
const [hours = 0, minutes = 0, seconds = 0] = parts;
return hours * 3600 + minutes * 60 + seconds;
}
function mapCpuTemperature(systemInfo: z.infer<typeof synologySystemInfoDataSchema>): number | undefined {
const temperature = parseNumericValue(systemInfo.sys_temp ?? systemInfo.temperature);
return temperature === null ? undefined : temperature;
}
function mapFileSystemEntries(volumes: SynologyVolumeRecord[]) {
return volumes.map((volume) => {
const availableBytes = Math.max(volume.totalBytes - volume.usedBytes, 0);
return {
deviceName: volume.name,
used: `${volume.usedBytes}`,
available: `${availableBytes}`,
percentage: (volume.usedBytes / volume.totalBytes) * 100,
};
});
}
function mapVolumeSmartEntries(volumes: SynologyVolumeRecord[], diskRecords: SynologyDiskRecord[]) {
return volumes.map((volume) => {
const matchingDisks = diskRecords.filter((disk) => diskBelongsToVolume(disk, volume.name));
const temperatures = matchingDisks
.map((disk) => disk.temperature)
.filter((temperature): temperature is number => temperature !== null);
const overallStatus = volume.status ?? pickWorstDiskStatus(matchingDisks);
return {
deviceName: volume.name,
healthy: isHealthyStatus(overallStatus),
overallStatus,
temperature: temperatures.length > 0 ? Math.max(...temperatures) : null,
};
});
}
function mergeDiskRecords(
loadInfoDisks: SynologyDiskRecord[],
smartInfoDisks: SynologyDiskRecord[],
): SynologyDiskRecord[] {
const diskMap = new Map<string, SynologyDiskRecord>();
for (const disk of loadInfoDisks) {
diskMap.set(disk.identifier, disk);
}
for (const disk of smartInfoDisks) {
const existingDisk = diskMap.get(disk.identifier);
diskMap.set(disk.identifier, {
identifier: disk.identifier,
name: existingDisk?.name ?? disk.name,
status: disk.status ?? existingDisk?.status,
temperature: disk.temperature ?? existingDisk?.temperature ?? null,
volumeName: existingDisk?.volumeName ?? disk.volumeName,
});
}
return [...diskMap.values()];
}
function diskBelongsToVolume(disk: SynologyDiskRecord, volumeName: string): boolean {
if (!disk.volumeName) {
return false;
}
return (
disk.volumeName === volumeName ||
disk.volumeName.endsWith(volumeName) ||
volumeName.endsWith(disk.volumeName) ||
disk.volumeName.includes(volumeName) ||
volumeName.includes(disk.volumeName)
);
}
const UNHEALTHY_STATUS_TOKENS = ["critical", "error", "crash", "failed", "fail", "degraded", "warning"] as const;
function getStatusSeverityIndex(status: string): number {
const normalizedStatus = status.toLowerCase();
const index = UNHEALTHY_STATUS_TOKENS.findIndex((token) => normalizedStatus.includes(token));
return index === -1 ? UNHEALTHY_STATUS_TOKENS.length : index;
}
function pickWorstDiskStatus(disks: SynologyDiskRecord[]): string {
const statuses = disks
.map((disk) => disk.status)
.filter((status): status is string => status !== undefined && status.length > 0);
if (statuses.length === 0) {
return "normal";
}
return statuses.reduce((worstStatus, currentStatus) =>
getStatusSeverityIndex(currentStatus) < getStatusSeverityIndex(worstStatus) ? currentStatus : worstStatus,
);
}
function isHealthyStatus(status: string | undefined): boolean {
if (!status) {
return true;
}
const normalizedStatus = status.toLowerCase();
return !["crash", "degraded", "fail", "failed", "warning", "error", "critical"].some((unhealthyToken) =>
normalizedStatus.includes(unhealthyToken),
);
}
function parseNumericValue(value: number | string | undefined): number | null {
if (value === undefined) {
return null;
}
const parsed = typeof value === "number" ? value : Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
@@ -1,191 +0,0 @@
import { z } from "zod";
export const REQUEST_TIMEOUT_MS = 5000;
export const AUTH_API_NAME = "SYNO.API.Auth";
export const INFO_API_NAME = "SYNO.API.Info";
export const SESSION_ERROR_CODES = new Set([106, 107, 119]);
export const synologyEnvelopeSchema = z.object({
success: z.boolean(),
data: z.unknown().optional(),
error: z
.object({
code: z.number(),
})
.optional(),
});
export const synologyApiDefinitionSchema = z.object({
path: z.string(),
maxVersion: z.number(),
});
export const synologyApiInfoResponseSchema = synologyEnvelopeSchema.extend({
data: z.record(z.string(), synologyApiDefinitionSchema).optional(),
});
export const synologyAuthResponseSchema = synologyEnvelopeSchema.extend({
data: z
.object({
sid: z.string().optional(),
})
.optional(),
});
const numericString = z.union([z.number(), z.string()]);
export const synologySystemInfoDataSchema = z
.object({
up_time: z.string().optional(),
uptime: z.number().optional(),
version_string: z.string().optional(),
firmware_ver: z.string().optional(),
version: z.union([z.string(), z.number()]).optional(),
model: z.string().optional(),
sys_temp: numericString.optional(),
temperature: numericString.optional(),
})
.passthrough();
export const synologyVolumeInfoSchema = z.object({
name: z.string(),
used_size: numericString.optional(),
total_size: numericString.optional(),
status: z.string().optional(),
vol_path: z.string().optional(),
});
export const synologyLegacyStorageDataSchema = z.object({
vol_info: z.array(synologyVolumeInfoSchema).optional(),
});
export const synologyStorageV2VolumeSchema = z
.object({
id: z.string().optional(),
vol_path: z.string().optional(),
display_name: z.string().optional(),
size: z
.object({
total: z.union([z.number(), z.string()]).optional(),
used: z.union([z.number(), z.string()]).optional(),
})
.optional(),
status: z.string().optional(),
})
.passthrough();
export const synologyStorageV2DataSchema = z.object({
volumes: z.array(synologyStorageV2VolumeSchema).optional(),
});
export const synologyUtilizationCpuSchema = z.object({
user_load: numericString.optional(),
system_load: numericString.optional(),
other_load: numericString.optional(),
"1min_load": numericString.optional(),
"5min_load": numericString.optional(),
"15min_load": numericString.optional(),
});
export const synologyUtilizationMemorySchema = z.object({
real_usage: numericString.optional(),
avail_real: numericString.optional(),
cached: numericString.optional(),
total_real: numericString.optional(),
});
export const synologyUtilizationNetworkSchema = z.object({
device: z.string(),
rx: numericString.optional(),
tx: numericString.optional(),
});
export const synologyUtilizationDataSchema = z.object({
cpu: synologyUtilizationCpuSchema.optional(),
memory: synologyUtilizationMemorySchema.optional(),
network: z.array(synologyUtilizationNetworkSchema).optional(),
});
export const synologyStorageDiskSchema = z
.object({
id: z.string().optional(),
name: z.string().optional(),
display_name: z.string().optional(),
status: z.string().optional(),
temp: numericString.optional(),
temperature: numericString.optional(),
smart_status: z.string().optional(),
volume_id: z.string().optional(),
vol_path: z.string().optional(),
})
.passthrough();
export const synologyStorageLoadInfoDataSchema = z
.object({
disks: z.array(synologyStorageDiskSchema).optional(),
volumes: z.array(synologyStorageV2VolumeSchema).optional(),
overview_data: z
.object({
volumes: z.array(synologyVolumeInfoSchema).optional(),
})
.optional(),
})
.passthrough();
export const synologySmartDiskSchema = z
.object({
id: z.string().optional(),
disk_id: z.string().optional(),
name: z.string().optional(),
status: z.string().optional(),
temp: numericString.optional(),
temperature: numericString.optional(),
overall_status: z.string().optional(),
})
.passthrough();
export const synologySmartInfoDataSchema = z
.object({
disks: z.array(synologySmartDiskSchema).optional(),
items: z.array(synologySmartDiskSchema).optional(),
})
.passthrough();
export const synologySystemStatusDataSchema = z
.object({
reboot_required: z.boolean().optional(),
system_need_repair: z.boolean().optional(),
})
.passthrough();
export const synologyUpgradeStatusDataSchema = z
.object({
reboot_required: z.boolean().optional(),
upgrade_ready: z.boolean().optional(),
})
.passthrough();
export const synologyUpgradeCheckDataSchema = z
.object({
available: z.boolean().optional(),
update_count: z.number().optional(),
})
.passthrough();
export type SynologyVolumeRecord = {
name: string;
displayName?: string;
usedBytes: number;
totalBytes: number;
status: string | undefined;
};
export type SynologyDiskRecord = {
identifier: string;
name: string;
status: string | undefined;
temperature: number | null;
volumeName: string | undefined;
};
@@ -1,585 +0,0 @@
// @vitest-environment node
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.CI = "true";
process.env.NODE_ENV = "test";
process.env.SECRET_ENCRYPTION_KEY = "0".repeat(64);
});
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock("../../base/session-store", () => ({
createSessionStore: () => ({
getAsync: vi.fn().mockResolvedValue(null),
setAsync: vi.fn().mockResolvedValue(undefined),
clearAsync: vi.fn().mockResolvedValue(undefined),
}),
}));
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { IntegrationResponseError } from "../../base/errors/http/integration-response-error";
import type { IntegrationSecret } from "../../base/types";
import { SynologyIntegration } from "../synology-integration";
const TEST_URL = "https://synology.example.com:5001";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const createIntegration = (secrets: IntegrationSecret[] = []) =>
new SynologyIntegration({
id: "test-synology",
name: "Test Synology",
url: TEST_URL,
externalUrl: null,
decryptedSecrets: secrets.length
? secrets
: [
{ kind: "username", value: "homarr" },
{ kind: "password", value: "secret-password" },
],
});
const apiDefinitions = {
"SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 },
"SYNO.API.Info": { path: "query.cgi", maxVersion: 1 },
"SYNO.Core.System": { path: "entry.cgi", maxVersion: 3 },
"SYNO.Core.System.Utilization": { path: "entry.cgi", maxVersion: 1 },
"SYNO.Storage.CGI.Storage": { path: "entry.cgi", maxVersion: 1 },
"SYNO.Storage.CGI.Smart": { path: "entry.cgi", maxVersion: 1 },
"SYNO.Core.System.Status": { path: "entry.cgi", maxVersion: 1 },
"SYNO.Core.Upgrade": { path: "entry.cgi", maxVersion: 1 },
"SYNO.Core.Upgrade.Server": { path: "entry.cgi", maxVersion: 1 },
};
const systemInfoResponse = {
success: true as const,
data: {
up_time: "48:30:15",
version_string: "DSM 7.3-12345",
model: "DS920+",
sys_temp: 42,
},
};
const utilizationResponse = {
success: true,
data: {
cpu: {
user_load: 12,
system_load: 8,
"1min_load": 10,
"5min_load": 14,
"15min_load": 9,
},
memory: {
real_usage: 35,
total_real: 8 * 1024 * 1024,
avail_real: Math.round(8 * 1024 * 1024 * 0.65),
cached: 900_000,
},
network: [
{ device: "total", rx: 4096, tx: 8192 },
{ device: "eth0", rx: 4096, tx: 8192 },
],
},
};
const storageV2Response = {
success: true,
data: {
volumes: [
{
id: "volume_1",
display_name: "Data Volume 1",
size: { total: 10_000_000_000, used: 4_000_000_000 },
status: "normal",
},
{
id: "volume_2",
display_name: "Data Volume 2",
size: { total: 20_000_000_000, used: 5_000_000_000 },
status: "degraded",
},
],
},
};
const storageLoadInfoResponse = {
success: true,
data: {
disks: [
{
id: "sda",
display_name: "Drive 1",
temp: 38,
status: "normal",
volume_id: "volume_1",
},
{
id: "sdb",
display_name: "Drive 2",
temp: 44,
status: "normal",
volume_id: "volume_2",
},
],
},
};
const smartInfoResponse = {
success: true,
data: {
disks: [
{
disk_id: "sda",
name: "Drive 1",
overall_status: "normal",
temp: 39,
},
],
},
};
const systemStatusResponse = {
success: true,
data: {
reboot_required: true,
},
};
const upgradeStatusResponse = {
success: true,
data: {
reboot_required: false,
},
};
const upgradeCheckResponse = {
success: true,
data: {
available: true,
update_count: 2,
},
};
const toUrlString = (url: unknown): string => String(url);
const getApiFromUrl = (url: string) => {
const parsedUrl = new URL(url);
return parsedUrl.searchParams.get("api");
};
const getMethodFromUrl = (url: string) => {
const parsedUrl = new URL(url);
return parsedUrl.searchParams.get("method");
};
const getTypeFromUrl = (url: string) => {
const parsedUrl = new URL(url);
return parsedUrl.searchParams.get("type");
};
const mockSuccessfulFlow = (options?: {
skipStorageV2?: boolean;
skipOptional?: boolean;
systemInfo?: {
success: boolean;
data: Record<string, unknown>;
};
utilization?: typeof utilizationResponse;
storageV2?: typeof storageV2Response;
storageLoadInfo?: typeof storageLoadInfoResponse;
}) => {
mockFetch.mockImplementation((url) => {
const urlString = toUrlString(url);
const apiName = getApiFromUrl(urlString);
const method = getMethodFromUrl(urlString);
const type = getTypeFromUrl(urlString);
if (apiName === "SYNO.API.Info" && method === "query") {
const query = new URL(urlString).searchParams.get("query") ?? "SYNO.API.Auth";
const definition = apiDefinitions[query as keyof typeof apiDefinitions];
return Promise.resolve(
new Response(JSON.stringify({ success: true, data: { [query]: definition } }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.API.Auth" && method === "login") {
return Promise.resolve(
new Response(JSON.stringify({ success: true, data: { sid: "test-session-id" } }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": "id=test-session-id; Path=/; HttpOnly",
},
}),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Core.System" && method === "info" && type === "storage_v2") {
if (options?.skipStorageV2) {
return Promise.resolve(
new Response(JSON.stringify({ success: false, error: { code: 103 } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(new Response(JSON.stringify(options?.storageV2 ?? storageV2Response), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
if (apiName === "SYNO.Core.System" && method === "info" && type === "storage") {
return Promise.resolve(
new Response(
JSON.stringify({
success: true,
data: {
vol_info: [
{
name: "volume_1",
used_size: 4_000_000_000,
total_size: 10_000_000_000,
status: "normal",
},
],
},
}),
{ status: 200 },
),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Core.System" && method === "info" && !type) {
return Promise.resolve(
new Response(JSON.stringify(options?.systemInfo ?? systemInfoResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Core.System.Utilization" && method === "get") {
return Promise.resolve(
new Response(JSON.stringify(options?.utilization ?? utilizationResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (options?.skipOptional) {
return Promise.resolve(
new Response(JSON.stringify({ success: false, error: { code: 105 } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Storage.CGI.Storage" && method === "load_info") {
return Promise.resolve(
new Response(JSON.stringify(options?.storageLoadInfo ?? storageLoadInfoResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Storage.CGI.Smart" && method === "get_smart_info") {
return Promise.resolve(new Response(JSON.stringify(smartInfoResponse), { status: 200 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
}
if (apiName === "SYNO.Core.System.Status" && method === "get") {
return Promise.resolve(
new Response(JSON.stringify(systemStatusResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Core.Upgrade" && method === "status") {
return Promise.resolve(
new Response(JSON.stringify(upgradeStatusResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.Core.Upgrade.Server" && method === "check") {
return Promise.resolve(
new Response(JSON.stringify(upgradeCheckResponse), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(
new Response(JSON.stringify({ success: false, error: { code: 102 } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
});
};
beforeEach(() => {
mockFetch.mockReset();
});
describe("SynologyIntegration.getSystemInfoAsync", () => {
test("maps all supported health monitoring fields", async () => {
mockSuccessfulFlow();
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.version).toBe("DSM 7.3-12345");
expect(result.cpuModelName).toBe("DS920+");
expect(result.cpuUtilization).toBe(20);
expect(result.memUsedInBytes).toBe(Math.round((8 * 1024 * 1024 * 1024 * 35) / 100));
expect(result.memAvailableInBytes).toBe(8 * 1024 * 1024 * 1024 - Math.round((8 * 1024 * 1024 * 1024 * 35) / 100));
expect(result.uptime).toBe(48 * 3600 + 30 * 60 + 15);
expect(result.cpuTemp).toBe(42);
expect(result.network).toEqual({ up: 8192, down: 4096 });
expect(result.loadAverage).toEqual({ "1min": 10, "5min": 14, "15min": 9 });
expect(result.rebootRequired).toBe(true);
expect(result.availablePkgUpdates).toBe(2);
expect(result.gpu).toEqual([]);
expect(result.fileSystem).toHaveLength(2);
expect(result.fileSystem[0]).toEqual({
deviceName: "volume_1",
used: "4000000000",
available: "6000000000",
percentage: 40,
});
expect(result.smart).toEqual([
{
deviceName: "volume_1",
healthy: true,
overallStatus: "normal",
temperature: 39,
},
{
deviceName: "volume_2",
healthy: false,
overallStatus: "degraded",
temperature: 44,
},
]);
});
test("picks worst disk status when volume status is missing", async () => {
mockSuccessfulFlow({
storageV2: {
success: true,
data: {
volumes: [
{
id: "volume_1",
display_name: "Data Volume 1",
size: { total: 10_000_000_000, used: 4_000_000_000 },
},
],
},
},
storageLoadInfo: {
success: true,
data: {
disks: [
{
id: "sda",
display_name: "Drive 1",
temp: 38,
status: "normal",
volume_id: "volume_1",
},
{
id: "sdb",
display_name: "Drive 2",
temp: 40,
status: "degraded",
volume_id: "volume_1",
},
],
},
},
});
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.smart).toEqual([
{
deviceName: "volume_1",
healthy: false,
overallStatus: "degraded",
temperature: 40,
},
]);
});
test("falls back to legacy storage info when storage_v2 is unavailable", async () => {
mockSuccessfulFlow({ skipStorageV2: true });
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toHaveLength(1);
expect(result.fileSystem[0]?.deviceName).toBe("volume_1");
});
test("continues when optional tier-2 calls fail", async () => {
mockSuccessfulFlow({ skipOptional: true });
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.cpuUtilization).toBe(20);
expect(result.rebootRequired).toBe(false);
expect(result.availablePkgUpdates).toBe(0);
expect(result.smart.every((entry) => entry.temperature === null)).toBe(true);
});
test("converts memory values from KiB to bytes", async () => {
mockSuccessfulFlow({
utilization: {
...utilizationResponse,
data: {
...utilizationResponse.data,
memory: {
real_usage: 17,
total_real: 16_351_532,
avail_real: 426_568,
},
},
},
});
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
const totalBytes = 16_351_532 * 1024;
expect(result.memUsedInBytes).toBe(Math.round((totalBytes * 17) / 100));
expect(result.memAvailableInBytes).toBe(totalBytes - Math.round((totalBytes * 17) / 100));
});
test("caps cpu utilization at 100 when user and system load exceed 100", async () => {
mockSuccessfulFlow({
utilization: {
...utilizationResponse,
data: {
...utilizationResponse.data,
cpu: {
...utilizationResponse.data.cpu,
user_load: 80,
system_load: 30,
},
},
},
});
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.cpuUtilization).toBe(100);
});
test("returns undefined cpu temperature when sensor is absent", async () => {
mockSuccessfulFlow({
systemInfo: {
success: true,
data: {
up_time: "01:00:00",
version_string: "DSM 7.3-12345",
model: "DS920+",
},
},
});
const integration = createIntegration();
const result = await integration.getSystemInfoAsync();
expect(result.cpuTemp).toBeUndefined();
});
});
describe("SynologyIntegration.listStorageVolumesAsync", () => {
test("returns auto-detected volume names", async () => {
mockSuccessfulFlow();
const integration = createIntegration();
const volumes = await integration.listStorageVolumesAsync();
expect(volumes).toEqual([
{ value: "test-synology:volume_1", label: "Data Volume 1 (Test Synology)" },
{ value: "test-synology:volume_2", label: "Data Volume 2 (Test Synology)" },
]);
});
});
describe("SynologyIntegration authentication", () => {
test("rejects two-factor authentication during login", async () => {
mockFetch.mockImplementation((url) => {
const urlString = toUrlString(url);
const apiName = getApiFromUrl(urlString);
const method = getMethodFromUrl(urlString);
if (apiName === "SYNO.API.Info" && method === "query") {
const query = new URL(urlString).searchParams.get("query") ?? "SYNO.API.Auth";
const definition = apiDefinitions[query as keyof typeof apiDefinitions];
return Promise.resolve(
new Response(JSON.stringify({ success: true, data: { [query]: definition } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.API.Auth" && method === "login") {
return Promise.resolve(
new Response(JSON.stringify({ success: false, error: { code: 403 } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(new Response("{}", { status: 500 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
const integration = createIntegration();
await expect(integration.getSystemInfoAsync()).rejects.toThrow();
});
test("does not leak credentials in login error URLs", async () => {
mockFetch.mockImplementation((url, init) => {
const urlString = toUrlString(url);
const apiName = getApiFromUrl(urlString);
const method = getMethodFromUrl(urlString);
if (apiName === "SYNO.API.Info" && method === "query") {
const query = new URL(urlString).searchParams.get("query") ?? "SYNO.API.Auth";
const definition = apiDefinitions[query as keyof typeof apiDefinitions];
return Promise.resolve(
new Response(JSON.stringify({ success: true, data: { [query]: definition } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (apiName === "SYNO.API.Auth" && method === "login") {
expect(init?.method).toBe("POST");
expect(init?.body).toContain("account=homarr");
expect(init?.body).toContain("passwd=secret-password");
expect(urlString).not.toContain("account=");
expect(urlString).not.toContain("passwd=");
return Promise.resolve(
new Response(JSON.stringify({ success: false, error: { code: 403 } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
return Promise.resolve(new Response("{}", { status: 500 })) as unknown as ReturnType<
typeof fetchWithTrustedCertificatesAsync
>;
});
const integration = createIntegration();
try {
await integration.getSystemInfoAsync();
expect.unreachable("Expected login to fail");
} catch (error) {
expect(error).toBeInstanceOf(IntegrationResponseError);
const responseError = (error as IntegrationResponseError).cause;
expect(responseError).toBeInstanceOf(ResponseError);
expect(responseError.url).not.toContain("secret-password");
expect(responseError.url).not.toContain("passwd=");
expect(responseError.url).not.toContain("account=homarr");
}
});
});
@@ -165,7 +165,6 @@ const optionMapping: OptionMapping = {
"sectionIndicatorColor" in oldOptions ? oldOptions.sectionIndicatorColor : undefined, "sectionIndicatorColor" in oldOptions ? oldOptions.sectionIndicatorColor : undefined,
showUptime: () => undefined, showUptime: () => undefined,
gpu: () => undefined, gpu: () => undefined,
visibleStorageVolumes: () => undefined,
visibleClusterSections: (oldOptions) => { visibleClusterSections: (oldOptions) => {
if (!("showNode" in oldOptions)) return undefined; if (!("showNode" in oldOptions)) return undefined;
-10
View File
@@ -2284,11 +2284,6 @@
"fileSystem": { "fileSystem": {
"label": "Show Filesystem Info" "label": "Show Filesystem Info"
}, },
"visibleStorageVolumes": {
"label": "Visible storage volumes",
"description": "Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.",
"placeholder": "Select storage volumes"
},
"defaultTab": { "defaultTab": {
"label": "Default tab" "label": "Default tab"
}, },
@@ -3543,11 +3538,6 @@
}, },
"showBackgroundBar": { "showBackgroundBar": {
"label": "Show background usage bar" "label": "Show background usage bar"
},
"visibleStorageVolumes": {
"label": "Visible storage volumes",
"description": "Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.",
"placeholder": "Select storage volumes"
} }
}, },
"status": { "status": {
+1 -1
View File
@@ -11,7 +11,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
itemId?: string; itemId?: string;
} }
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string; type UseWidgetInputTranslationReturnType = (key: "label" | "description") => string;
/** /**
* Short description why as and unknown convertions are used below: * Short description why as and unknown convertions are used below:
-2
View File
@@ -17,7 +17,6 @@ import { WidgetUmamiEventInput } from "./widget-umami-event-input";
import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input"; import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input";
import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input"; import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input";
import { WidgetIntegrationSelectInput } from "./widget-integration-select-input"; import { WidgetIntegrationSelectInput } from "./widget-integration-select-input";
import { WidgetIntegrationMultiSelectInput } from "./widget-integration-multi-select-input";
const mapping = { const mapping = {
anchorNote: WidgetAnchorNoteInput, anchorNote: WidgetAnchorNoteInput,
@@ -38,7 +37,6 @@ const mapping = {
umamiWebsite: WidgetUmamiWebsiteInput, umamiWebsite: WidgetUmamiWebsiteInput,
customWidgetSelect: WidgetCustomWidgetSelectInput, customWidgetSelect: WidgetCustomWidgetSelectInput,
integrationSelect: WidgetIntegrationSelectInput, integrationSelect: WidgetIntegrationSelectInput,
integrationMultiSelect: WidgetIntegrationMultiSelectInput,
} satisfies Record<WidgetOptionType, unknown>; } satisfies Record<WidgetOptionType, unknown>;
export const getInputForType = <TType extends WidgetOptionType>(type: TType) => { export const getInputForType = <TType extends WidgetOptionType>(type: TType) => {
@@ -1,60 +0,0 @@
"use client";
import { MultiSelect, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
const useIntegrationSelectTranslation = () => useScopedI18n("widget.integrationSelect");
export const WidgetIntegrationMultiSelectInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"integrationMultiSelect">) => {
const t = useIntegrationSelectTranslation();
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const integrationIds = form.values.integrationIds as string[];
const inputProps = form.getInputProps(`options.${property}`);
const selectedValues = (inputProps.value as string[] | undefined) ?? [];
const { data: selectData = [], isPending, isError } = options.useOptions(integrationIds);
const data = [
...selectData,
...selectedValues
.filter((selectedValue) => !selectData.some((option) => option.value === selectedValue))
.map((selectedValue) => ({ value: selectedValue, label: selectedValue })),
];
if (integrationIds.length === 0) {
return (
<Text size="sm" c="dimmed">
{t("configureIntegrationFirst")}
</Text>
);
}
const description = options.withDescription ? tInput("description") : undefined;
const placeholder = isPending ? t("loading") : tInput("placeholder");
const loadError = isError ? t("loadError") : undefined;
return (
<MultiSelect
{...inputProps}
label={tInput("label")}
description={description}
placeholder={placeholder}
searchable
clearable
nothingFoundMessage={t("noResults")}
data={data}
disabled={isError}
error={loadError ?? inputProps.error}
/>
);
};
@@ -1,146 +0,0 @@
import { describe, expect, test } from "vitest";
import {
filterStorageVolumes,
normalizeStorageDeviceName,
toScopedStorageVolumeValue,
} from "./filter-storage-volumes";
import { matchFileSystemAndSmart } from "./health-monitoring/system-health";
describe("normalizeStorageDeviceName", () => {
test("strips partition suffixes from block device paths", () => {
expect(normalizeStorageDeviceName("/dev/sda1")).toBe("/dev/sda");
expect(normalizeStorageDeviceName("/dev/sda")).toBe("/dev/sda");
});
test("preserves Synology-style volume names", () => {
expect(normalizeStorageDeviceName("volume_1")).toBe("volume_1");
expect(normalizeStorageDeviceName("volume_2")).toBe("volume_2");
});
test("strips NVMe partition suffixes without collapsing the namespace id", () => {
expect(normalizeStorageDeviceName("/dev/nvme0n1")).toBe("/dev/nvme0n1");
expect(normalizeStorageDeviceName("/dev/nvme0n1p2")).toBe("/dev/nvme0n1");
expect(normalizeStorageDeviceName("/dev/nvme0n2p1")).toBe("/dev/nvme0n2");
});
test("does not collapse unrelated md devices", () => {
expect(normalizeStorageDeviceName("/dev/md0")).toBe("/dev/md0");
expect(normalizeStorageDeviceName("/dev/md1")).toBe("/dev/md1");
});
});
describe("toScopedStorageVolumeValue", () => {
test("scopes unscoped volume names to the query integration", () => {
expect(toScopedStorageVolumeValue("nas-a", "volume_1")).toBe("nas-a:volume_1");
});
test("re-scopes already-prefixed values using the query integration", () => {
expect(toScopedStorageVolumeValue("nas-b", "nas-a:volume_1")).toBe("nas-b:volume_1");
});
});
describe("filterStorageVolumes", () => {
const integrationId = "nas-1";
test("matches SMART entries using normalized partition identifiers", () => {
const entries = [
{ deviceName: "/dev/sda1", used: "1", available: "2", percentage: 50 },
{ deviceName: "/dev/sdb1", used: "1", available: "2", percentage: 50 },
];
const smartEntries = [
{ deviceName: "/dev/sda", temperature: 35, overallStatus: "GOOD" },
{ deviceName: "/dev/sdb", temperature: 40, overallStatus: "GOOD" },
];
const visibleVolumes = [`${integrationId}:/dev/sda1`];
expect(filterStorageVolumes(entries, visibleVolumes, integrationId)).toHaveLength(1);
expect(filterStorageVolumes(smartEntries, visibleVolumes, integrationId)).toHaveLength(1);
});
test("keeps exact Synology volume matches", () => {
const entries = [{ deviceName: "volume_1" }, { deviceName: "volume_2" }];
const visibleVolumes = [`${integrationId}:volume_1`];
expect(filterStorageVolumes(entries, visibleVolumes, integrationId)).toEqual([{ deviceName: "volume_1" }]);
});
test("does not apply another integration's scoped selection", () => {
const entries = [{ deviceName: "volume_1" }, { deviceName: "volume_2" }];
const visibleVolumes = ["nas-b:volume_1"];
expect(filterStorageVolumes(entries, visibleVolumes, integrationId)).toEqual([]);
});
test("does not treat different md devices as the same volume", () => {
const entries = [{ deviceName: "/dev/md0" }, { deviceName: "/dev/md1" }];
const visibleVolumes = [`${integrationId}:/dev/md0`];
expect(filterStorageVolumes(entries, visibleVolumes, integrationId)).toEqual([{ deviceName: "/dev/md0" }]);
});
});
describe("matchFileSystemAndSmart", () => {
test("joins partitioned filesystem entries with base SMART device names", () => {
const result = matchFileSystemAndSmart(
[{ deviceName: "/dev/sda1", used: "100", available: "900", percentage: 10 }],
[{ deviceName: "/dev/sda", temperature: 42, overallStatus: "GOOD" }],
);
expect(result).toEqual([
{
deviceName: "/dev/sda",
used: "100",
available: "900",
percentage: 10,
temperature: 42,
overallStatus: "GOOD",
},
]);
});
test("joins Synology volume names without stripping volume suffixes", () => {
const result = matchFileSystemAndSmart(
[{ deviceName: "volume_1", used: "100", available: "900", percentage: 10 }],
[{ deviceName: "volume_1", temperature: 39, overallStatus: "normal" }],
);
expect(result[0]).toMatchObject({
deviceName: "volume_1",
temperature: 39,
overallStatus: "normal",
});
});
test("joins NVMe partitions with their base device SMART data", () => {
const result = matchFileSystemAndSmart(
[{ deviceName: "/dev/nvme0n1p2", used: "100", available: "900", percentage: 10 }],
[{ deviceName: "/dev/nvme0n1", temperature: 41, overallStatus: "GOOD" }],
);
expect(result).toEqual([
{
deviceName: "/dev/nvme0n1",
used: "100",
available: "900",
percentage: 10,
temperature: 41,
overallStatus: "GOOD",
},
]);
});
test("does not join SMART data from a different md device", () => {
const result = matchFileSystemAndSmart(
[{ deviceName: "/dev/md0", used: "100", available: "900", percentage: 10 }],
[{ deviceName: "/dev/md1", temperature: 41, overallStatus: "GOOD" }],
);
expect(result[0]).toMatchObject({
deviceName: "/dev/md0",
temperature: null,
overallStatus: "",
});
});
});
@@ -1,67 +0,0 @@
interface StorageVolumeEntry {
deviceName: string;
}
const partitionSuffixPatterns: ReadonlyArray<{ pattern: RegExp; baseGroupIndex: number }> = [
// SCSI/SATA/VirtIO: /dev/sda1 -> /dev/sda
{ pattern: /^(\/dev\/(?:sd|vd|hd|xvd)[a-z]+)[0-9]+$/, baseGroupIndex: 1 },
// NVMe: /dev/nvme0n1p2 -> /dev/nvme0n1
{ pattern: /^(\/dev\/nvme[0-9]+n[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
// eMMC: /dev/mmcblk0p1 -> /dev/mmcblk0
{ pattern: /^(\/dev\/mmcblk[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
];
export const normalizeStorageDeviceName = (deviceName: string): string => {
for (const { pattern, baseGroupIndex } of partitionSuffixPatterns) {
const match = deviceName.match(pattern);
if (match) {
return match[baseGroupIndex] ?? deviceName;
}
}
return deviceName;
};
export const toScopedStorageVolumeValue = (integrationId: string, value: string): string => {
const separatorIndex = value.indexOf(":");
const volumeName = separatorIndex === -1 ? value : value.slice(separatorIndex + 1);
return `${integrationId}:${volumeName}`;
};
const storageDeviceNamesMatch = (leftDeviceName: string, rightDeviceName: string): boolean => {
return (
leftDeviceName === rightDeviceName ||
normalizeStorageDeviceName(leftDeviceName) === normalizeStorageDeviceName(rightDeviceName)
);
};
const matchesVisibleStorageVolume = (
visibleVolume: string,
integrationId: string,
deviceName: string,
): boolean => {
const separatorIndex = visibleVolume.indexOf(":");
if (separatorIndex === -1) {
return storageDeviceNamesMatch(visibleVolume, deviceName);
}
const visibleIntegrationId = visibleVolume.slice(0, separatorIndex);
const visibleVolumeName = visibleVolume.slice(separatorIndex + 1);
return visibleIntegrationId === integrationId && storageDeviceNamesMatch(visibleVolumeName, deviceName);
};
export const filterStorageVolumes = <TEntry extends StorageVolumeEntry>(
entries: TEntry[],
visibleStorageVolumes: string[] | undefined,
integrationId: string,
): TEntry[] => {
if (!visibleStorageVolumes || visibleStorageVolumes.length === 0) {
return entries;
}
return entries.filter((entry) =>
visibleStorageVolumes.some((visibleVolume) =>
matchesVisibleStorageVolume(visibleVolume, integrationId, entry.deviceName),
),
);
};
@@ -4,7 +4,6 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition"; import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options"; import { optionsBuilder } from "../options";
import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options";
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", { export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor, icon: IconHeartRateMonitor,
@@ -29,7 +28,6 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
fileSystem: factory.switch({ fileSystem: factory.switch({
defaultValue: true, defaultValue: true,
}), }),
visibleStorageVolumes: factory.integrationMultiSelect(createStorageVolumeMultiSelectOptions()),
visibleClusterSections: factory.multiSelect({ visibleClusterSections: factory.multiSelect({
options: [ options: [
{ {
@@ -79,11 +77,6 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
return integrationKinds.every((kind) => kind === "proxmox") || integrationKinds.length === 0; return integrationKinds.every((kind) => kind === "proxmox") || integrationKinds.length === 0;
}, },
}, },
visibleStorageVolumes: {
shouldHide(_, integrationKinds) {
return integrationKinds.length === 0 || !integrationKinds.every((kind) => kind === "synology");
},
},
showUptime: { showUptime: {
shouldHide(_, integrationKinds) { shouldHide(_, integrationKinds) {
// Uptime is only shown on cluster health tab // Uptime is only shown on cluster health tab
@@ -36,9 +36,8 @@ import { humanFileSize } from "@homarr/common";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client"; import { useI18n } from "@homarr/translation/client";
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { WidgetEmptyState } from "../common/empty-state";
import { CpuRing } from "./rings/cpu-ring"; import { CpuRing } from "./rings/cpu-ring";
import { CpuTempRing } from "./rings/cpu-temp-ring"; import { CpuTempRing } from "./rings/cpu-temp-ring";
import { GpuRing } from "./rings/gpu-ring"; import { GpuRing } from "./rings/gpu-ring";
@@ -66,13 +65,7 @@ export const SystemHealthMonitoring = ({
return ( return (
<Stack h="100%" gap="sm" className="health-monitoring"> <Stack h="100%" gap="sm" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo }) => { {healthData.map(({ integrationId, integrationName, healthInfo }) => {
const filteredFileSystem = filterStorageVolumes( const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
healthInfo.fileSystem,
options.visibleStorageVolumes,
integrationId,
);
const filteredSmart = filterStorageVolumes(healthInfo.smart, options.visibleStorageVolumes, integrationId);
const disksData = matchFileSystemAndSmart(filteredFileSystem, filteredSmart);
const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes); const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes);
return ( return (
<Stack <Stack
@@ -296,12 +289,8 @@ interface SmartData {
export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: SmartData[]) => { export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: SmartData[]) => {
return fileSystems return fileSystems
.map((fileSystem) => { .map((fileSystem) => {
const normalizedFileSystemName = normalizeStorageDeviceName(fileSystem.deviceName); const baseDeviceName = fileSystem.deviceName.replace(/[0-9]+$/, "");
const smartDisk = smartData.find( const smartDisk = smartData.find((smart) => smart.deviceName === baseDeviceName);
(smart) =>
smart.deviceName === fileSystem.deviceName ||
normalizeStorageDeviceName(smart.deviceName) === normalizedFileSystemName,
);
return { return {
deviceName: smartDisk?.deviceName ?? fileSystem.deviceName, deviceName: smartDisk?.deviceName ?? fileSystem.deviceName,
-15
View File
@@ -72,15 +72,6 @@ interface IntegrationSelectInput extends CommonInput<string> {
}; };
} }
interface IntegrationMultiSelectInput extends CommonInput<string[]> {
withDescription?: boolean;
useOptions: (integrationIds: string[]) => {
data: { value: string; label: string }[];
isPending: boolean;
isError: boolean;
};
}
interface NumberInput extends CommonInput<number> { interface NumberInput extends CommonInput<number> {
validate: z.ZodNumber; validate: z.ZodNumber;
step?: number; step?: number;
@@ -205,12 +196,6 @@ const optionsFactory = {
searchable: input.searchable ?? true, searchable: input.searchable ?? true,
useOptions: input.useOptions, useOptions: input.useOptions,
}), }),
integrationMultiSelect: (input: IntegrationMultiSelectInput) => ({
type: "integrationMultiSelect" as const,
defaultValue: input.defaultValue ?? [],
withDescription: input.withDescription ?? false,
useOptions: input.useOptions,
}),
customWidgetSelect: (input?: CommonInput<string>) => ({ customWidgetSelect: (input?: CommonInput<string>) => ({
type: "customWidgetSelect" as const, type: "customWidgetSelect" as const,
defaultValue: input?.defaultValue ?? "", defaultValue: input?.defaultValue ?? "",
@@ -1,39 +0,0 @@
import { clientApi } from "@homarr/api/client";
import { toScopedStorageVolumeValue } from "./filter-storage-volumes";
export const createStorageVolumeMultiSelectOptions = () => ({
defaultValue: [] as string[],
withDescription: true,
useOptions: (integrationIds: string[]) => {
const queryResults = clientApi.useQueries((queryBuilder) =>
integrationIds.map((integrationId) =>
queryBuilder.widget.healthMonitoring.listStorageVolumes({
integrationId,
}),
),
);
const isPending = queryResults.some((queryResult) => queryResult.isPending);
const isError = queryResults.every((queryResult) => queryResult.isError);
const options = queryResults.flatMap((queryResult, index) => {
const integrationId = integrationIds[index];
if (!integrationId) {
return [];
}
return (queryResult.data ?? []).map((option) => ({
label: option.label,
value: toScopedStorageVolumeValue(integrationId, option.value),
}));
});
const uniqueOptions = [...new Map(options.map((option) => [option.value, option])).values()];
return {
data: uniqueOptions,
isPending,
isError: isError && uniqueOptions.length === 0,
};
},
});
@@ -10,7 +10,6 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state"; import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition"; import type { WidgetComponentProps } from "../definition";
import { filterStorageVolumes } from "../filter-storage-volumes";
import { NoIntegrationDataError } from "../errors/no-data-integration"; import { NoIntegrationDataError } from "../errors/no-data-integration";
type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"]; type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
@@ -124,25 +123,12 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo
const lastItem = data.at(-1); const lastItem = data.at(-1);
if (!lastItem) return <WidgetEmptyState />; if (!lastItem) return <WidgetEmptyState />;
const { fileSystem, smart } = lastItem.healthInfo;
const rawFileSystem = lastItem.healthInfo.fileSystem; if (fileSystem.length === 0) {
if (rawFileSystem.length === 0) {
throw new NoIntegrationDataError(); throw new NoIntegrationDataError();
} }
const fileSystem = filterStorageVolumes(
rawFileSystem,
options.visibleStorageVolumes,
lastItem.integrationId,
);
const smart = filterStorageVolumes(
lastItem.healthInfo.smart,
options.visibleStorageVolumes,
lastItem.integrationId,
);
if (fileSystem.length === 0) return <WidgetEmptyState />;
return ( return (
<Stack gap="xs" p="xs" h="100%"> <Stack gap="xs" p="xs" h="100%">
{fileSystem.map((item) => { {fileSystem.map((item) => {
+11 -22
View File
@@ -2,32 +2,21 @@ import { IconServer2 } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition"; import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options"; import { optionsBuilder } from "../options";
import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options";
export const { definition, componentLoader } = createWidgetDefinition("systemDisks", { export const { definition, componentLoader } = createWidgetDefinition("systemDisks", {
icon: IconServer2, icon: IconServer2,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"], supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid"],
createOptions() { createOptions() {
return optionsBuilder.from( return optionsBuilder.from((factory) => ({
(factory) => ({ showTemperatureIfAvailable: factory.switch({ defaultValue: true }),
showTemperatureIfAvailable: factory.switch({ defaultValue: true }), displayMode: factory.select({
displayMode: factory.select({ options: (["percentage", "absolute", "free"] as const).map((value) => ({
options: (["percentage", "absolute", "free"] as const).map((value) => ({ value,
value, label: (t) => t(`widget.systemDisks.option.displayMode.option.${value}.label`),
label: (t) => t(`widget.systemDisks.option.displayMode.option.${value}.label`), })),
})), defaultValue: "percentage",
defaultValue: "percentage",
}),
showBackgroundBar: factory.switch({ defaultValue: true }),
visibleStorageVolumes: factory.integrationMultiSelect(createStorageVolumeMultiSelectOptions()),
}), }),
{ showBackgroundBar: factory.switch({ defaultValue: true }),
visibleStorageVolumes: { }));
shouldHide(_, integrationKinds) {
return integrationKinds.length === 0 || !integrationKinds.every((kind) => kind === "synology");
},
},
},
);
}, },
}).withDynamicImport(() => import("./component")); }).withDynamicImport(() => import("./component"));
@@ -14,7 +14,7 @@ const labelDisplayModeOptions = {
export const { definition, componentLoader } = createWidgetDefinition("systemResources", { export const { definition, componentLoader } = createWidgetDefinition("systemResources", {
icon: IconGraphFilled, icon: IconGraphFilled,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"], supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances"],
createOptions() { createOptions() {
return optionsBuilder.from((factory) => ({ return optionsBuilder.from((factory) => ({
hasShadow: factory.switch({ defaultValue: true }), hasShadow: factory.switch({ defaultValue: true }),
+104 -89
View File
@@ -100,7 +100,7 @@ catalogs:
specifier: ^1.61.1 specifier: ^1.61.1
version: 1.61.1 version: 1.61.1
'@scalar/api-reference-react': '@scalar/api-reference-react':
specifier: ^0.9.51 specifier: ^0.9.50
version: 0.9.52 version: 0.9.52
'@semantic-release/changelog': '@semantic-release/changelog':
specifier: ^6.0.3 specifier: ^6.0.3
@@ -415,8 +415,8 @@ catalogs:
specifier: 3.22.5 specifier: 3.22.5
version: 3.22.5 version: 3.22.5
next: next:
specifier: 16.2.10 specifier: 16.2.9
version: 16.2.10 version: 16.2.9
next-auth: next-auth:
specifier: 5.0.0-beta.31 specifier: 5.0.0-beta.31
version: 5.0.0-beta.31 version: 5.0.0-beta.31
@@ -578,7 +578,7 @@ overrides:
tar-fs@>=3.0.0 <3.0.9: '>=3.1.3' tar-fs@>=3.0.0 <3.0.9: '>=3.1.3'
tar-fs@>=2.0.0 <2.1.3: '>=3.1.3' tar-fs@>=2.0.0 <2.1.3: '>=3.1.3'
tmp@<=0.2.3: '>=0.2.7' tmp@<=0.2.3: '>=0.2.7'
vite@>=5.0.0 <=5.4.18: '>=8.1.3' vite@>=5.0.0 <=5.4.18: '>=8.1.2'
patchedDependencies: patchedDependencies:
'@types/node-unifi': 5e6ae51e2a17a7f9729bfa30b0eb3d0842a5810ac6db47603ab4a6efa1ed84c5 '@types/node-unifi': 5e6ae51e2a17a7f9729bfa30b0eb3d0842a5810ac6db47603ab4a6efa1ed84c5
@@ -928,7 +928,7 @@ importers:
version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7) version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)
'@tanstack/react-query-next-experimental': '@tanstack/react-query-next-experimental':
specifier: 'catalog:' specifier: 'catalog:'
version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7) version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7)
'@tanstack/react-query-persist-client': '@tanstack/react-query-persist-client':
specifier: 'catalog:' specifier: 'catalog:'
version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7) version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)
@@ -937,7 +937,7 @@ importers:
version: 11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3) version: 11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3)
'@trpc/next': '@trpc/next':
specifier: 'catalog:' specifier: 'catalog:'
version: 11.18.0(@tanstack/react-query@5.101.2(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.18.0(@tanstack/react-query@5.101.2(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.2.7)(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) version: 11.18.0(@tanstack/react-query@5.101.2(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.18.0(@tanstack/react-query@5.101.2(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.2.7)(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)
'@trpc/react-query': '@trpc/react-query':
specifier: 'catalog:' specifier: 'catalog:'
version: 11.18.0(@tanstack/react-query@5.101.2(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.2.7)(typescript@6.0.3) version: 11.18.0(@tanstack/react-query@5.101.2(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.2.7)(typescript@6.0.3)
@@ -1003,10 +1003,10 @@ importers:
version: mantine-react-table-open@9.0.3(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: mantine-react-table-open@9.0.3(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
mcp-handler: mcp-handler:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.1.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)) version: 1.1.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))
next: next:
specifier: 'catalog:' specifier: 'catalog:'
version: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) version: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
postcss-preset-mantine: postcss-preset-mantine:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.18.0(postcss@8.5.16) version: 1.18.0(postcss@8.5.16)
@@ -1039,7 +1039,7 @@ importers:
version: 2.2.6 version: 2.2.6
trpc-to-mcp: trpc-to-mcp:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.3.2(@trpc/server@11.18.0(typescript@6.0.3))(better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.2))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3)))(mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)))(zod@4.4.3) version: 1.3.2(@trpc/server@11.18.0(typescript@6.0.3))(better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.2))(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3)))(mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)))(zod@4.4.3)
use-deep-compare-effect: use-deep-compare-effect:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.8.1(react@19.2.7) version: 1.8.1(react@19.2.7)
@@ -1334,7 +1334,7 @@ importers:
version: 4.5.0 version: 4.5.0
next: next:
specifier: 'catalog:' specifier: 'catalog:'
version: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) version: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.7 version: 19.2.7
@@ -1346,7 +1346,7 @@ importers:
version: 2.2.6 version: 2.2.6
trpc-to-mcp: trpc-to-mcp:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.3.2(@trpc/server@11.18.0(typescript@6.0.3))(better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.2))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3)))(mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.4.3))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)))(zod@4.4.3) version: 1.3.2(@trpc/server@11.18.0(typescript@6.0.3))(better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.2))(next@16.2.9(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3)))(mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)))(zod@4.4.3)
trpc-to-openapi: trpc-to-openapi:
specifier: 'catalog:' specifier: 'catalog:'
version: 3.3.0(patch_hash=2ca3c16af0fcca0c736697ad4fe553a14f794524fa9ce0d5c3e8ee4aea76090c)(@trpc/server@11.18.0(typescript@6.0.3))(zod-openapi@5.3.0(zod@4.4.3))(zod@4.4.3) version: 3.3.0(patch_hash=2ca3c16af0fcca0c736697ad4fe553a14f794524fa9ce0d5c3e8ee4aea76090c)(@trpc/server@11.18.0(typescript@6.0.3))(zod-openapi@5.3.0(zod@4.4.3))(zod@4.4.3)
@@ -1395,10 +1395,10 @@ importers:
version: 8.1.8 version: 8.1.8
next: next:
specifier: 'catalog:' specifier: 'catalog:'
version: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) version: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
next-auth: next-auth:
specifier: 'catalog:' specifier: 'catalog:'
version: 5.0.0-beta.31(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7) version: 5.0.0-beta.31(next@16.2.9(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.7 version: 19.2.7
@@ -1488,7 +1488,7 @@ importers:
version: 1.11.21 version: 1.11.21
next: next:
specifier: 'catalog:' specifier: 'catalog:'
version: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) version: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
octokit: octokit:
specifier: 'catalog:' specifier: 'catalog:'
version: 5.0.5 version: 5.0.5
@@ -2057,7 +2057,7 @@ importers:
version: 1.11.21 version: 1.11.21
next: next:
specifier: 'catalog:' specifier: 'catalog:'
version: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) version: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.7 version: 19.2.7
@@ -2140,7 +2140,7 @@ importers:
version: 0.5.18 version: 0.5.18
next: next:
specifier: 'catalog:' specifier: 'catalog:'
version: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) version: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.7 version: 19.2.7
@@ -2305,7 +2305,7 @@ importers:
version: 9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
next: next:
specifier: 'catalog:' specifier: 'catalog:'
version: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) version: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.7 version: 19.2.7
@@ -2372,7 +2372,7 @@ importers:
version: 2.20.1(@babel/core@7.29.0)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7) version: 2.20.1(@babel/core@7.29.0)(@babel/template@7.29.7)(@types/react@19.2.17)(react@19.2.7)
next: next:
specifier: 'catalog:' specifier: 'catalog:'
version: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) version: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.7 version: 19.2.7
@@ -2409,10 +2409,10 @@ importers:
version: mantine-react-table-open@9.0.3(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: mantine-react-table-open@9.0.3(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
next: next:
specifier: 'catalog:' specifier: 'catalog:'
version: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) version: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
next-intl: next-intl:
specifier: 'catalog:' specifier: 'catalog:'
version: 4.13.1(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7)(typescript@6.0.3) version: 4.13.1(next@16.2.9(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7)(typescript@6.0.3)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.7 version: 19.2.7
@@ -2464,7 +2464,7 @@ importers:
version: mantine-react-table-open@9.0.3(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: mantine-react-table-open@9.0.3(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
next: next:
specifier: 'catalog:' specifier: 'catalog:'
version: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) version: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.7 version: 19.2.7
@@ -2664,7 +2664,7 @@ importers:
version: mantine-react-table-open@9.0.3(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: mantine-react-table-open@9.0.3(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/dates@9.4.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(@tabler/icons-react@3.44.0(react@19.2.7))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
next: next:
specifier: 'catalog:' specifier: 'catalog:'
version: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) version: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
react: react:
specifier: 'catalog:' specifier: 'catalog:'
version: 19.2.7 version: 19.2.7
@@ -4243,6 +4243,9 @@ packages:
'@emnapi/core@1.11.1': '@emnapi/core@1.11.1':
resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==}
'@emnapi/runtime@1.10.0':
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
'@emnapi/runtime@1.11.1': '@emnapi/runtime@1.11.1':
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
@@ -5731,57 +5734,57 @@ packages:
'@emnapi/core': ^1.7.1 '@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1 '@emnapi/runtime': ^1.7.1
'@next/env@16.2.10': '@next/env@16.2.9':
resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==} resolution: {integrity: sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==}
'@next/swc-darwin-arm64@16.2.10': '@next/swc-darwin-arm64@16.2.9':
resolution: {integrity: sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==} resolution: {integrity: sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@next/swc-darwin-x64@16.2.10': '@next/swc-darwin-x64@16.2.9':
resolution: {integrity: sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==} resolution: {integrity: sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@next/swc-linux-arm64-gnu@16.2.10': '@next/swc-linux-arm64-gnu@16.2.9':
resolution: {integrity: sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==} resolution: {integrity: sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@next/swc-linux-arm64-musl@16.2.10': '@next/swc-linux-arm64-musl@16.2.9':
resolution: {integrity: sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==} resolution: {integrity: sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@next/swc-linux-x64-gnu@16.2.10': '@next/swc-linux-x64-gnu@16.2.9':
resolution: {integrity: sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==} resolution: {integrity: sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc] libc: [glibc]
'@next/swc-linux-x64-musl@16.2.10': '@next/swc-linux-x64-musl@16.2.9':
resolution: {integrity: sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==} resolution: {integrity: sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl] libc: [musl]
'@next/swc-win32-arm64-msvc@16.2.10': '@next/swc-win32-arm64-msvc@16.2.9':
resolution: {integrity: sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==} resolution: {integrity: sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@next/swc-win32-x64-msvc@16.2.10': '@next/swc-win32-x64-msvc@16.2.9':
resolution: {integrity: sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==} resolution: {integrity: sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -12026,6 +12029,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
nanoid@5.1.11:
resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==}
engines: {node: ^18 || >=20}
hasBin: true
nanoid@5.1.16: nanoid@5.1.16:
resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==} resolution: {integrity: sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==}
engines: {node: ^18 || >=20} engines: {node: ^18 || >=20}
@@ -12088,8 +12096,8 @@ packages:
typescript: typescript:
optional: true optional: true
next@16.2.10: next@16.2.9:
resolution: {integrity: sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==} resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -14908,7 +14916,7 @@ packages:
vite-tsconfig-paths@6.1.1: vite-tsconfig-paths@6.1.1:
resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==} resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==}
peerDependencies: peerDependencies:
vite: '>=8.1.3' vite: '>=8.1.2'
vite@8.1.3: vite@8.1.3:
resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==} resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==}
@@ -17947,6 +17955,11 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@emnapi/runtime@1.10.0':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.11.1': '@emnapi/runtime@1.11.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -18547,7 +18560,7 @@ snapshots:
'@img/sharp-wasm32@0.34.5': '@img/sharp-wasm32@0.34.5':
dependencies: dependencies:
'@emnapi/runtime': 1.11.1 '@emnapi/runtime': 1.10.0
optional: true optional: true
'@img/sharp-win32-arm64@0.34.5': '@img/sharp-win32-arm64@0.34.5':
@@ -19234,30 +19247,30 @@ snapshots:
'@tybys/wasm-util': 0.10.3 '@tybys/wasm-util': 0.10.3
optional: true optional: true
'@next/env@16.2.10': {} '@next/env@16.2.9': {}
'@next/swc-darwin-arm64@16.2.10': '@next/swc-darwin-arm64@16.2.9':
optional: true optional: true
'@next/swc-darwin-x64@16.2.10': '@next/swc-darwin-x64@16.2.9':
optional: true optional: true
'@next/swc-linux-arm64-gnu@16.2.10': '@next/swc-linux-arm64-gnu@16.2.9':
optional: true optional: true
'@next/swc-linux-arm64-musl@16.2.10': '@next/swc-linux-arm64-musl@16.2.9':
optional: true optional: true
'@next/swc-linux-x64-gnu@16.2.10': '@next/swc-linux-x64-gnu@16.2.9':
optional: true optional: true
'@next/swc-linux-x64-musl@16.2.10': '@next/swc-linux-x64-musl@16.2.9':
optional: true optional: true
'@next/swc-win32-arm64-msvc@16.2.10': '@next/swc-win32-arm64-msvc@16.2.9':
optional: true optional: true
'@next/swc-win32-x64-msvc@16.2.10': '@next/swc-win32-x64-msvc@16.2.9':
optional: true optional: true
'@nivo/annotations@0.99.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': '@nivo/annotations@0.99.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
@@ -20173,7 +20186,7 @@ snapshots:
fuse.js: 7.4.2 fuse.js: 7.4.2
js-base64: 3.7.8 js-base64: 3.7.8
jsonc-parser: 3.3.1 jsonc-parser: 3.3.1
nanoid: 5.1.16 nanoid: 5.1.11
pretty-ms: 9.3.0 pretty-ms: 9.3.0
radix-vue: 1.9.17(vue@3.5.35(typescript@6.0.3)) radix-vue: 1.9.17(vue@3.5.35(typescript@6.0.3))
set-cookie-parser: 3.1.0 set-cookie-parser: 3.1.0
@@ -20242,7 +20255,7 @@ snapshots:
'@vueuse/core': 13.9.0(vue@3.5.35(typescript@6.0.3)) '@vueuse/core': 13.9.0(vue@3.5.35(typescript@6.0.3))
fuse.js: 7.4.2 fuse.js: 7.4.2
microdiff: 1.5.0 microdiff: 1.5.0
nanoid: 5.1.16 nanoid: 5.1.11
vue: 3.5.35(typescript@6.0.3) vue: 3.5.35(typescript@6.0.3)
yaml: 2.9.0 yaml: 2.9.0
transitivePeerDependencies: transitivePeerDependencies:
@@ -20390,15 +20403,15 @@ snapshots:
'@scalar/themes@0.16.2': '@scalar/themes@0.16.2':
dependencies: dependencies:
nanoid: 5.1.16 nanoid: 5.1.11
'@scalar/typebox@0.1.3': {} '@scalar/typebox@0.1.3': {}
'@scalar/types@0.16.1': '@scalar/types@0.16.1':
dependencies: dependencies:
'@scalar/helpers': 0.9.0 '@scalar/helpers': 0.9.0
nanoid: 5.1.16 nanoid: 5.1.11
type-fest: 5.7.0 type-fest: 5.6.0
zod: 4.4.3 zod: 4.4.3
'@scalar/use-codemirror@0.14.12(typescript@6.0.3)': '@scalar/use-codemirror@0.14.12(typescript@6.0.3)':
@@ -20453,7 +20466,7 @@ snapshots:
'@scalar/types': 0.16.1 '@scalar/types': 0.16.1
'@scalar/validation': 0.6.0 '@scalar/validation': 0.6.0
js-base64: 3.7.8 js-base64: 3.7.8
type-fest: 5.7.0 type-fest: 5.6.0
vue: 3.5.35(typescript@6.0.3) vue: 3.5.35(typescript@6.0.3)
yaml: 2.9.0 yaml: 2.9.0
transitivePeerDependencies: transitivePeerDependencies:
@@ -20938,10 +20951,10 @@ snapshots:
'@tanstack/react-query': 5.101.2(react@19.2.7) '@tanstack/react-query': 5.101.2(react@19.2.7)
react: 19.2.7 react: 19.2.7
'@tanstack/react-query-next-experimental@5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7)': '@tanstack/react-query-next-experimental@5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7)':
dependencies: dependencies:
'@tanstack/react-query': 5.101.2(react@19.2.7) '@tanstack/react-query': 5.101.2(react@19.2.7)
next: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) next: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
react: 19.2.7 react: 19.2.7
'@tanstack/react-query-persist-client@5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)': '@tanstack/react-query-persist-client@5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)':
@@ -21225,11 +21238,11 @@ snapshots:
'@trpc/server': 11.18.0(typescript@6.0.3) '@trpc/server': 11.18.0(typescript@6.0.3)
typescript: 6.0.3 typescript: 6.0.3
'@trpc/next@11.18.0(@tanstack/react-query@5.101.2(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.18.0(@tanstack/react-query@5.101.2(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.2.7)(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)': '@trpc/next@11.18.0(@tanstack/react-query@5.101.2(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/react-query@11.18.0(@tanstack/react-query@5.101.2(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(react@19.2.7)(typescript@6.0.3))(@trpc/server@11.18.0(typescript@6.0.3))(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)':
dependencies: dependencies:
'@trpc/client': 11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3) '@trpc/client': 11.18.0(@trpc/server@11.18.0(typescript@6.0.3))(typescript@6.0.3)
'@trpc/server': 11.18.0(typescript@6.0.3) '@trpc/server': 11.18.0(typescript@6.0.3)
next: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) next: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
react: 19.2.7 react: 19.2.7
react-dom: 19.2.7(react@19.2.7) react-dom: 19.2.7(react@19.2.7)
typescript: 6.0.3 typescript: 6.0.3
@@ -22363,7 +22376,7 @@ snapshots:
before-after-hook@4.0.0: {} before-after-hook@4.0.0: {}
better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.2))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3)): better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.2))(next@16.2.9(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3)):
dependencies: dependencies:
'@better-auth/core': 1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/core': 1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/drizzle-adapter': 1.6.15(@better-auth/core@1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1)) '@better-auth/drizzle-adapter': 1.6.15(@better-auth/core@1.6.15(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1))
@@ -22387,7 +22400,7 @@ snapshots:
drizzle-kit: 0.31.10 drizzle-kit: 0.31.10
drizzle-orm: 0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1) drizzle-orm: 0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1)
mysql2: 3.22.5(@types/node@24.13.2) mysql2: 3.22.5(@types/node@24.13.2)
next: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) next: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
pg: 8.22.0 pg: 8.22.0
react: 19.2.7 react: 19.2.7
react-dom: 19.2.7(react@19.2.7) react-dom: 19.2.7(react@19.2.7)
@@ -25642,23 +25655,23 @@ snapshots:
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.4.3))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)): mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)):
dependencies: dependencies:
'@modelcontextprotocol/sdk': 1.26.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.26.0(zod@4.4.3)
chalk: 5.6.2 chalk: 5.6.2
commander: 11.1.0 commander: 11.1.0
redis: 4.7.1 redis: 4.7.1
optionalDependencies: optionalDependencies:
next: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) next: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)): mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)):
dependencies: dependencies:
'@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
chalk: 5.6.2 chalk: 5.6.2
commander: 11.1.0 commander: 11.1.0
redis: 4.7.1 redis: 4.7.1
optionalDependencies: optionalDependencies:
next: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) next: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
mdast-util-directive@3.1.0: mdast-util-directive@3.1.0:
dependencies: dependencies:
@@ -26368,6 +26381,8 @@ snapshots:
nanoid@3.3.12: {} nanoid@3.3.12: {}
nanoid@5.1.11: {}
nanoid@5.1.16: {} nanoid@5.1.16: {}
nanostores@1.3.0: {} nanostores@1.3.0: {}
@@ -26386,22 +26401,22 @@ snapshots:
neverpanic@0.0.8: {} neverpanic@0.0.8: {}
next-auth@5.0.0-beta.31(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7): next-auth@5.0.0-beta.31(next@16.2.9(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7):
dependencies: dependencies:
'@auth/core': 0.41.2 '@auth/core': 0.41.2
next: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) next: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
react: 19.2.7 react: 19.2.7
next-intl-swc-plugin-extractor@4.13.1: {} next-intl-swc-plugin-extractor@4.13.1: {}
next-intl@4.13.1(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7)(typescript@6.0.3): next-intl@4.13.1(next@16.2.9(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7)(typescript@6.0.3):
dependencies: dependencies:
'@formatjs/intl-localematcher': 0.8.1 '@formatjs/intl-localematcher': 0.8.1
'@parcel/watcher': 2.4.1 '@parcel/watcher': 2.4.1
'@swc/core': 1.15.3(@swc/helpers@0.5.15) '@swc/core': 1.15.3(@swc/helpers@0.5.15)
icu-minify: 4.13.1 icu-minify: 4.13.1
negotiator: 1.0.0 negotiator: 1.0.0
next: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) next: 16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)
next-intl-swc-plugin-extractor: 4.13.1 next-intl-swc-plugin-extractor: 4.13.1
po-parser: 2.1.1 po-parser: 2.1.1
react: 19.2.7 react: 19.2.7
@@ -26411,9 +26426,9 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- '@swc/helpers' - '@swc/helpers'
next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0): next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0):
dependencies: dependencies:
'@next/env': 16.2.10 '@next/env': 16.2.9
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.15
baseline-browser-mapping: 2.10.31 baseline-browser-mapping: 2.10.31
caniuse-lite: 1.0.30001793 caniuse-lite: 1.0.30001793
@@ -26422,14 +26437,14 @@ snapshots:
react-dom: 19.2.7(react@19.2.7) react-dom: 19.2.7(react@19.2.7)
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.7) styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.7)
optionalDependencies: optionalDependencies:
'@next/swc-darwin-arm64': 16.2.10 '@next/swc-darwin-arm64': 16.2.9
'@next/swc-darwin-x64': 16.2.10 '@next/swc-darwin-x64': 16.2.9
'@next/swc-linux-arm64-gnu': 16.2.10 '@next/swc-linux-arm64-gnu': 16.2.9
'@next/swc-linux-arm64-musl': 16.2.10 '@next/swc-linux-arm64-musl': 16.2.9
'@next/swc-linux-x64-gnu': 16.2.10 '@next/swc-linux-x64-gnu': 16.2.9
'@next/swc-linux-x64-musl': 16.2.10 '@next/swc-linux-x64-musl': 16.2.9
'@next/swc-win32-arm64-msvc': 16.2.10 '@next/swc-win32-arm64-msvc': 16.2.9
'@next/swc-win32-x64-msvc': 16.2.10 '@next/swc-win32-x64-msvc': 16.2.9
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@playwright/test': 1.61.1 '@playwright/test': 1.61.1
babel-plugin-react-compiler: 1.0.0 babel-plugin-react-compiler: 1.0.0
@@ -29204,23 +29219,23 @@ snapshots:
trough@2.2.0: {} trough@2.2.0: {}
trpc-to-mcp@1.3.2(@trpc/server@11.18.0(typescript@6.0.3))(better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.2))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3)))(mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)))(zod@4.4.3): trpc-to-mcp@1.3.2(@trpc/server@11.18.0(typescript@6.0.3))(better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.2))(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3)))(mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)))(zod@4.4.3):
dependencies: dependencies:
'@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
'@trpc/server': 11.18.0(typescript@6.0.3) '@trpc/server': 11.18.0(typescript@6.0.3)
better-auth: 1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.2))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3)) better-auth: 1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.2))(next@16.2.9(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3))
mcp-handler: 1.1.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(next@16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)) mcp-handler: 1.1.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(next@16.2.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))
zod: 4.4.3 zod: 4.4.3
transitivePeerDependencies: transitivePeerDependencies:
- '@cfworker/json-schema' - '@cfworker/json-schema'
- supports-color - supports-color
trpc-to-mcp@1.3.2(@trpc/server@11.18.0(typescript@6.0.3))(better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.2))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3)))(mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.4.3))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)))(zod@4.4.3): trpc-to-mcp@1.3.2(@trpc/server@11.18.0(typescript@6.0.3))(better-auth@1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.2))(next@16.2.9(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3)))(mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)))(zod@4.4.3):
dependencies: dependencies:
'@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
'@trpc/server': 11.18.0(typescript@6.0.3) '@trpc/server': 11.18.0(typescript@6.0.3)
better-auth: 1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.2))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3)) better-auth: 1.6.15(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client-wasm@0.14.0)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.11.1)(gel@2.0.0)(kysely@0.29.2)(mysql2@3.22.5(@types/node@24.13.2))(pg@8.22.0)(sql.js@1.14.1))(mysql2@3.22.5(@types/node@24.13.2))(next@16.2.9(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(pg@8.22.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9)(vue@3.5.35(typescript@6.0.3))
mcp-handler: 1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.4.3))(next@16.2.10(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0)) mcp-handler: 1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.4.3))(next@16.2.9(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))
zod: 4.4.3 zod: 4.4.3
transitivePeerDependencies: transitivePeerDependencies:
- '@cfworker/json-schema' - '@cfworker/json-schema'
+4 -4
View File
@@ -33,7 +33,7 @@ catalog:
"@mantine/notifications": ^9.4.1 "@mantine/notifications": ^9.4.1
"@mantine/spotlight": ^9.4.1 "@mantine/spotlight": ^9.4.1
"@mantine/tiptap": ^9.4.1 "@mantine/tiptap": ^9.4.1
"@next/eslint-plugin-next": 16.2.10 "@next/eslint-plugin-next": 16.2.9
"@octokit/auth-app": ^8.2.0 "@octokit/auth-app": ^8.2.0
"@paralleldrive/cuid2": ^3.3.0 "@paralleldrive/cuid2": ^3.3.0
"@playwright/test": ^1.61.1 "@playwright/test": ^1.61.1
@@ -89,7 +89,7 @@ catalog:
"@types/prismjs": ^1.26.6 "@types/prismjs": ^1.26.6
"@types/react": 19.2.17 "@types/react": 19.2.17
"@types/react-dom": 19.2.3 "@types/react-dom": 19.2.3
"@scalar/api-reference-react": ^0.9.51 "@scalar/api-reference-react": ^0.9.50
"@types/video.js": ^7.3.58 "@types/video.js": ^7.3.58
"@types/ws": ^8.18.1 "@types/ws": ^8.18.1
"@types/xml2js": ^0.4.14 "@types/xml2js": ^0.4.14
@@ -146,7 +146,7 @@ catalog:
mcp-handler: ^1.1.0 mcp-handler: ^1.1.0
maria2: ^0.4.1 maria2: ^0.4.1
mysql2: 3.22.5 mysql2: 3.22.5
next: 16.2.10 next: 16.2.9
next-auth: 5.0.0-beta.31 next-auth: 5.0.0-beta.31
next-intl: 4.13.1 next-intl: 4.13.1
node-loader: ^2.1.0 node-loader: ^2.1.0
@@ -229,7 +229,7 @@ overrides:
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.3" "tar-fs@>=3.0.0 <3.0.9": ">=3.1.3"
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.3" "tar-fs@>=2.0.0 <2.1.3": ">=3.1.3"
"tmp@<=0.2.3": ">=0.2.7" "tmp@<=0.2.3": ">=0.2.7"
"vite@>=5.0.0 <=5.4.18": ">=8.1.3" "vite@>=5.0.0 <=5.4.18": ">=8.1.2"
patchedDependencies: patchedDependencies:
"@types/node-unifi": "patches/@types__node-unifi.patch" "@types/node-unifi": "patches/@types__node-unifi.patch"
"trpc-to-openapi": "patches/trpc-to-openapi.patch" "trpc-to-openapi": "patches/trpc-to-openapi.patch"