Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cecd58626 | ||
|
|
e4b061920c | ||
|
|
65ba866239 | ||
|
|
260b167134 | ||
|
|
7a4de03c0e | ||
|
|
68f8e8146b | ||
|
|
1297e6c2ac | ||
|
|
31288dcbfe | ||
|
|
fc2c251557 | ||
|
|
d067261171 | ||
|
|
ae2f05c834 | ||
|
|
c7fa7cdb50 | ||
|
|
0c28224e82 |
No files matched your search
+8
-1
@@ -59,4 +59,11 @@ TURBO_TELEMETRY_DISABLED=1
|
|||||||
# ENABLE_KUBERNETES=true
|
# ENABLE_KUBERNETES=true
|
||||||
|
|
||||||
# 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
|
||||||
@@ -25,7 +25,7 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-format-autofix:
|
lint-format-autofix:
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -159,6 +159,10 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
title: "Synology DiskStation"
|
||||||
|
description: "Monitor CPU, memory, storage volumes, and system health from Synology DSM."
|
||||||
|
hide_title: true
|
||||||
|
---
|
||||||
|
|
||||||
|
import { IntegrationHeader } from "@site/src/components/integrations/header";
|
||||||
|
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
|
||||||
|
import { AddingIntegration } from "@site/src/components/integrations/adding";
|
||||||
|
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
|
||||||
|
import { synologyIntegration } from ".";
|
||||||
|
import { systemResourcesWidget } from "@site/docs/widgets/system-resources";
|
||||||
|
import { systemDisksWidget } from "@site/docs/widgets/system-disks";
|
||||||
|
import { healthMonitoringWidget } from "@site/docs/widgets/health-monitoring";
|
||||||
|
import Alert from "@theme/Admonition";
|
||||||
|
|
||||||
|
<IntegrationHeader integration={synologyIntegration} categories={["NAS", "Hardware"]} />
|
||||||
|
|
||||||
|
### Widgets & Capabilities
|
||||||
|
|
||||||
|
<IntegrationCapabilites
|
||||||
|
items={[
|
||||||
|
{ widget: healthMonitoringWidget },
|
||||||
|
{ widget: systemResourcesWidget },
|
||||||
|
{ widget: systemDisksWidget },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
### Supported metrics
|
||||||
|
|
||||||
|
| Widget section | DSM API source | May be unavailable |
|
||||||
|
| ---------------------------- | ----------------------------------------------------- | ------------------------------------------------------- |
|
||||||
|
| CPU ring / chart | `SYNO.Core.System.Utilization` | — |
|
||||||
|
| Memory ring / chart | `SYNO.Core.System.Utilization` | — |
|
||||||
|
| CPU temperature ring | `SYNO.Core.System` system info | Models without a temperature sensor |
|
||||||
|
| Network charts | `SYNO.Core.System.Utilization` network totals | Some DSM builds or permission configurations |
|
||||||
|
| Load average (info modal) | `SYNO.Core.System.Utilization` CPU load fields | — |
|
||||||
|
| Storage volume usage | `SYNO.Core.System` storage info / Storage Manager API | — |
|
||||||
|
| SMART status and temperature | Storage Manager and SMART APIs | Disk-to-volume mapping may be incomplete on some setups |
|
||||||
|
| Reboot / update indicator | `SYNO.Core.System.Status` / upgrade APIs | Permission-dependent |
|
||||||
|
| GPU | Not available | Typical DiskStation models |
|
||||||
|
|
||||||
|
### Adding the integration
|
||||||
|
|
||||||
|
<AddingIntegration />
|
||||||
|
|
||||||
|
<Alert type="info" title="Use the correct DSM URL">
|
||||||
|
Enter the HTTP or HTTPS URL of your Synology DSM web interface, for example <code>http://diskstation.local:5000</code>{" "}
|
||||||
|
or <code>https://diskstation.local:5001</code>. If DSM uses a self-signed certificate, add it under{" "}
|
||||||
|
<a href="/docs/management/certificates">Management > Tools > Certificates</a> so Homarr can connect securely.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
### Secrets
|
||||||
|
|
||||||
|
<IntegrationSecrets
|
||||||
|
secrets={[
|
||||||
|
{
|
||||||
|
credentials: ["username", "password"],
|
||||||
|
steps: [
|
||||||
|
"Create a dedicated DSM user with a strong password for Homarr.",
|
||||||
|
"Add the user to the default Administrators group so Homarr can read `SYNO.Core.System` metrics.",
|
||||||
|
"Under User Groups and Permissions, deny access to shared folders and most applications.",
|
||||||
|
"Allow login to DSM only, optionally restricted by source IP address.",
|
||||||
|
"Log in once with the service account in DSM to accept terms and conditions.",
|
||||||
|
],
|
||||||
|
body: (
|
||||||
|
<>
|
||||||
|
<Alert type="caution" title="Administrators group required">
|
||||||
|
Homarr reads system, storage, and utilization data from APIs that require membership in the default
|
||||||
|
Administrators group.
|
||||||
|
</Alert>
|
||||||
|
<Alert type="warning" title="Two-factor authentication is not supported">
|
||||||
|
Accounts with 2-step verification enabled cannot authenticate with this integration. Use a dedicated service
|
||||||
|
account without 2FA.
|
||||||
|
</Alert>
|
||||||
|
<Alert type="tip" title="First-time setup">
|
||||||
|
If login fails during testing, temporarily disable automatic blocking for invalid logins under Control Panel
|
||||||
|
> Security > Protection, then re-enable it after Homarr connects successfully.
|
||||||
|
</Alert>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
### Storage volume selection
|
||||||
|
|
||||||
|
Health Monitoring and System Disks widgets can filter Synology storage volumes when **every** selected integration is a Synology DiskStation. Leave **Visible storage volumes** empty to show all volumes, or pick specific volumes from the list. When multiple Synology integrations are connected, volumes are labeled with the integration name.
|
||||||
|
|
||||||
|
<Alert type="note" title="Synology-only option">
|
||||||
|
The volume filter is hidden when the widget also uses non-Synology integrations such as TrueNAS or Unraid.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Alert type="note" title="Volume ordering">
|
||||||
|
When no volume filter is configured, the first volume returned by DSM is not guaranteed to be `volume_1`.
|
||||||
|
</Alert>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { IntegrationDefinition } from "@site/src/types";
|
||||||
|
|
||||||
|
export const synologyIntegration: IntegrationDefinition = {
|
||||||
|
name: "Synology DiskStation",
|
||||||
|
description: "Monitor CPU, memory, storage volumes, and system health from Synology DSM.",
|
||||||
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/synology.svg",
|
||||||
|
path: "../../integrations/synology",
|
||||||
|
};
|
||||||
@@ -1,61 +1,71 @@
|
|||||||
---
|
---
|
||||||
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 items={[{
|
<WidgetIntegrations
|
||||||
integration: dashDotIntegration,
|
items={[
|
||||||
note: "Will display information on the system tab"
|
{
|
||||||
}, {
|
integration: dashDotIntegration,
|
||||||
integration: openMediaVaultIntegration,
|
note: "Will display information on the system tab",
|
||||||
note: "Will display information on the system tab"
|
},
|
||||||
}, {
|
{
|
||||||
integration: truenasIntegration,
|
integration: openMediaVaultIntegration,
|
||||||
note: "Will display information on the system tab"
|
note: "Will display information on the system tab",
|
||||||
}, {
|
},
|
||||||
integration: glancesIntegration,
|
{
|
||||||
note: "Will display information on the system tab"
|
integration: truenasIntegration,
|
||||||
}, {
|
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,6 +38,13 @@ 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.",
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
title: "System disks"
|
||||||
|
description: "Disk usage of your system"
|
||||||
|
hide_title: true
|
||||||
|
---
|
||||||
|
|
||||||
|
import { WidgetHeader } from "@site/src/components/widgets/header";
|
||||||
|
import { AddingWidget } from "@site/src/components/widgets/adding";
|
||||||
|
import { WidgetConfig } from "@site/src/components/widgets/configuration";
|
||||||
|
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
|
||||||
|
import { systemDisksWidget } from ".";
|
||||||
|
import { dashDotIntegration } from "@site/docs/integrations/dash-dot";
|
||||||
|
import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault";
|
||||||
|
import { truenasIntegration } from "@site/docs/integrations/truenas";
|
||||||
|
import { unraidIntegration } from "@site/docs/integrations/unraid";
|
||||||
|
import { synologyIntegration } from "@site/docs/integrations/synology";
|
||||||
|
|
||||||
|
<WidgetHeader widget={systemDisksWidget} categories={["Storage", "Disks"]} />
|
||||||
|
|
||||||
|
This widget displays filesystem and SMART disk usage for supported server and NAS integrations.
|
||||||
|
|
||||||
|
### Supported Integrations
|
||||||
|
|
||||||
|
<WidgetIntegrations
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
integration: dashDotIntegration,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
integration: openMediaVaultIntegration,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
integration: truenasIntegration,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
integration: unraidIntegration,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
integration: synologyIntegration,
|
||||||
|
note: "Supports storage volume filtering when only Synology integrations are selected",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
### Adding the widget
|
||||||
|
|
||||||
|
<AddingWidget />
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
<WidgetConfig configuration={systemDisksWidget.configuration} />
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { WidgetDefinition } from "@site/src/types";
|
||||||
|
import { IconServer2 } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export const systemDisksWidget: WidgetDefinition = {
|
||||||
|
icon: IconServer2,
|
||||||
|
name: "System disks",
|
||||||
|
description: "Disk usage of your system",
|
||||||
|
path: "../../widgets/system-disks",
|
||||||
|
configuration: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: "Show temperature if available",
|
||||||
|
description: "Display disk temperature when the integration provides it",
|
||||||
|
values: { type: "boolean" },
|
||||||
|
defaultValue: "Yes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Display mode",
|
||||||
|
description: "How disk usage values are shown",
|
||||||
|
values: {
|
||||||
|
type: "select",
|
||||||
|
options: ["Percentage (e.g., 76%)", "Absolute values (e.g., 800GB / 1TB)", "Free space percentage (e.g., 24% free)"],
|
||||||
|
},
|
||||||
|
defaultValue: "Percentage (e.g., 76%)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Show background usage bar",
|
||||||
|
description: "Fill the area below the usage value with a background bar",
|
||||||
|
values: { type: "boolean" },
|
||||||
|
defaultValue: "Yes",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Visible storage volumes",
|
||||||
|
description:
|
||||||
|
"Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.",
|
||||||
|
values: "List of storage volumes",
|
||||||
|
defaultValue: "All volumes",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,57 +1,69 @@
|
|||||||
---
|
---
|
||||||
title: 'System Resources'
|
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 src={cpuAndMemory} alt="system-resources-widget cpu-and-memory icon-text-label" width={256 + 4} height={256 + 4} />
|
<img
|
||||||
|
src={cpuAndMemory}
|
||||||
|
alt="system-resources-widget cpu-and-memory icon-text-label"
|
||||||
|
width={256 + 4}
|
||||||
|
height={256 + 4}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### Supported Integrations
|
### Supported Integrations
|
||||||
|
|
||||||
<WidgetIntegrations items={[{
|
<WidgetIntegrations
|
||||||
integration: dashDotIntegration
|
items={[
|
||||||
}, {
|
{
|
||||||
integration: openMediaVaultIntegration
|
integration: dashDotIntegration,
|
||||||
}, {
|
},
|
||||||
integration: truenasIntegration
|
{
|
||||||
}, {
|
integration: openMediaVaultIntegration,
|
||||||
integration: glancesIntegration
|
},
|
||||||
}]} />
|
{
|
||||||
|
integration: truenasIntegration,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
integration: synologyIntegration,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
integration: glancesIntegration,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
### Adding the widget
|
### Adding the widget
|
||||||
|
|
||||||
@@ -59,4 +71,4 @@ import { truenasIntegration } from '@site/docs/integrations/truenas';
|
|||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
<WidgetConfig configuration={systemResourcesWidget.configuration} />
|
<WidgetConfig configuration={systemResourcesWidget.configuration} />
|
||||||
@@ -1,18 +1,25 @@
|
|||||||
// 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
|
||||||
// DocSearch-managed crawlers: paste this in the Algolia crawler editor or send to DocSearch support.
|
// Paste this in the Algolia crawler editor.
|
||||||
// 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",
|
||||||
rateLimit: 8,
|
indexPrefix: "",
|
||||||
maxDepth: 10,
|
rateLimit: 32,
|
||||||
|
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",
|
||||||
@@ -105,7 +112,6 @@ 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"],
|
||||||
@@ -119,4 +125,10 @@ 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 } },
|
||||||
});
|
});
|
||||||
@@ -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: true,
|
trailingSlash: undefined,
|
||||||
favicon: "img/logo.png",
|
favicon: "img/logo.png",
|
||||||
organizationName: "homarr-labs",
|
organizationName: "homarr-labs",
|
||||||
projectName: "homarr",
|
projectName: "homarr",
|
||||||
@@ -79,8 +79,27 @@ const config: Config = {
|
|||||||
sitemap: {
|
sitemap: {
|
||||||
changefreq: "weekly",
|
changefreq: "weekly",
|
||||||
priority: 0.5,
|
priority: 0.5,
|
||||||
ignorePatterns: ["/tags/**"],
|
ignorePatterns: ["/tags/**", "/docs/category/**"],
|
||||||
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>(serialized);
|
const persisted = superjson.parse<PersistedClient | undefined>(serialized);
|
||||||
if (persisted.buster !== queryCacheBuster) return null;
|
if (!persisted || 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,7 +45,9 @@ export const BoardContentHeaderActions = () => {
|
|||||||
const [isEditMode] = useEditMode();
|
const [isEditMode] = useEditMode();
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { hasChangeAccess } = useBoardPermissions(board);
|
const { hasChangeAccess } = useBoardPermissions(board);
|
||||||
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
|
// Fall back to read-only if the query has no data (e.g. errored) so we never expose
|
||||||
|
// 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 />;
|
||||||
@@ -55,9 +57,9 @@ export const BoardContentHeaderActions = () => {
|
|||||||
<>
|
<>
|
||||||
{isEditMode && <AddMenu />}
|
{isEditMode && <AddMenu />}
|
||||||
|
|
||||||
<EditModeMenu demoMode={demoMode ?? false} />
|
<EditModeMenu demoReadOnly={demoReadOnly} />
|
||||||
|
|
||||||
{!demoMode && (
|
{!demoReadOnly && (
|
||||||
<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} />
|
||||||
@@ -158,7 +160,7 @@ const AddMenu = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: 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();
|
||||||
@@ -188,11 +190,11 @@ const EditModeMenu = ({ demoMode }: { demoMode: boolean }) => {
|
|||||||
|
|
||||||
const toggle = useCallback(() => {
|
const toggle = useCallback(() => {
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
if (demoMode) return discardDemoChanges();
|
if (demoReadOnly) return discardDemoChanges();
|
||||||
return saveBoard(board);
|
return saveBoard(board);
|
||||||
}
|
}
|
||||||
open();
|
open();
|
||||||
}, [board, isEditMode, demoMode, saveBoard, open, discardDemoChanges]);
|
}, [board, isEditMode, demoReadOnly, 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: 960,
|
width: 128,
|
||||||
height: 640,
|
height: 128,
|
||||||
});
|
});
|
||||||
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, Menu, Switch, Text } from "@mantine/core";
|
import { Group, Loader, Menu, Switch, Text } from "@mantine/core";
|
||||||
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
|
import { IconCopy, IconLayoutKanban, IconRefresh, IconSettings, IconTrash } from "@tabler/icons-react";
|
||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useIsFetching, 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,8 +50,9 @@ 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.invalidateQueries({ queryKey: widgetQueryKey });
|
void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
|
||||||
}, [queryClient, widgetQueryKey]);
|
}, [queryClient, widgetQueryKey]);
|
||||||
|
|
||||||
const options = useMemo(
|
const options = useMemo(
|
||||||
@@ -223,11 +224,15 @@ 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 closeMenuOnClick leftSection={<IconRefresh size={16} />} onClick={handleRefetch}>
|
<Menu.Item
|
||||||
|
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
|
||||||
|
onClick={handleRefetch}
|
||||||
|
disabled={isWidgetFetching}
|
||||||
|
>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<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} />
|
<WidgetCacheAge queryClient={queryClient} queryKey={widgetQueryKey} isFetching={isWidgetFetching} />
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@@ -265,13 +270,24 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const WidgetCacheAge = ({ queryClient, queryKey }: { queryClient: QueryClient; queryKey: unknown[] }) => {
|
const WidgetCacheAge = ({
|
||||||
|
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 })
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ 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,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, publicProcedure, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, isDemoMode, isDemoReadOnly, publicProcedure, protectedProcedure } from "../trpc";
|
||||||
|
|
||||||
export const infoRouter = createTRPCRouter({
|
export const infoRouter = createTRPCRouter({
|
||||||
getInfo: protectedProcedure
|
getInfo: protectedProcedure
|
||||||
@@ -20,4 +20,5 @@ export const infoRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
isDemoMode: publicProcedure.query(() => isDemoMode),
|
isDemoMode: publicProcedure.query(() => isDemoMode),
|
||||||
|
isDemoReadOnly: publicProcedure.query(() => isDemoReadOnly),
|
||||||
});
|
});
|
||||||
@@ -1,21 +1,31 @@
|
|||||||
|
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/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
|
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Synology/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.concat(
|
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
|
||||||
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();
|
||||||
@@ -27,6 +37,23 @@ export const healthMonitoringRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
listStorageVolumes: publicProcedure
|
||||||
|
.meta({
|
||||||
|
mcp: {
|
||||||
|
enabled: true,
|
||||||
|
description:
|
||||||
|
"List storage volumes from a Synology DiskStation integration for widget configuration. REQUIRED: integrationId from integration_all (Synology integration only)",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.concat(createOneIntegrationMiddleware("query", "synology"))
|
||||||
|
.query(async ({ ctx }) => {
|
||||||
|
const integrationInstance = await createIntegrationAsync(ctx.integration);
|
||||||
|
if (!(integrationInstance instanceof SynologyIntegration)) {
|
||||||
|
throw new Error("Expected Synology integration");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await integrationInstance.listStorageVolumesAsync();
|
||||||
|
}),
|
||||||
getClusterHealthStatus: publicProcedure
|
getClusterHealthStatus: publicProcedure
|
||||||
.meta({
|
.meta({
|
||||||
mcp: {
|
mcp: {
|
||||||
|
|||||||
@@ -95,8 +95,12 @@ 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 (env.DEMO_MODE && type === "mutation") {
|
if (isDemoReadOnly && 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",
|
||||||
@@ -105,7 +109,7 @@ const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
|
|||||||
return next({ ctx });
|
return next({ ctx });
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
|
const baseProcedure = isDemoReadOnly ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public (unauthed) procedure
|
* Public (unauthed) procedure
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ 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-sources"
|
|
||||||
| "/docs/tags/data"
|
| "/docs/tags/data"
|
||||||
|
| "/docs/tags/data-sources"
|
||||||
| "/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-engines"
|
|
||||||
| "/docs/tags/search"
|
| "/docs/tags/search"
|
||||||
|
| "/docs/tags/search-engines"
|
||||||
| "/docs/tags/secrets"
|
| "/docs/tags/secrets"
|
||||||
| "/docs/tags/security"
|
| "/docs/tags/security"
|
||||||
| "/docs/tags/self-signed"
|
| "/docs/tags/self-signed"
|
||||||
@@ -132,15 +132,6 @@ 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"
|
||||||
@@ -205,6 +196,7 @@ 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"
|
||||||
@@ -266,6 +258,7 @@ 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"
|
||||||
@@ -312,6 +305,7 @@ 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"
|
||||||
@@ -357,6 +351,7 @@ 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,6 +36,7 @@ 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: null,
|
systemDisks: "system-disks",
|
||||||
timetable: "timetable",
|
timetable: "timetable",
|
||||||
"immich-serverStats": "immich-server-stats",
|
"immich-serverStats": "immich-server-stats",
|
||||||
"immich-albumCarousel": "immich-album-carousel",
|
"immich-albumCarousel": "immich-album-carousel",
|
||||||
|
|||||||
@@ -321,6 +321,14 @@ 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"],
|
systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
|
||||||
systemDisks: ["dashDot", "openmediavault", "truenas", "unraid"],
|
systemDisks: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
|
||||||
coolify: ["coolify"],
|
coolify: ["coolify"],
|
||||||
"immich-serverStats": ["immich"],
|
"immich-serverStats": ["immich"],
|
||||||
"immich-albumCarousel": ["immich"],
|
"immich-albumCarousel": ["immich"],
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ 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";
|
||||||
@@ -54,83 +55,86 @@ 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?`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const creator = integrationCreators[integration.kind];
|
return (await integrationCreators[integration.kind].create(integration)) as IntegrationInstanceByKind[TKind];
|
||||||
|
|
||||||
// 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 IntegrationInstance = new (integration: IntegrationInput) => Integration;
|
type IntegrationConstructor<TIntegration extends Integration = Integration> = new (
|
||||||
|
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: AnchorIntegration,
|
anchor: fromClass(AnchorIntegration),
|
||||||
piHole: [createPiHoleIntegrationAsync],
|
piHole: fromFactory(createPiHoleIntegrationAsync),
|
||||||
adGuardHome: AdGuardHomeIntegration,
|
adGuardHome: fromClass(AdGuardHomeIntegration),
|
||||||
technitiumDns: [createTechnitiumDnsIntegrationAsync],
|
technitiumDns: fromFactory(createTechnitiumDnsIntegrationAsync),
|
||||||
homeAssistant: HomeAssistantIntegration,
|
homeAssistant: fromClass(HomeAssistantIntegration),
|
||||||
jellyfin: JellyfinIntegration,
|
jellyfin: fromClass(JellyfinIntegration),
|
||||||
plex: PlexIntegration,
|
plex: fromClass(PlexIntegration),
|
||||||
sonarr: SonarrIntegration,
|
sonarr: fromClass(SonarrIntegration),
|
||||||
radarr: RadarrIntegration,
|
radarr: fromClass(RadarrIntegration),
|
||||||
sabNzbd: SabnzbdIntegration,
|
sabNzbd: fromClass(SabnzbdIntegration),
|
||||||
nzbGet: NzbGetIntegration,
|
nzbGet: fromClass(NzbGetIntegration),
|
||||||
qBittorrent: QBitTorrentIntegration,
|
qBittorrent: fromClass(QBitTorrentIntegration),
|
||||||
deluge: DelugeIntegration,
|
deluge: fromClass(DelugeIntegration),
|
||||||
transmission: TransmissionIntegration,
|
transmission: fromClass(TransmissionIntegration),
|
||||||
slskd: SlskdIntegration,
|
slskd: fromClass(SlskdIntegration),
|
||||||
aria2: Aria2Integration,
|
aria2: fromClass(Aria2Integration),
|
||||||
jellyseerr: JellyseerrIntegration,
|
jellyseerr: fromClass(JellyseerrIntegration),
|
||||||
seerr: SeerrIntegration,
|
seerr: fromClass(SeerrIntegration),
|
||||||
overseerr: OverseerrIntegration,
|
overseerr: fromClass(OverseerrIntegration),
|
||||||
prowlarr: ProwlarrIntegration,
|
prowlarr: fromClass(ProwlarrIntegration),
|
||||||
openmediavault: OpenMediaVaultIntegration,
|
openmediavault: fromClass(OpenMediaVaultIntegration),
|
||||||
lidarr: LidarrIntegration,
|
lidarr: fromClass(LidarrIntegration),
|
||||||
readarr: ReadarrIntegration,
|
readarr: fromClass(ReadarrIntegration),
|
||||||
dashDot: DashDotIntegration,
|
dashDot: fromClass(DashDotIntegration),
|
||||||
tdarr: TdarrIntegration,
|
tdarr: fromClass(TdarrIntegration),
|
||||||
proxmox: ProxmoxIntegration,
|
proxmox: fromClass(ProxmoxIntegration),
|
||||||
emby: EmbyIntegration,
|
emby: fromClass(EmbyIntegration),
|
||||||
nextcloud: NextcloudIntegration,
|
nextcloud: fromClass(NextcloudIntegration),
|
||||||
unifiController: UnifiControllerIntegration,
|
unifiController: fromClass(UnifiControllerIntegration),
|
||||||
opnsense: OPNsenseIntegration,
|
opnsense: fromClass(OPNsenseIntegration),
|
||||||
ical: ICalIntegration,
|
ical: fromClass(ICalIntegration),
|
||||||
ntfy: NTFYIntegration,
|
ntfy: fromClass(NTFYIntegration),
|
||||||
gotify: GotifyIntegration,
|
gotify: fromClass(GotifyIntegration),
|
||||||
mock: MockIntegration,
|
mock: fromClass(MockIntegration),
|
||||||
truenas: TrueNasIntegration,
|
truenas: fromClass(TrueNasIntegration),
|
||||||
unraid: UnraidIntegration,
|
synology: fromClass(SynologyIntegration),
|
||||||
coolify: CoolifyIntegration,
|
unraid: fromClass(UnraidIntegration),
|
||||||
tracearr: TracearrIntegration,
|
coolify: fromClass(CoolifyIntegration),
|
||||||
glances: GlancesIntegration,
|
tracearr: fromClass(TracearrIntegration),
|
||||||
immich: ImmichIntegration,
|
glances: fromClass(GlancesIntegration),
|
||||||
paperlessNgx: PaperlessNgxIntegration,
|
immich: fromClass(ImmichIntegration),
|
||||||
speedtestTracker: SpeedtestTrackerIntegration,
|
paperlessNgx: fromClass(PaperlessNgxIntegration),
|
||||||
audiobookshelf: AudiobookshelfIntegration,
|
speedtestTracker: fromClass(SpeedtestTrackerIntegration),
|
||||||
navidrome: NavidromeIntegration,
|
audiobookshelf: fromClass(AudiobookshelfIntegration),
|
||||||
umami: UmamiIntegration,
|
navidrome: fromClass(NavidromeIntegration),
|
||||||
gluetun: GluetunIntegration,
|
umami: fromClass(UmamiIntegration),
|
||||||
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
|
gluetun: fromClass(GluetunIntegration),
|
||||||
uptimeKuma: UptimeKumaIntegration,
|
archiveTeamWarrior: fromClass(ArchiveTeamWarriorIntegration),
|
||||||
peaNut: PeaNutIntegration,
|
uptimeKuma: fromClass(UptimeKumaIntegration),
|
||||||
beszel: BeszelIntegration,
|
peaNut: fromClass(PeaNutIntegration),
|
||||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
beszel: fromClass(BeszelIntegration),
|
||||||
|
} satisfies Record<IntegrationKind, IntegrationCreator>;
|
||||||
|
|
||||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
type IntegrationInstanceByKind = {
|
||||||
[kind in TKind]: (typeof integrationCreators)[kind] extends [(input: IntegrationInput) => Promise<Integration>]
|
[TKind in keyof typeof integrationCreators]: Awaited<ReturnType<(typeof integrationCreators)[TKind]["create"]>>;
|
||||||
? 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()),
|
hostnames: z.array(z.string()).nullable(),
|
||||||
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.number(),
|
listening_port: z.union([z.number(), z.array(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()),
|
addresses: z.array(z.string()).nullable(),
|
||||||
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,6 +124,126 @@ 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,
|
||||||
@@ -368,4 +488,54 @@ 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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -29,6 +29,7 @@ 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";
|
||||||
|
|||||||
@@ -0,0 +1,411 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
// @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,6 +165,7 @@ 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;
|
||||||
|
|
||||||
|
|||||||
@@ -2284,6 +2284,11 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
@@ -3538,6 +3543,11 @@
|
|||||||
},
|
},
|
||||||
"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": {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface CommonWidgetInputProps<TKey extends WidgetOptionType> {
|
|||||||
itemId?: string;
|
itemId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type UseWidgetInputTranslationReturnType = (key: "label" | "description") => string;
|
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Short description why as and unknown convertions are used below:
|
* Short description why as and unknown convertions are used below:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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,
|
||||||
@@ -37,6 +38,7 @@ 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) => {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
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: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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,6 +4,7 @@ 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,
|
||||||
@@ -28,6 +29,7 @@ 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: [
|
||||||
{
|
{
|
||||||
@@ -77,6 +79,11 @@ 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,8 +36,9 @@ 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 type { WidgetComponentProps } from "../definition";
|
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
|
||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
|
import type { WidgetComponentProps } from "../definition";
|
||||||
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";
|
||||||
@@ -65,7 +66,13 @@ 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 disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
|
const filteredFileSystem = filterStorageVolumes(
|
||||||
|
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
|
||||||
@@ -289,8 +296,12 @@ 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 baseDeviceName = fileSystem.deviceName.replace(/[0-9]+$/, "");
|
const normalizedFileSystemName = normalizeStorageDeviceName(fileSystem.deviceName);
|
||||||
const smartDisk = smartData.find((smart) => smart.deviceName === baseDeviceName);
|
const smartDisk = smartData.find(
|
||||||
|
(smart) =>
|
||||||
|
smart.deviceName === fileSystem.deviceName ||
|
||||||
|
normalizeStorageDeviceName(smart.deviceName) === normalizedFileSystemName,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deviceName: smartDisk?.deviceName ?? fileSystem.deviceName,
|
deviceName: smartDisk?.deviceName ?? fileSystem.deviceName,
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ 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;
|
||||||
@@ -196,6 +205,12 @@ 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 ?? "",
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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,6 +10,7 @@ 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"];
|
||||||
@@ -123,12 +124,25 @@ 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;
|
|
||||||
|
|
||||||
if (fileSystem.length === 0) {
|
const rawFileSystem = lastItem.healthInfo.fileSystem;
|
||||||
|
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) => {
|
||||||
|
|||||||
@@ -2,21 +2,32 @@ 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"],
|
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
|
||||||
createOptions() {
|
createOptions() {
|
||||||
return optionsBuilder.from((factory) => ({
|
return optionsBuilder.from(
|
||||||
showTemperatureIfAvailable: factory.switch({ defaultValue: true }),
|
(factory) => ({
|
||||||
displayMode: factory.select({
|
showTemperatureIfAvailable: factory.switch({ defaultValue: true }),
|
||||||
options: (["percentage", "absolute", "free"] as const).map((value) => ({
|
displayMode: factory.select({
|
||||||
value,
|
options: (["percentage", "absolute", "free"] as const).map((value) => ({
|
||||||
label: (t) => t(`widget.systemDisks.option.displayMode.option.${value}.label`),
|
value,
|
||||||
})),
|
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"],
|
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
|
||||||
createOptions() {
|
createOptions() {
|
||||||
return optionsBuilder.from((factory) => ({
|
return optionsBuilder.from((factory) => ({
|
||||||
hasShadow: factory.switch({ defaultValue: true }),
|
hasShadow: factory.switch({ defaultValue: true }),
|
||||||
|
|||||||
Generated
+89
-104
@@ -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.50
|
specifier: ^0.9.51
|
||||||
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.9
|
specifier: 16.2.10
|
||||||
version: 16.2.9
|
version: 16.2.10
|
||||||
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.2'
|
vite@>=5.0.0 <=5.4.18: '>=8.1.3'
|
||||||
|
|
||||||
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.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)
|
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)
|
||||||
'@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.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)
|
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)
|
||||||
'@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.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))
|
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))
|
||||||
next:
|
next:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
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)
|
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)
|
||||||
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.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)
|
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)
|
||||||
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.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)
|
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)
|
||||||
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.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)
|
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)
|
||||||
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.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)
|
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)
|
||||||
next-auth:
|
next-auth:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
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)
|
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)
|
||||||
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.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)
|
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)
|
||||||
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.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)
|
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)
|
||||||
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.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)
|
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)
|
||||||
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.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)
|
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)
|
||||||
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.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)
|
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)
|
||||||
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.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)
|
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)
|
||||||
next-intl:
|
next-intl:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
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)
|
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)
|
||||||
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.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)
|
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)
|
||||||
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.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)
|
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)
|
||||||
react:
|
react:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
@@ -4243,9 +4243,6 @@ 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==}
|
||||||
|
|
||||||
@@ -5734,57 +5731,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.9':
|
'@next/env@16.2.10':
|
||||||
resolution: {integrity: sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==}
|
resolution: {integrity: sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==}
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@16.2.9':
|
'@next/swc-darwin-arm64@16.2.10':
|
||||||
resolution: {integrity: sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==}
|
resolution: {integrity: sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-darwin-x64@16.2.9':
|
'@next/swc-darwin-x64@16.2.10':
|
||||||
resolution: {integrity: sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==}
|
resolution: {integrity: sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@16.2.9':
|
'@next/swc-linux-arm64-gnu@16.2.10':
|
||||||
resolution: {integrity: sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==}
|
resolution: {integrity: sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==}
|
||||||
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.9':
|
'@next/swc-linux-arm64-musl@16.2.10':
|
||||||
resolution: {integrity: sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==}
|
resolution: {integrity: sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==}
|
||||||
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.9':
|
'@next/swc-linux-x64-gnu@16.2.10':
|
||||||
resolution: {integrity: sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==}
|
resolution: {integrity: sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==}
|
||||||
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.9':
|
'@next/swc-linux-x64-musl@16.2.10':
|
||||||
resolution: {integrity: sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==}
|
resolution: {integrity: sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==}
|
||||||
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.9':
|
'@next/swc-win32-arm64-msvc@16.2.10':
|
||||||
resolution: {integrity: sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==}
|
resolution: {integrity: sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@16.2.9':
|
'@next/swc-win32-x64-msvc@16.2.10':
|
||||||
resolution: {integrity: sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==}
|
resolution: {integrity: sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
@@ -12029,11 +12026,6 @@ 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}
|
||||||
@@ -12096,8 +12088,8 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
next@16.2.9:
|
next@16.2.10:
|
||||||
resolution: {integrity: sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==}
|
resolution: {integrity: sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -14916,7 +14908,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.2'
|
vite: '>=8.1.3'
|
||||||
|
|
||||||
vite@8.1.3:
|
vite@8.1.3:
|
||||||
resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==}
|
resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==}
|
||||||
@@ -17955,11 +17947,6 @@ 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
|
||||||
@@ -18560,7 +18547,7 @@ snapshots:
|
|||||||
|
|
||||||
'@img/sharp-wasm32@0.34.5':
|
'@img/sharp-wasm32@0.34.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/runtime': 1.10.0
|
'@emnapi/runtime': 1.11.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/sharp-win32-arm64@0.34.5':
|
'@img/sharp-win32-arm64@0.34.5':
|
||||||
@@ -19247,30 +19234,30 @@ snapshots:
|
|||||||
'@tybys/wasm-util': 0.10.3
|
'@tybys/wasm-util': 0.10.3
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/env@16.2.9': {}
|
'@next/env@16.2.10': {}
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@16.2.9':
|
'@next/swc-darwin-arm64@16.2.10':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-darwin-x64@16.2.9':
|
'@next/swc-darwin-x64@16.2.10':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@16.2.9':
|
'@next/swc-linux-arm64-gnu@16.2.10':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@16.2.9':
|
'@next/swc-linux-arm64-musl@16.2.10':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@16.2.9':
|
'@next/swc-linux-x64-gnu@16.2.10':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@16.2.9':
|
'@next/swc-linux-x64-musl@16.2.10':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@16.2.9':
|
'@next/swc-win32-arm64-msvc@16.2.10':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@16.2.9':
|
'@next/swc-win32-x64-msvc@16.2.10':
|
||||||
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)':
|
||||||
@@ -20186,7 +20173,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.11
|
nanoid: 5.1.16
|
||||||
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
|
||||||
@@ -20255,7 +20242,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.11
|
nanoid: 5.1.16
|
||||||
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:
|
||||||
@@ -20403,15 +20390,15 @@ snapshots:
|
|||||||
|
|
||||||
'@scalar/themes@0.16.2':
|
'@scalar/themes@0.16.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 5.1.11
|
nanoid: 5.1.16
|
||||||
|
|
||||||
'@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.11
|
nanoid: 5.1.16
|
||||||
type-fest: 5.6.0
|
type-fest: 5.7.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)':
|
||||||
@@ -20466,7 +20453,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.6.0
|
type-fest: 5.7.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:
|
||||||
@@ -20951,10 +20938,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.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-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)':
|
||||||
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.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: 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
|
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)':
|
||||||
@@ -21238,11 +21225,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.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/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)':
|
||||||
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.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: 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
|
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
|
||||||
@@ -22376,7 +22363,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.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)):
|
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)):
|
||||||
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))
|
||||||
@@ -22400,7 +22387,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.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: 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
|
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)
|
||||||
@@ -25655,23 +25642,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.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)):
|
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)):
|
||||||
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.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: 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)):
|
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)):
|
||||||
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.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: 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)
|
||||||
|
|
||||||
mdast-util-directive@3.1.0:
|
mdast-util-directive@3.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -26381,8 +26368,6 @@ 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: {}
|
||||||
@@ -26401,22 +26386,22 @@ snapshots:
|
|||||||
|
|
||||||
neverpanic@0.0.8: {}
|
neverpanic@0.0.8: {}
|
||||||
|
|
||||||
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):
|
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):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@auth/core': 0.41.2
|
'@auth/core': 0.41.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)
|
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
|
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.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):
|
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):
|
||||||
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.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: 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-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
|
||||||
@@ -26426,9 +26411,9 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@swc/helpers'
|
- '@swc/helpers'
|
||||||
|
|
||||||
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@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):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 16.2.9
|
'@next/env': 16.2.10
|
||||||
'@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
|
||||||
@@ -26437,14 +26422,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.9
|
'@next/swc-darwin-arm64': 16.2.10
|
||||||
'@next/swc-darwin-x64': 16.2.9
|
'@next/swc-darwin-x64': 16.2.10
|
||||||
'@next/swc-linux-arm64-gnu': 16.2.9
|
'@next/swc-linux-arm64-gnu': 16.2.10
|
||||||
'@next/swc-linux-arm64-musl': 16.2.9
|
'@next/swc-linux-arm64-musl': 16.2.10
|
||||||
'@next/swc-linux-x64-gnu': 16.2.9
|
'@next/swc-linux-x64-gnu': 16.2.10
|
||||||
'@next/swc-linux-x64-musl': 16.2.9
|
'@next/swc-linux-x64-musl': 16.2.10
|
||||||
'@next/swc-win32-arm64-msvc': 16.2.9
|
'@next/swc-win32-arm64-msvc': 16.2.10
|
||||||
'@next/swc-win32-x64-msvc': 16.2.9
|
'@next/swc-win32-x64-msvc': 16.2.10
|
||||||
'@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
|
||||||
@@ -29219,23 +29204,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.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):
|
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):
|
||||||
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.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))
|
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.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))
|
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
|
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.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-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):
|
||||||
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.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))
|
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.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))
|
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
|
zod: 4.4.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@cfworker/json-schema'
|
- '@cfworker/json-schema'
|
||||||
|
|||||||
+4
-4
@@ -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.9
|
"@next/eslint-plugin-next": 16.2.10
|
||||||
"@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.50
|
"@scalar/api-reference-react": ^0.9.51
|
||||||
"@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.9
|
next: 16.2.10
|
||||||
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.2"
|
"vite@>=5.0.0 <=5.4.18": ">=8.1.3"
|
||||||
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"
|
||||||
|
|||||||
Reference in new issue
Block a user