Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
387b42f78b | ||
|
|
0dd021e17d | ||
|
|
a2bb53a8b8 | ||
|
|
f56a05bd8e | ||
|
|
6051b17401 | ||
|
|
29eed08408 | ||
|
|
bd74fe4a61 | ||
|
|
b88e8861e1 | ||
|
|
39af3ec7e6 | ||
|
|
7f50be3dbb | ||
|
|
b6b7bea1dd | ||
|
|
9bde1dfbd6 | ||
|
|
c2712ed7f1 | ||
|
|
16be7ece33 | ||
|
|
b0a46186d1 | ||
|
|
d0385a4480 | ||
|
|
3d6a3594a3 | ||
|
|
641ee18278 | ||
|
|
2e538bf40d | ||
|
|
1c0a94a6dc | ||
|
|
3a3dc449de | ||
|
|
d6b156b8ee | ||
|
|
fc4e31fb42 | ||
|
|
b82b22431b | ||
|
|
510cba295a | ||
|
|
56d838dafc | ||
|
|
eae9f0438c | ||
|
|
b81f729bfd | ||
|
|
86b83c5425 | ||
|
|
bbc4589c9e | ||
|
|
ea9dd716f0 | ||
|
|
4383da38ef |
No files matched your search
@@ -66,7 +66,7 @@ jobs:
|
|||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
if: env.SKIP_RELEASE == 'false'
|
if: env.SKIP_RELEASE == 'false'
|
||||||
with:
|
with:
|
||||||
node-version: 24.16.0
|
node-version: 24.17.0
|
||||||
package-manager-cache: false
|
package-manager-cache: false
|
||||||
- run: npm i -g pnpm
|
- run: npm i -g pnpm
|
||||||
if: env.SKIP_RELEASE == 'false'
|
if: env.SKIP_RELEASE == 'false'
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
[tools]
|
[tools]
|
||||||
node = "24.16.0"
|
node = "24.17.0"
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
# ── CODEOWNERS ───────────────────────────────────────
|
# ── CODEOWNERS ───────────────────────────────────────
|
||||||
* @homarr-labs/maintainers
|
* @homarr-labs/maintainers
|
||||||
|
|
||||||
|
# Specific features / integrations
|
||||||
|
# Glueten
|
||||||
|
apps/docs/docs/integrations/glueten @Lok3sh-kumar
|
||||||
|
packages/integrations/src/gluetun @Lok3sh-kumar
|
||||||
|
|
||||||
# Exempt Renovate‑managed files (no owners)
|
# Exempt Renovate‑managed files (no owners)
|
||||||
package.json
|
package.json
|
||||||
**/package.json
|
**/package.json
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24.16.0-alpine AS base
|
FROM node:24.17.0-alpine AS base
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
title: 'Gluetun'
|
||||||
|
description: 'Connect Gluetun to Homarr as a VPN integration.'
|
||||||
|
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 { gluetunIntegration } from '.';
|
||||||
|
import { vpnWidget } from '@site/docs/widgets/vpn';
|
||||||
|
|
||||||
|
<IntegrationHeader
|
||||||
|
integration={gluetunIntegration}
|
||||||
|
categories={['VPN', 'Network']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
[Gluetun](https://github.com/qdm12/gluetun) exposes a control server that Homarr can query to report the VPN tunnel
|
||||||
|
status, DNS status, public IP address, and active provider details in the VPN widget.
|
||||||
|
|
||||||
|
### Widgets & Capabilities
|
||||||
|
|
||||||
|
<IntegrationCapabilites
|
||||||
|
items={[{
|
||||||
|
widget: vpnWidget,
|
||||||
|
}]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
### Setting up the Gluetun control server
|
||||||
|
|
||||||
|
Refer to the [Gluetun control server documentation](https://github.com/qdm12/gluetun-wiki/blob/main/setup/advanced/control-server.md#authentication) to enable and configure authentication.
|
||||||
|
|
||||||
|
### Adding the integration
|
||||||
|
|
||||||
|
<AddingIntegration />
|
||||||
|
|
||||||
|
### Secrets
|
||||||
|
|
||||||
|
Gluetun's control server can be left unprotected or secured with either an API key or HTTP Basic authentication.
|
||||||
|
Choose the option that matches how your Gluetun instance integration is set up.
|
||||||
|
|
||||||
|
<IntegrationSecrets secrets={[{
|
||||||
|
credentials: [],
|
||||||
|
steps: [],
|
||||||
|
},{
|
||||||
|
credentials: ['username', 'password'],
|
||||||
|
steps: [],
|
||||||
|
},{
|
||||||
|
credentials: ['apiKey'],
|
||||||
|
steps: [],
|
||||||
|
},,]} />
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { IntegrationDefinition } from "@site/src/types";
|
||||||
|
|
||||||
|
export const gluetunIntegration: IntegrationDefinition = {
|
||||||
|
name: "Gluetun",
|
||||||
|
description: "VPN control server integration for Homarr's VPN widget.",
|
||||||
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gluetun.svg",
|
||||||
|
path: "../../integrations/gluetun",
|
||||||
|
};
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
title: 'VPN'
|
||||||
|
description: 'Monitor the status of your VPN integrations.'
|
||||||
|
hide_title: true
|
||||||
|
---
|
||||||
|
|
||||||
|
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||||
|
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||||
|
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||||
|
import { vpnWidget } from '.';
|
||||||
|
import { gluetunIntegration } from '@site/docs/integrations/gluetun';
|
||||||
|
|
||||||
|
<WidgetHeader
|
||||||
|
widget={vpnWidget}
|
||||||
|
categories={['Network', 'Security']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
The VPN widget provides a compact overview of each configured VPN integration. It shows whether the tunnel and DNS
|
||||||
|
services are up, the currently exposed public IP address, and the VPN provider and protocol being used.
|
||||||
|
|
||||||
|
When multiple VPN integrations are attached, the widget renders one card per integration so you can compare their
|
||||||
|
current status at a glance.
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|
import singleIntegrationImage from './img/single.png';
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<img src={singleIntegrationImage} alt="VPN widget showing a single integration" width={128 * 3} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
### Supported Integrations
|
||||||
|
|
||||||
|
<WidgetIntegrations items={[{
|
||||||
|
integration: gluetunIntegration,
|
||||||
|
}]} />
|
||||||
|
|
||||||
|
### Adding the widget
|
||||||
|
|
||||||
|
<AddingWidget />
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { WidgetDefinition } from "@site/src/types";
|
||||||
|
import { IconShieldLock } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export const vpnWidget: WidgetDefinition = {
|
||||||
|
icon: IconShieldLock,
|
||||||
|
name: "VPN",
|
||||||
|
description: "Monitor the connection status, public IP, and provider details of your VPN integrations.",
|
||||||
|
path: "../../widgets/vpn",
|
||||||
|
};
|
||||||
@@ -211,7 +211,17 @@ describe("SQLite backup", () => {
|
|||||||
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
|
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
|
||||||
expect(decryptedAfter).toBe(plaintext);
|
expect(decryptedAfter).toBe(plaintext);
|
||||||
|
|
||||||
expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
|
// The old key must no longer recover the plaintext. With AES-256-CBC this
|
||||||
|
// usually fails PKCS7 padding validation (throws), but ~1/256 of the time the
|
||||||
|
// wrong key yields valid padding and returns garbage instead of throwing, so
|
||||||
|
// assert on the actual property (plaintext is unrecoverable) rather than throwing.
|
||||||
|
let decryptedWithOldKey: string | null = null;
|
||||||
|
try {
|
||||||
|
decryptedWithOldKey = decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey);
|
||||||
|
} catch {
|
||||||
|
// Expected for most wrong keys: padding validation fails.
|
||||||
|
}
|
||||||
|
expect(decryptedWithOldKey).not.toBe(plaintext);
|
||||||
|
|
||||||
sqlite.close();
|
sqlite.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -142,7 +142,8 @@ export const useBackupAnalysis = () => {
|
|||||||
phase: "error",
|
phase: "error",
|
||||||
});
|
});
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`, { cause: migrationErr },
|
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`,
|
||||||
|
{ cause: migrationErr },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,12 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
|
|||||||
name: t(`widget.${kind}.name`),
|
name: t(`widget.${kind}.name`),
|
||||||
description: t(`widget.${kind}.description`),
|
description: t(`widget.${kind}.description`),
|
||||||
}))
|
}))
|
||||||
.sort((itemA, itemB) => itemA.name.localeCompare(itemB.name)),
|
.sort((itemA, itemB) => {
|
||||||
|
if (itemA.kind === "app") return -1;
|
||||||
|
if (itemB.kind === "app") return 1;
|
||||||
|
|
||||||
|
return itemA.name.localeCompare(itemB.name);
|
||||||
|
}),
|
||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export const categoryTranslationKeys: Record<string, string> = {
|
|||||||
mediaMonitoring: "integration.category.mediaMonitoring",
|
mediaMonitoring: "integration.category.mediaMonitoring",
|
||||||
speedtest: "integration.category.speedtest",
|
speedtest: "integration.category.speedtest",
|
||||||
analytics: "integration.category.analytics",
|
analytics: "integration.category.analytics",
|
||||||
|
vpn: "integration.category.vpn",
|
||||||
documents: "integration.category.documents",
|
documents: "integration.category.documents",
|
||||||
mediaLibrary: "integration.category.mediaLibrary",
|
mediaLibrary: "integration.category.mediaLibrary",
|
||||||
uptimeMonitoring: "integration.category.uptimeMonitoring",
|
uptimeMonitoring: "integration.category.uptimeMonitoring",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24.16.0-trixie AS base
|
FROM node:24.17.0-trixie AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json .
|
COPY package.json .
|
||||||
COPY pnpm-lock.yaml .
|
COPY pnpm-lock.yaml .
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
"better-sqlite3": "^12.10.0"
|
"better-sqlite3": "^12.10.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.16.0",
|
"node": ">=24.17.0",
|
||||||
"pnpm": ">=11.7.0"
|
"pnpm": ">=11.8.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.7.0"
|
"packageManager": "pnpm@11.8.0"
|
||||||
}
|
}
|
||||||
+3
-3
@@ -61,10 +61,10 @@
|
|||||||
"vitest": "catalog:"
|
"vitest": "catalog:"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24.16.0",
|
"node": ">=24.17.0",
|
||||||
"pnpm": ">=11.7.0"
|
"pnpm": ">=11.8.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.7.0",
|
"packageManager": "pnpm@11.8.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jsonpath-plus": "catalog:"
|
"jsonpath-plus": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const widgetRouter = createTRPCRouter({
|
|||||||
uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)),
|
uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)),
|
||||||
audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)),
|
audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)),
|
||||||
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
|
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
|
||||||
|
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
|
||||||
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
|
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
|
||||||
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
|
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { observable } from "@trpc/server/observable";
|
||||||
|
|
||||||
|
import type { Modify } from "@homarr/common/types";
|
||||||
|
import type { Integration } from "@homarr/db/schema";
|
||||||
|
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||||
|
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
|
import type { VpnSummary } from "@homarr/integrations/types";
|
||||||
|
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
|
||||||
|
|
||||||
|
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||||
|
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||||
|
|
||||||
|
const createVpnIntegrationMiddleware = () =>
|
||||||
|
createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"));
|
||||||
|
|
||||||
|
export const vpnRouter = createTRPCRouter({
|
||||||
|
getSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).query(async ({ ctx }) => {
|
||||||
|
return await Promise.all(
|
||||||
|
ctx.integrations.map(async (integration) => {
|
||||||
|
const innerHandler = vpnSummaryHandler.handler(integration, {});
|
||||||
|
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||||
|
|
||||||
|
return {
|
||||||
|
integration: {
|
||||||
|
id: integration.id,
|
||||||
|
name: integration.name,
|
||||||
|
kind: integration.kind,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
},
|
||||||
|
summary: data,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
subscribeSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).subscription(({ ctx }) => {
|
||||||
|
return observable<{
|
||||||
|
integration: Modify<Integration, { kind: IntegrationKindByCategory<"vpn"> }>;
|
||||||
|
summary: VpnSummary | null;
|
||||||
|
}>((emit) => {
|
||||||
|
const unsubscribes: (() => void)[] = [];
|
||||||
|
for (const integrationWithSecrets of ctx.integrations) {
|
||||||
|
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
|
||||||
|
const innerHandler = vpnSummaryHandler.handler(integrationWithSecrets, {});
|
||||||
|
const unsubscribe = innerHandler.subscribe((summary) => {
|
||||||
|
emit.next({
|
||||||
|
integration,
|
||||||
|
summary,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
unsubscribes.push(unsubscribe);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
unsubscribes.forEach((unsubscribe) => {
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ import { refreshNotificationsJob } from "./jobs/integrations/notifications";
|
|||||||
import { timetableJob } from "./jobs/integrations/timetable";
|
import { timetableJob } from "./jobs/integrations/timetable";
|
||||||
import { tracearrJob } from "./jobs/integrations/tracearr";
|
import { tracearrJob } from "./jobs/integrations/tracearr";
|
||||||
import { umamiActiveVisitorsJob } from "./jobs/integrations/umami";
|
import { umamiActiveVisitorsJob } from "./jobs/integrations/umami";
|
||||||
|
import { vpnJob } from "./jobs/integrations/vpn";
|
||||||
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
|
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
|
||||||
import { pingJob } from "./jobs/ping";
|
import { pingJob } from "./jobs/ping";
|
||||||
import { rssFeedsJob } from "./jobs/rss-feeds";
|
import { rssFeedsJob } from "./jobs/rss-feeds";
|
||||||
@@ -57,6 +58,7 @@ const getJobGroup = () => {
|
|||||||
timetable: timetableJob,
|
timetable: timetableJob,
|
||||||
tracearr: tracearrJob,
|
tracearr: tracearrJob,
|
||||||
umamiActiveVisitors: umamiActiveVisitorsJob,
|
umamiActiveVisitors: umamiActiveVisitorsJob,
|
||||||
|
vpn: vpnJob,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { EVERY_30_SECONDS } from "@homarr/cron-jobs-core/expressions";
|
||||||
|
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
|
||||||
|
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
|
||||||
|
|
||||||
|
import { createCronJob } from "../../lib";
|
||||||
|
|
||||||
|
export const vpnJob = createCronJob("vpn", EVERY_30_SECONDS).withCallback(
|
||||||
|
createRequestIntegrationJobHandler(vpnSummaryHandler.handler, {
|
||||||
|
widgetKinds: ["vpn"],
|
||||||
|
getInput: {
|
||||||
|
vpn: () => ({}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -179,6 +179,7 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/integrations/github"
|
| "/docs/integrations/github"
|
||||||
| "/docs/integrations/gitlab"
|
| "/docs/integrations/gitlab"
|
||||||
| "/docs/integrations/glances"
|
| "/docs/integrations/glances"
|
||||||
|
| "/docs/integrations/gluetun"
|
||||||
| "/docs/integrations/gotify"
|
| "/docs/integrations/gotify"
|
||||||
| "/docs/integrations/home-assistant"
|
| "/docs/integrations/home-assistant"
|
||||||
| "/docs/integrations/ical"
|
| "/docs/integrations/ical"
|
||||||
@@ -280,6 +281,7 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/widgets/ups"
|
| "/docs/widgets/ups"
|
||||||
| "/docs/widgets/uptime-kuma"
|
| "/docs/widgets/uptime-kuma"
|
||||||
| "/docs/widgets/video"
|
| "/docs/widgets/video"
|
||||||
|
| "/docs/widgets/vpn"
|
||||||
| "/docs/widgets/weather"
|
| "/docs/widgets/weather"
|
||||||
| ""
|
| ""
|
||||||
| "/sitemap.xml"
|
| "/sitemap.xml"
|
||||||
@@ -337,6 +339,7 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/integrations/audiobookshelf"
|
| "/docs/integrations/audiobookshelf"
|
||||||
| "/docs/integrations/navidrome"
|
| "/docs/integrations/navidrome"
|
||||||
| "/docs/integrations/umami"
|
| "/docs/integrations/umami"
|
||||||
|
| "/docs/integrations/gluetun"
|
||||||
| "/docs/integrations/peanut"
|
| "/docs/integrations/peanut"
|
||||||
| "/docs/integrations/beszel"
|
| "/docs/integrations/beszel"
|
||||||
| "/docs/widgets/clock"
|
| "/docs/widgets/clock"
|
||||||
@@ -380,6 +383,7 @@ export type HomarrDocumentationPath =
|
|||||||
| "/docs/widgets/uptime-kuma"
|
| "/docs/widgets/uptime-kuma"
|
||||||
| "/docs/widgets/audio-stats"
|
| "/docs/widgets/audio-stats"
|
||||||
| "/docs/widgets/umami"
|
| "/docs/widgets/umami"
|
||||||
|
| "/docs/widgets/vpn"
|
||||||
| "/docs/widgets/ups"
|
| "/docs/widgets/ups"
|
||||||
| "/docs/widgets/beszel-system-table"
|
| "/docs/widgets/beszel-system-table"
|
||||||
| "/docs/widgets/beszel-system-grid"
|
| "/docs/widgets/beszel-system-grid"
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
|||||||
audiobookshelf: "audiobookshelf",
|
audiobookshelf: "audiobookshelf",
|
||||||
navidrome: "navidrome",
|
navidrome: "navidrome",
|
||||||
umami: "umami",
|
umami: "umami",
|
||||||
|
gluetun: "gluetun",
|
||||||
peaNut: "peanut",
|
peaNut: "peanut",
|
||||||
beszel: "beszel",
|
beszel: "beszel",
|
||||||
archiveTeamWarrior: null,
|
archiveTeamWarrior: null,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
|||||||
uptimeKuma: "uptime-kuma",
|
uptimeKuma: "uptime-kuma",
|
||||||
audioStats: "audio-stats",
|
audioStats: "audio-stats",
|
||||||
umami: "umami",
|
umami: "umami",
|
||||||
|
vpn: "vpn",
|
||||||
ups: "ups",
|
ups: "ups",
|
||||||
archiveTeamWarrior: null,
|
archiveTeamWarrior: null,
|
||||||
beszelSystemTable: "beszel-system-table",
|
beszelSystemTable: "beszel-system-table",
|
||||||
|
|||||||
@@ -489,6 +489,13 @@ export const integrationDefs = {
|
|||||||
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
|
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
|
||||||
defaultPort: 8090,
|
defaultPort: 8090,
|
||||||
},
|
},
|
||||||
|
gluetun: {
|
||||||
|
name: "Gluetun",
|
||||||
|
secretKinds: [["username", "password"], ["apiKey"], []],
|
||||||
|
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gluetun.svg",
|
||||||
|
category: ["vpn"],
|
||||||
|
documentationUrl: createDocumentationLink("/docs/integrations/gluetun"),
|
||||||
|
},
|
||||||
archiveTeamWarrior: {
|
archiveTeamWarrior: {
|
||||||
name: "ArchiveTeam Warrior",
|
name: "ArchiveTeam Warrior",
|
||||||
secretKinds: [[], ["username", "password"]],
|
secretKinds: [[], ["username", "password"]],
|
||||||
@@ -604,6 +611,7 @@ export const integrationCategories = [
|
|||||||
"mediaMonitoring",
|
"mediaMonitoring",
|
||||||
"speedtest",
|
"speedtest",
|
||||||
"analytics",
|
"analytics",
|
||||||
|
"vpn",
|
||||||
"archiving",
|
"archiving",
|
||||||
"ups",
|
"ups",
|
||||||
"documents",
|
"documents",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
|||||||
speedtestTracker: ["speedtestTracker"],
|
speedtestTracker: ["speedtestTracker"],
|
||||||
uptimeKuma: ["uptimeKuma"],
|
uptimeKuma: ["uptimeKuma"],
|
||||||
audioStats: ["navidrome", "audiobookshelf"],
|
audioStats: ["navidrome", "audiobookshelf"],
|
||||||
|
vpn: getIntegrationKindsByCategory("vpn"),
|
||||||
anchorNote: ["anchor"],
|
anchorNote: ["anchor"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const widgetKinds = [
|
|||||||
"uptimeKuma",
|
"uptimeKuma",
|
||||||
"audioStats",
|
"audioStats",
|
||||||
"umami",
|
"umami",
|
||||||
|
"vpn",
|
||||||
"archiveTeamWarrior",
|
"archiveTeamWarrior",
|
||||||
"ups",
|
"ups",
|
||||||
"beszelSystemTable",
|
"beszelSystemTable",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { GitHubContainerRegistryIntegration } from "../github-container-registry
|
|||||||
import { GithubIntegration } from "../github/github-integration";
|
import { GithubIntegration } from "../github/github-integration";
|
||||||
import { GitlabIntegration } from "../gitlab/gitlab-integration";
|
import { GitlabIntegration } from "../gitlab/gitlab-integration";
|
||||||
import { GlancesIntegration } from "../glances/glances-integration";
|
import { GlancesIntegration } from "../glances/glances-integration";
|
||||||
|
import { GluetunIntegration } from "../gluetun/gluetun-integration";
|
||||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||||
import { ICalIntegration } from "../ical/ical-integration";
|
import { ICalIntegration } from "../ical/ical-integration";
|
||||||
import { ImmichIntegration } from "../immich/immich-integration";
|
import { ImmichIntegration } from "../immich/immich-integration";
|
||||||
@@ -137,6 +138,7 @@ export const integrationCreators = {
|
|||||||
audiobookshelf: AudiobookshelfIntegration,
|
audiobookshelf: AudiobookshelfIntegration,
|
||||||
navidrome: NavidromeIntegration,
|
navidrome: NavidromeIntegration,
|
||||||
umami: UmamiIntegration,
|
umami: UmamiIntegration,
|
||||||
|
gluetun: GluetunIntegration,
|
||||||
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
|
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
|
||||||
uptimeKuma: UptimeKumaIntegration,
|
uptimeKuma: UptimeKumaIntegration,
|
||||||
peaNut: PeaNutIntegration,
|
peaNut: PeaNutIntegration,
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { ResponseError } from "@homarr/common/server";
|
||||||
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationTestingInput } from "../base/integration";
|
||||||
|
import { Integration } from "../base/integration";
|
||||||
|
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||||
|
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||||
|
import type { VpnSummaryIntegration } from "../interfaces/vpn-summary/vpn-summary-integration";
|
||||||
|
import type { VpnSummary } from "../interfaces/vpn-summary/vpn-summary-types";
|
||||||
|
import {
|
||||||
|
gluetunDnsStatusSchema,
|
||||||
|
gluetunPublicIpSchema,
|
||||||
|
gluetunVpnSettingsSchema,
|
||||||
|
gluetunVpnStatusSchema,
|
||||||
|
} from "./gluetun-types";
|
||||||
|
import type { GluetunDnsStatus, GluetunPublicIp, GluetunVpnSettings, GluetunVpnStatus } from "./gluetun-types";
|
||||||
|
|
||||||
|
export class GluetunIntegration extends Integration implements VpnSummaryIntegration {
|
||||||
|
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||||
|
const authHeaders = this.getAuthHeaders();
|
||||||
|
const url = this.url("/v1/vpn/status");
|
||||||
|
const response = await input.fetchAsync(url, {
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return TestConnectionError.StatusResult(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getSummaryAsync(): Promise<VpnSummary> {
|
||||||
|
const authHeaders = this.getAuthHeaders();
|
||||||
|
const [vpnStatus, dnsStatus, publicIp, vpnSettings] = await Promise.all([
|
||||||
|
this.getVpnStatusAsync(authHeaders),
|
||||||
|
this.getDnsStatusAsync(authHeaders),
|
||||||
|
this.getPublicIpAsync(authHeaders),
|
||||||
|
this.getVpnSettingAsync(authHeaders),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
vpnStatus: vpnStatus.status,
|
||||||
|
dnsStatus: dnsStatus.status,
|
||||||
|
publicIp: publicIp.public_ip,
|
||||||
|
country: publicIp.country,
|
||||||
|
city: publicIp.city,
|
||||||
|
vpnProvider: {
|
||||||
|
protocol: vpnSettings.type,
|
||||||
|
provider: vpnSettings.provider.name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getVpnStatusAsync(authHeaders: Record<string, string>): Promise<GluetunVpnStatus> {
|
||||||
|
const url = this.url("/v1/vpn/status");
|
||||||
|
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new ResponseError(response);
|
||||||
|
return gluetunVpnStatusSchema.parse(await response.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDnsStatusAsync(authHeaders: Record<string, string>): Promise<GluetunDnsStatus> {
|
||||||
|
const url = this.url("/v1/dns/status");
|
||||||
|
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new ResponseError(response);
|
||||||
|
return gluetunDnsStatusSchema.parse(await response.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPublicIpAsync(authHeaders: Record<string, string>): Promise<GluetunPublicIp> {
|
||||||
|
const url = this.url("/v1/publicip/ip");
|
||||||
|
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new ResponseError(response);
|
||||||
|
return gluetunPublicIpSchema.parse(await response.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getVpnSettingAsync(authHeaders: Record<string, string>): Promise<GluetunVpnSettings> {
|
||||||
|
const url = this.url("/v1/vpn/settings");
|
||||||
|
|
||||||
|
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new ResponseError(response);
|
||||||
|
return gluetunVpnSettingsSchema.parse(await response.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAuthHeaders(): Record<string, string> {
|
||||||
|
if (this.hasSecretValue("apiKey")) {
|
||||||
|
return { "X-API-Key": this.getSecretValue("apiKey") };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.hasSecretValue("username") && this.hasSecretValue("password")) {
|
||||||
|
const username = this.getSecretValue("username");
|
||||||
|
const password = this.getSecretValue("password");
|
||||||
|
|
||||||
|
const encodedCredentials = btoa(`${username}:${password}`);
|
||||||
|
|
||||||
|
return { Authorization: `Basic ${encodedCredentials}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
export const gluetunVpnStatusSchema = z.object({
|
||||||
|
status: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GluetunVpnStatus = z.infer<typeof gluetunVpnStatusSchema>;
|
||||||
|
|
||||||
|
export const gluetunDnsStatusSchema = gluetunVpnStatusSchema;
|
||||||
|
|
||||||
|
export type GluetunDnsStatus = z.infer<typeof gluetunDnsStatusSchema>;
|
||||||
|
|
||||||
|
export const gluetunPublicIpSchema = z.object({
|
||||||
|
public_ip: z.string(),
|
||||||
|
region: z.string(),
|
||||||
|
country: z.string(),
|
||||||
|
city: z.string(),
|
||||||
|
location: z.string(),
|
||||||
|
organization: z.string(),
|
||||||
|
postal_code: z.string(),
|
||||||
|
timezone: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GluetunPublicIp = z.infer<typeof gluetunPublicIpSchema>;
|
||||||
|
|
||||||
|
const gluetunServerSelectionOpenvpnSchema = z.object({
|
||||||
|
config_file_path: z.string(),
|
||||||
|
protocol: z.string(),
|
||||||
|
endpoint_ip: z.string(),
|
||||||
|
custom_port: z.number(),
|
||||||
|
pia_encryption_preset: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const gluetunServerSelectionWireguardSchema = z.object({
|
||||||
|
endpoint_ip: z.string(),
|
||||||
|
endpoint_port: z.number(),
|
||||||
|
public_key: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const gluetunServerSelectionSchema = z.object({
|
||||||
|
vpn: z.string(),
|
||||||
|
countries: z.array(z.string()).nullable(),
|
||||||
|
categories: z.array(z.string()).nullable(),
|
||||||
|
regions: z.array(z.string()).nullable(),
|
||||||
|
cities: z.array(z.string()).nullable(),
|
||||||
|
isps: z.array(z.string()).nullable(),
|
||||||
|
names: z.array(z.string()).nullable(),
|
||||||
|
numbers: z.array(z.number()).nullable(),
|
||||||
|
hostnames: z.array(z.string()),
|
||||||
|
owned_only: z.boolean(),
|
||||||
|
free_only: z.boolean(),
|
||||||
|
premium_only: z.boolean(),
|
||||||
|
stream_only: z.boolean(),
|
||||||
|
multi_hop_only: z.boolean(),
|
||||||
|
port_forward_only: z.boolean(),
|
||||||
|
secure_core_only: z.boolean(),
|
||||||
|
tor_only: z.boolean(),
|
||||||
|
openvpn: gluetunServerSelectionOpenvpnSchema,
|
||||||
|
wireguard: gluetunServerSelectionWireguardSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const gluetunPortForwardingSchema = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
provider: z.string(),
|
||||||
|
status_file_path: z.string(),
|
||||||
|
up_command: z.string(),
|
||||||
|
down_command: z.string(),
|
||||||
|
listening_port: z.number(),
|
||||||
|
username: z.string(),
|
||||||
|
password: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const gluetunProviderSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
server_selection: gluetunServerSelectionSchema,
|
||||||
|
port_forwarding: gluetunPortForwardingSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const gluetunOpenvpnSettingsSchema = z.object({
|
||||||
|
version: z.string(),
|
||||||
|
user: z.string(),
|
||||||
|
password: z.string(),
|
||||||
|
config_file_path: z.string(),
|
||||||
|
ciphers: z.array(z.string()).nullable(),
|
||||||
|
auth: z.string(),
|
||||||
|
cert: z.string(),
|
||||||
|
key: z.string(),
|
||||||
|
encrypted_key: z.string(),
|
||||||
|
key_passphrase: z.string(),
|
||||||
|
pia_encryption_preset: z.string(),
|
||||||
|
mssfix: z.number(),
|
||||||
|
interface: z.string(),
|
||||||
|
process_user: z.string(),
|
||||||
|
verbosity: z.number(),
|
||||||
|
flags: z.array(z.string()).nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const gluetunWireguardSettingsSchema = z.object({
|
||||||
|
private_key: z.string(),
|
||||||
|
pre_shared_key: z.string(),
|
||||||
|
addresses: z.array(z.string()),
|
||||||
|
allowed_ips: z.array(z.string()),
|
||||||
|
interface: z.string(),
|
||||||
|
persistent_keep_alive_interval: z.number(),
|
||||||
|
mtu: z.number(),
|
||||||
|
implementation: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const gluetunVpnSettingsSchema = z.object({
|
||||||
|
type: z.string(),
|
||||||
|
provider: gluetunProviderSchema,
|
||||||
|
openvpn: gluetunOpenvpnSettingsSchema,
|
||||||
|
wireguard: gluetunWireguardSettingsSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GluetunVpnSettings = z.infer<typeof gluetunVpnSettingsSchema>;
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
import { Response } from "undici";
|
||||||
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createDb } from "@homarr/db/test";
|
||||||
|
|
||||||
|
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||||
|
|
||||||
|
import type { IntegrationTestingInput } from "../../base/integration";
|
||||||
|
import { GluetunIntegration } from "../gluetun-integration";
|
||||||
|
import {
|
||||||
|
gluetunDnsStatusSchema,
|
||||||
|
gluetunPublicIpSchema,
|
||||||
|
gluetunVpnSettingsSchema,
|
||||||
|
gluetunVpnStatusSchema,
|
||||||
|
} from "../gluetun-types";
|
||||||
|
|
||||||
|
vi.mock("@homarr/db", async (importActual) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||||
|
const actual = await importActual<typeof import("@homarr/db")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
db: createDb(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||||
|
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const TEST_URL = "https://gluetun.example.com";
|
||||||
|
const TEST_API_KEY = "test-api-key";
|
||||||
|
const TEST_USERNAME = "user";
|
||||||
|
const TEST_PASSWORD = "p@ss";
|
||||||
|
|
||||||
|
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||||
|
|
||||||
|
type FetchResponse = Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
|
||||||
|
|
||||||
|
const VPN_STATUS_PAYLOAD = { status: "running" };
|
||||||
|
const DNS_STATUS_PAYLOAD = { status: "running" };
|
||||||
|
const PUBLIC_IP_PAYLOAD = {
|
||||||
|
public_ip: "203.0.113.42",
|
||||||
|
region: "Europe",
|
||||||
|
country: "Germany",
|
||||||
|
city: "Berlin",
|
||||||
|
location: "52.5200,13.4050",
|
||||||
|
organization: "Example Org",
|
||||||
|
postal_code: "10115",
|
||||||
|
timezone: "Europe/Berlin",
|
||||||
|
};
|
||||||
|
const VPN_SETTINGS_PAYLOAD = {
|
||||||
|
type: "wireguard",
|
||||||
|
provider: {
|
||||||
|
name: "mullvad",
|
||||||
|
server_selection: {
|
||||||
|
vpn: "wireguard",
|
||||||
|
countries: ["Germany"],
|
||||||
|
categories: null,
|
||||||
|
regions: null,
|
||||||
|
cities: ["Berlin"],
|
||||||
|
isps: null,
|
||||||
|
names: null,
|
||||||
|
numbers: null,
|
||||||
|
hostnames: ["de-ber-wg-001"],
|
||||||
|
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: "",
|
||||||
|
protocol: "udp",
|
||||||
|
endpoint_ip: "",
|
||||||
|
custom_port: 0,
|
||||||
|
pia_encryption_preset: "",
|
||||||
|
},
|
||||||
|
wireguard: {
|
||||||
|
endpoint_ip: "203.0.113.1",
|
||||||
|
endpoint_port: 51820,
|
||||||
|
public_key: "pubkey",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
port_forwarding: {
|
||||||
|
enabled: false,
|
||||||
|
provider: "mullvad",
|
||||||
|
status_file_path: "",
|
||||||
|
up_command: "",
|
||||||
|
down_command: "",
|
||||||
|
listening_port: 0,
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
openvpn: {
|
||||||
|
version: "",
|
||||||
|
user: "",
|
||||||
|
password: "",
|
||||||
|
config_file_path: "",
|
||||||
|
ciphers: null,
|
||||||
|
auth: "",
|
||||||
|
cert: "",
|
||||||
|
key: "",
|
||||||
|
encrypted_key: "",
|
||||||
|
key_passphrase: "",
|
||||||
|
pia_encryption_preset: "",
|
||||||
|
mssfix: 0,
|
||||||
|
interface: "tun0",
|
||||||
|
process_user: "",
|
||||||
|
verbosity: 0,
|
||||||
|
flags: null,
|
||||||
|
},
|
||||||
|
wireguard: {
|
||||||
|
private_key: "",
|
||||||
|
pre_shared_key: "",
|
||||||
|
addresses: ["10.0.0.2/32"],
|
||||||
|
allowed_ips: ["0.0.0.0/0"],
|
||||||
|
interface: "wg0",
|
||||||
|
persistent_keep_alive_interval: 25,
|
||||||
|
mtu: 1420,
|
||||||
|
implementation: "auto",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonResponse = (body: unknown, init: ResponseInit = { status: 200 }): FetchResponse =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
...init,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}) as unknown as FetchResponse;
|
||||||
|
|
||||||
|
const setupHappyPath = () => {
|
||||||
|
mockFetch.mockImplementation((url) => {
|
||||||
|
const urlString = typeof url === "string" ? url : url.toString();
|
||||||
|
const { pathname } = new URL(urlString);
|
||||||
|
|
||||||
|
switch (pathname) {
|
||||||
|
case "/v1/vpn/status":
|
||||||
|
return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
|
||||||
|
case "/v1/dns/status":
|
||||||
|
return Promise.resolve(jsonResponse(DNS_STATUS_PAYLOAD));
|
||||||
|
case "/v1/publicip/ip":
|
||||||
|
return Promise.resolve(jsonResponse(PUBLIC_IP_PAYLOAD));
|
||||||
|
case "/v1/vpn/settings":
|
||||||
|
return Promise.resolve(jsonResponse(VPN_SETTINGS_PAYLOAD));
|
||||||
|
default:
|
||||||
|
return Promise.resolve(jsonResponse({ error: "not found" }, { status: 404 }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createIntegrationWithApiKey = () =>
|
||||||
|
new GluetunIntegration({
|
||||||
|
id: "gluetun-1",
|
||||||
|
name: "Gluetun",
|
||||||
|
url: TEST_URL,
|
||||||
|
externalUrl: null,
|
||||||
|
decryptedSecrets: [{ kind: "apiKey", value: TEST_API_KEY }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const createIntegrationWithBasicAuth = () =>
|
||||||
|
new GluetunIntegration({
|
||||||
|
id: "gluetun-2",
|
||||||
|
name: "Gluetun",
|
||||||
|
url: TEST_URL,
|
||||||
|
externalUrl: null,
|
||||||
|
decryptedSecrets: [
|
||||||
|
{ kind: "username", value: TEST_USERNAME },
|
||||||
|
{ kind: "password", value: TEST_PASSWORD },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GluetunIntegration.getSummaryAsync", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maps the four upstream responses into a flat VPN summary", async () => {
|
||||||
|
setupHappyPath();
|
||||||
|
|
||||||
|
const result = await createIntegrationWithApiKey().getSummaryAsync();
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
vpnStatus: "running",
|
||||||
|
dnsStatus: "running",
|
||||||
|
publicIp: "203.0.113.42",
|
||||||
|
country: "Germany",
|
||||||
|
city: "Berlin",
|
||||||
|
vpnProvider: {
|
||||||
|
protocol: "wireguard",
|
||||||
|
provider: "mullvad",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hits each of the four control endpoints exactly once", async () => {
|
||||||
|
setupHappyPath();
|
||||||
|
|
||||||
|
await createIntegrationWithApiKey().getSummaryAsync();
|
||||||
|
|
||||||
|
const calledPaths = mockFetch.mock.calls.map(([url]) => {
|
||||||
|
const urlString = typeof url === "string" ? url : url.toString();
|
||||||
|
return new URL(urlString).pathname;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(calledPaths).toHaveLength(4);
|
||||||
|
expect(new Set(calledPaths)).toEqual(
|
||||||
|
new Set(["/v1/vpn/status", "/v1/dns/status", "/v1/publicip/ip", "/v1/vpn/settings"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sends X-API-Key header when an apiKey secret is configured", async () => {
|
||||||
|
setupHappyPath();
|
||||||
|
|
||||||
|
await createIntegrationWithApiKey().getSummaryAsync();
|
||||||
|
|
||||||
|
for (const [, init] of mockFetch.mock.calls) {
|
||||||
|
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||||
|
expect(headers["X-API-Key"]).toBe(TEST_API_KEY);
|
||||||
|
expect(headers.Authorization).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sends Basic auth header when username/password are configured", async () => {
|
||||||
|
setupHappyPath();
|
||||||
|
|
||||||
|
await createIntegrationWithBasicAuth().getSummaryAsync();
|
||||||
|
|
||||||
|
const expected = `Basic ${btoa(`${TEST_USERNAME}:${TEST_PASSWORD}`)}`;
|
||||||
|
for (const [, init] of mockFetch.mock.calls) {
|
||||||
|
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||||
|
expect(headers.Authorization).toBe(expected);
|
||||||
|
expect(headers["X-API-Key"]).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when any upstream endpoint returns a non-OK status", async () => {
|
||||||
|
mockFetch.mockImplementation((url) => {
|
||||||
|
const urlString = typeof url === "string" ? url : url.toString();
|
||||||
|
const { pathname } = new URL(urlString);
|
||||||
|
|
||||||
|
if (pathname === "/v1/publicip/ip") {
|
||||||
|
return Promise.resolve(jsonResponse({ error: "unauthorized" }, { status: 401 }));
|
||||||
|
}
|
||||||
|
return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(createIntegrationWithApiKey().getSummaryAsync()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when an upstream payload fails schema validation", async () => {
|
||||||
|
mockFetch.mockImplementation((url) => {
|
||||||
|
const urlString = typeof url === "string" ? url : url.toString();
|
||||||
|
const { pathname } = new URL(urlString);
|
||||||
|
|
||||||
|
if (pathname === "/v1/publicip/ip") {
|
||||||
|
return Promise.resolve(jsonResponse({ public_ip: 123 }));
|
||||||
|
}
|
||||||
|
return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(createIntegrationWithApiKey().getSummaryAsync()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GluetunIntegration testing endpoint", () => {
|
||||||
|
type TestingFn = (input: IntegrationTestingInput) => Promise<{ success: boolean }>;
|
||||||
|
|
||||||
|
const invokeTesting = (integration: GluetunIntegration, input: IntegrationTestingInput) => {
|
||||||
|
const testing = (integration as unknown as { testingAsync: TestingFn }).testingAsync.bind(integration);
|
||||||
|
return testing(input);
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeInput = (response: Response): IntegrationTestingInput =>
|
||||||
|
({
|
||||||
|
fetchAsync: vi.fn().mockResolvedValue(response),
|
||||||
|
}) as unknown as IntegrationTestingInput;
|
||||||
|
|
||||||
|
test("returns success when /v1/vpn/status responds 200", async () => {
|
||||||
|
const input = makeInput(new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 }));
|
||||||
|
|
||||||
|
const result = await invokeTesting(createIntegrationWithApiKey(), input);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns a status-code failure when /v1/vpn/status responds non-OK", async () => {
|
||||||
|
const input = makeInput(new Response("unauthorized", { status: 401 }));
|
||||||
|
|
||||||
|
const result = await invokeTesting(createIntegrationWithApiKey(), input);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes the api-key header through to fetchAsync", async () => {
|
||||||
|
const response = new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 });
|
||||||
|
const input = makeInput(response);
|
||||||
|
|
||||||
|
await invokeTesting(createIntegrationWithApiKey(), input);
|
||||||
|
|
||||||
|
const fetchAsyncMock = vi.mocked(input.fetchAsync);
|
||||||
|
const init = fetchAsyncMock.mock.calls[0]?.[1];
|
||||||
|
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||||
|
expect(headers["X-API-Key"]).toBe(TEST_API_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes the Basic auth header through to fetchAsync when username/password are configured", async () => {
|
||||||
|
const response = new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 });
|
||||||
|
const input = makeInput(response);
|
||||||
|
|
||||||
|
await invokeTesting(createIntegrationWithBasicAuth(), input);
|
||||||
|
|
||||||
|
const fetchAsyncMock = vi.mocked(input.fetchAsync);
|
||||||
|
const init = fetchAsyncMock.mock.calls[0]?.[1];
|
||||||
|
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||||
|
const expected = `Basic ${btoa(`${TEST_USERNAME}:${TEST_PASSWORD}`)}`;
|
||||||
|
expect(headers.Authorization).toBe(expected);
|
||||||
|
expect(headers["X-API-Key"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("gluetun schemas", () => {
|
||||||
|
test("vpn status schema accepts a string status", () => {
|
||||||
|
expect(gluetunVpnStatusSchema.parse({ status: "running" })).toEqual({ status: "running" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vpn status schema rejects a missing status", () => {
|
||||||
|
expect(() => gluetunVpnStatusSchema.parse({})).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("dns status schema mirrors vpn status schema", () => {
|
||||||
|
expect(gluetunDnsStatusSchema.parse({ status: "stopped" })).toEqual({ status: "stopped" });
|
||||||
|
expect(() => gluetunDnsStatusSchema.parse({ status: 42 })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("public ip schema parses the full payload", () => {
|
||||||
|
expect(gluetunPublicIpSchema.parse(PUBLIC_IP_PAYLOAD)).toEqual(PUBLIC_IP_PAYLOAD);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("public ip schema rejects a missing required field", () => {
|
||||||
|
const { city: _city, ...partial } = PUBLIC_IP_PAYLOAD;
|
||||||
|
expect(() => gluetunPublicIpSchema.parse(partial)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vpn settings schema parses a wireguard payload", () => {
|
||||||
|
const parsed = gluetunVpnSettingsSchema.parse(VPN_SETTINGS_PAYLOAD);
|
||||||
|
expect(parsed.type).toBe("wireguard");
|
||||||
|
expect(parsed.provider.name).toBe("mullvad");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vpn settings schema accepts nullable server-selection arrays", () => {
|
||||||
|
const payloadWithNulls = {
|
||||||
|
...VPN_SETTINGS_PAYLOAD,
|
||||||
|
provider: {
|
||||||
|
...VPN_SETTINGS_PAYLOAD.provider,
|
||||||
|
server_selection: {
|
||||||
|
...VPN_SETTINGS_PAYLOAD.provider.server_selection,
|
||||||
|
countries: null,
|
||||||
|
cities: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => gluetunVpnSettingsSchema.parse(payloadWithNulls)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("vpn settings schema rejects a non-string type", () => {
|
||||||
|
expect(() => gluetunVpnSettingsSchema.parse({ ...VPN_SETTINGS_PAYLOAD, type: 1 })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,9 +15,9 @@ export class GotifyIntegration extends Integration implements INotificationsInte
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getHeaders() {
|
private getHeaders() {
|
||||||
const credentials = Buffer.from(
|
const credentials = Buffer.from(`${super.getSecretValue("username")}:${super.getSecretValue("password")}`).toString(
|
||||||
`${super.getSecretValue("username")}:${super.getSecretValue("password")}`,
|
"base64",
|
||||||
).toString("base64");
|
);
|
||||||
return { Authorization: `Basic ${credentials}` };
|
return { Authorization: `Basic ${credentials}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,11 +31,13 @@ export class GotifyIntegration extends Integration implements INotificationsInte
|
|||||||
if (!result.success) throw new Error(`Failed to parse Gotify response: ${result.error.message}`);
|
if (!result.success) throw new Error(`Failed to parse Gotify response: ${result.error.message}`);
|
||||||
const parsed = result.data;
|
const parsed = result.data;
|
||||||
|
|
||||||
return parsed.messages.map((message): Notification => ({
|
return parsed.messages.map(
|
||||||
id: String(message.id),
|
(message): Notification => ({
|
||||||
time: new Date(message.date),
|
id: String(message.id),
|
||||||
title: message.title,
|
time: new Date(message.date),
|
||||||
body: message.message,
|
title: message.title,
|
||||||
}));
|
body: message.message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import type { VpnSummary } from "./vpn-summary-types";
|
||||||
|
|
||||||
|
export interface VpnSummaryIntegration {
|
||||||
|
getSummaryAsync(): Promise<VpnSummary>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export interface VpnProviderDetails {
|
||||||
|
protocol: string;
|
||||||
|
provider: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VpnSummary {
|
||||||
|
vpnStatus: string;
|
||||||
|
dnsStatus: string;
|
||||||
|
publicIp: string;
|
||||||
|
country: string;
|
||||||
|
city: string;
|
||||||
|
vpnProvider: VpnProviderDetails;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export * from "./interfaces/network-controller-summary/network-controller-summar
|
|||||||
export * from "./interfaces/firewall-summary/firewall-summary-types";
|
export * from "./interfaces/firewall-summary/firewall-summary-types";
|
||||||
export * from "./interfaces/health-monitoring/health-monitoring-types";
|
export * from "./interfaces/health-monitoring/health-monitoring-types";
|
||||||
export * from "./interfaces/ups-summary/ups-summary-types";
|
export * from "./interfaces/ups-summary/ups-summary-types";
|
||||||
|
export * from "./interfaces/vpn-summary/vpn-summary-types";
|
||||||
export * from "./interfaces/indexer-manager/indexer-manager-types";
|
export * from "./interfaces/indexer-manager/indexer-manager-types";
|
||||||
export * from "./interfaces/media-requests/media-request-types";
|
export * from "./interfaces/media-requests/media-request-types";
|
||||||
export * from "./base/searchable-integration";
|
export * from "./base/searchable-integration";
|
||||||
@@ -22,4 +23,5 @@ export * from "./audiobookshelf/audiobookshelf-types";
|
|||||||
export * from "./navidrome/navidrome-types";
|
export * from "./navidrome/navidrome-types";
|
||||||
export * from "./paperless-ngx/paperless-ngx-types";
|
export * from "./paperless-ngx/paperless-ngx-types";
|
||||||
export * from "./umami/umami-types";
|
export * from "./umami/umami-types";
|
||||||
|
export * from "./gluetun/gluetun-types";
|
||||||
export * from "./beszel/beszel-types";
|
export * from "./beszel/beszel-types";
|
||||||
@@ -2,15 +2,7 @@
|
|||||||
|
|
||||||
import type { StartedTestContainer } from "testcontainers";
|
import type { StartedTestContainer } from "testcontainers";
|
||||||
import { GenericContainer, Wait } from "testcontainers";
|
import { GenericContainer, Wait } from "testcontainers";
|
||||||
import {
|
import { beforeAll, beforeEach, afterAll, describe, expect, test, vi } from "vitest";
|
||||||
beforeAll,
|
|
||||||
beforeEach,
|
|
||||||
afterAll,
|
|
||||||
describe,
|
|
||||||
expect,
|
|
||||||
test,
|
|
||||||
vi,
|
|
||||||
} from "vitest";
|
|
||||||
|
|
||||||
import { createDb } from "@homarr/db/test";
|
import { createDb } from "@homarr/db/test";
|
||||||
|
|
||||||
@@ -31,15 +23,10 @@ vi.mock("@homarr/db", async (importActual) => {
|
|||||||
|
|
||||||
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
|
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||||
const actual =
|
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
|
||||||
await importActual<
|
|
||||||
typeof import("@homarr/core/infrastructure/certificates")
|
|
||||||
>();
|
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
getTrustedCertificateHostnamesAsync: vi
|
getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => Promise.resolve([])),
|
||||||
.fn()
|
|
||||||
.mockImplementation(() => Promise.resolve([])),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -90,9 +77,7 @@ const INTEGRATION_ID = "test-technitium";
|
|||||||
|
|
||||||
const makeInput = (
|
const makeInput = (
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
auth:
|
auth: { kind: "credentials"; password?: string } | { kind: "apiKey"; value: string },
|
||||||
| { kind: "credentials"; password?: string }
|
|
||||||
| { kind: "apiKey"; value: string },
|
|
||||||
): IntegrationInput => ({
|
): IntegrationInput => ({
|
||||||
id: INTEGRATION_ID,
|
id: INTEGRATION_ID,
|
||||||
name: "Technitium DNS",
|
name: "Technitium DNS",
|
||||||
@@ -109,10 +94,7 @@ const makeInput = (
|
|||||||
|
|
||||||
// Fetch an API token appropriate for the server version.
|
// Fetch an API token appropriate for the server version.
|
||||||
// v15 has a dedicated createToken endpoint; legacy reuses the session token from login.
|
// v15 has a dedicated createToken endpoint; legacy reuses the session token from login.
|
||||||
async function fetchApiToken(
|
async function fetchApiToken(baseUrl: string, apiVersion: TechnitiumVersion): Promise<string> {
|
||||||
baseUrl: string,
|
|
||||||
apiVersion: TechnitiumVersion,
|
|
||||||
): Promise<string> {
|
|
||||||
if (apiVersion === "v15") {
|
if (apiVersion === "v15") {
|
||||||
// v15 exposes a dedicated endpoint for non-expiring API tokens.
|
// v15 exposes a dedicated endpoint for non-expiring API tokens.
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
@@ -122,9 +104,7 @@ async function fetchApiToken(
|
|||||||
}
|
}
|
||||||
// Legacy: /api/user/login exists on all tested versions (v11–v14) and returns a session
|
// Legacy: /api/user/login exists on all tested versions (v11–v14) and returns a session
|
||||||
// token that can be used as a long-lived API key via the ?token= query parameter.
|
// token that can be used as a long-lived API key via the ?token= query parameter.
|
||||||
const res = await fetch(
|
const res = await fetch(`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`);
|
||||||
`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`,
|
|
||||||
);
|
|
||||||
return ((await res.json()) as { token: string }).token;
|
return ((await res.json()) as { token: string }).token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,16 +124,12 @@ const pool: Record<string, ContainerEntry> = {};
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
VERSIONS.map(async ({ tag, apiVersion }) => {
|
VERSIONS.map(async ({ tag, apiVersion }) => {
|
||||||
const container = await new GenericContainer(
|
const container = await new GenericContainer(`technitium/dns-server:${tag}`)
|
||||||
`technitium/dns-server:${tag}`,
|
|
||||||
)
|
|
||||||
.withExposedPorts(5380)
|
.withExposedPorts(5380)
|
||||||
// Explicitly set the admin password so all versions use the same credentials.
|
// Explicitly set the admin password so all versions use the same credentials.
|
||||||
// Without this, older versions may generate a random password on first run.
|
// Without this, older versions may generate a random password on first run.
|
||||||
.withEnvironment({ DNS_SERVER_ADMIN_PASSWORD: DEFAULT_PASSWORD })
|
.withEnvironment({ DNS_SERVER_ADMIN_PASSWORD: DEFAULT_PASSWORD })
|
||||||
.withWaitStrategy(
|
.withWaitStrategy(Wait.forLogMessage("Technitium DNS Server was started successfully."))
|
||||||
Wait.forLogMessage("Technitium DNS Server was started successfully."),
|
|
||||||
)
|
|
||||||
.start();
|
.start();
|
||||||
const baseUrl = `http://${container.getHost()}:${container.getMappedPort(5380)}`;
|
const baseUrl = `http://${container.getHost()}:${container.getMappedPort(5380)}`;
|
||||||
const apiToken = await fetchApiToken(baseUrl, apiVersion);
|
const apiToken = await fetchApiToken(baseUrl, apiVersion);
|
||||||
@@ -163,238 +139,209 @@ beforeAll(async () => {
|
|||||||
}, 120_000);
|
}, 120_000);
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await Promise.all(
|
await Promise.all(Object.values(pool).map(({ container }) => container.stop()));
|
||||||
Object.values(pool).map(({ container }) => container.stop()),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── version-parameterised suites ────────────────────────────────────────────
|
// ─── version-parameterised suites ────────────────────────────────────────────
|
||||||
|
|
||||||
describe.each(VERSIONS)(
|
describe.each(VERSIONS)("Technitium DNS $tag ($apiVersion)", ({ tag, apiVersion }) => {
|
||||||
"Technitium DNS $tag ($apiVersion)",
|
let baseUrl: string;
|
||||||
({ tag, apiVersion }) => {
|
let apiToken: string;
|
||||||
let baseUrl: string;
|
|
||||||
let apiToken: string;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
const entry = pool[tag];
|
const entry = pool[tag];
|
||||||
if (!entry)
|
if (!entry) throw new Error(`Container pool entry for ${tag} was not initialized`);
|
||||||
throw new Error(`Container pool entry for ${tag} was not initialized`);
|
({ baseUrl, apiToken } = entry);
|
||||||
({ baseUrl, apiToken } = entry);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sessionState[INTEGRATION_ID] = null;
|
sessionState[INTEGRATION_ID] = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── factory ────────────────────────────────────────────────────────────────
|
// ── factory ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
|
describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
|
||||||
test("factory creates a working integration (auto-detects version)", async () => {
|
test("factory creates a working integration (auto-detects version)", async () => {
|
||||||
const integration = await createTechnitiumDnsIntegrationAsync(
|
const integration = await createTechnitiumDnsIntegrationAsync(makeInput(baseUrl, { kind: "credentials" }));
|
||||||
makeInput(baseUrl, { kind: "credentials" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(result.status).toBe("enabled");
|
expect(result.status).toBe("enabled");
|
||||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── getSummaryAsync ────────────────────────────────────────────────────────
|
// ── getSummaryAsync ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("getSummaryAsync", () => {
|
describe("getSummaryAsync", () => {
|
||||||
test("returns valid summary with credentials", async () => {
|
test("returns valid summary with credentials", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(
|
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||||
makeInput(baseUrl, { kind: "credentials" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||||
expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
|
expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
|
||||||
expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
|
expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
|
||||||
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
||||||
expect(result.status).toBe("enabled");
|
expect(result.status).toBe("enabled");
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("returns valid summary with API key", async () => {
|
test("returns valid summary with API key", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(
|
const integration = new TechnitiumDnsIntegration(
|
||||||
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
||||||
apiVersion,
|
apiVersion,
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(result.status).toBe("enabled");
|
expect(result.status).toBe("enabled");
|
||||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
|
test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(
|
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||||
makeInput(baseUrl, { kind: "credentials" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(typeof result.domainsBeingBlocked).toBe("number");
|
expect(typeof result.domainsBeingBlocked).toBe("number");
|
||||||
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── session caching ────────────────────────────────────────────────────────
|
// ── session caching ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("session caching", () => {
|
describe("session caching", () => {
|
||||||
test("login is performed once and token is cached for subsequent calls", async () => {
|
test("login is performed once and token is cached for subsequent calls", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(
|
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||||
makeInput(baseUrl, { kind: "credentials" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await integration.getSummaryAsync();
|
await integration.getSummaryAsync();
|
||||||
const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
|
const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
|
||||||
expect(tokenAfterFirst).toBeDefined();
|
expect(tokenAfterFirst).toBeDefined();
|
||||||
|
|
||||||
await integration.getSummaryAsync();
|
await integration.getSummaryAsync();
|
||||||
const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
|
const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
|
||||||
|
|
||||||
expect(tokenAfterSecond).toBe(tokenAfterFirst);
|
expect(tokenAfterSecond).toBe(tokenAfterFirst);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("recovers from an expired/invalid stored token by re-logging in", async () => {
|
test("recovers from an expired/invalid stored token by re-logging in", async () => {
|
||||||
// Plant a bad token with the correct version so it hits the right API path before failing
|
// Plant a bad token with the correct version so it hits the right API path before failing
|
||||||
sessionState[INTEGRATION_ID] = {
|
sessionState[INTEGRATION_ID] = {
|
||||||
token: "deliberately-invalid-token",
|
token: "deliberately-invalid-token",
|
||||||
version: apiVersion,
|
version: apiVersion,
|
||||||
};
|
};
|
||||||
|
|
||||||
const integration = new TechnitiumDnsIntegration(
|
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }), apiVersion);
|
||||||
makeInput(baseUrl, { kind: "credentials" }),
|
|
||||||
apiVersion,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(result.status).toBe("enabled");
|
expect(result.status).toBe("enabled");
|
||||||
expect(sessionState[INTEGRATION_ID]).not.toBeNull();
|
expect(sessionState[INTEGRATION_ID]).not.toBeNull();
|
||||||
expect(sessionState[INTEGRATION_ID]?.token).not.toBe(
|
expect(sessionState[INTEGRATION_ID]?.token).not.toBe("deliberately-invalid-token");
|
||||||
"deliberately-invalid-token",
|
}, 20_000);
|
||||||
);
|
});
|
||||||
}, 20_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── blocking state changes ─────────────────────────────────────────────────
|
// ── blocking state changes ─────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("blocking state changes", () => {
|
describe("blocking state changes", () => {
|
||||||
test("disableAsync permanently disables blocking", async () => {
|
test("disableAsync permanently disables blocking", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(
|
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||||
makeInput(baseUrl, { kind: "credentials" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await integration.disableAsync();
|
await integration.disableAsync();
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(result.status).toBe("disabled");
|
expect(result.status).toBe("disabled");
|
||||||
|
|
||||||
await integration.enableAsync(); // restore
|
await integration.enableAsync(); // restore
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("enableAsync re-enables blocking after permanent disable", async () => {
|
test("enableAsync re-enables blocking after permanent disable", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(
|
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||||
makeInput(baseUrl, { kind: "credentials" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await integration.disableAsync();
|
await integration.disableAsync();
|
||||||
await integration.enableAsync();
|
await integration.enableAsync();
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(result.status).toBe("enabled");
|
expect(result.status).toBe("enabled");
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("disableAsync with duration temporarily disables blocking", async () => {
|
test("disableAsync with duration temporarily disables blocking", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(
|
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||||
makeInput(baseUrl, { kind: "credentials" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await integration.disableAsync(120); // 120s → 2 min
|
await integration.disableAsync(120); // 120s → 2 min
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(result.status).toBe("disabled");
|
expect(result.status).toBe("disabled");
|
||||||
|
|
||||||
await integration.enableAsync(); // restore
|
await integration.enableAsync(); // restore
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
|
test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(
|
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||||
makeInput(baseUrl, { kind: "credentials" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await integration.disableAsync(1);
|
await integration.disableAsync(1);
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(result.status).toBe("disabled");
|
expect(result.status).toBe("disabled");
|
||||||
|
|
||||||
await integration.enableAsync(); // restore
|
await integration.enableAsync(); // restore
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("enableAsync cancels an active timed disable immediately", async () => {
|
test("enableAsync cancels an active timed disable immediately", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(
|
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||||
makeInput(baseUrl, { kind: "credentials" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await integration.disableAsync(600); // 10 minutes
|
await integration.disableAsync(600); // 10 minutes
|
||||||
await integration.enableAsync();
|
await integration.enableAsync();
|
||||||
const result = await integration.getSummaryAsync();
|
const result = await integration.getSummaryAsync();
|
||||||
|
|
||||||
expect(result.status).toBe("enabled");
|
expect(result.status).toBe("enabled");
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── testConnectionAsync ────────────────────────────────────────────────────
|
// ── testConnectionAsync ────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("testConnectionAsync", () => {
|
describe("testConnectionAsync", () => {
|
||||||
test("succeeds with valid credentials", async () => {
|
test("succeeds with valid credentials", async () => {
|
||||||
const result = await new TechnitiumDnsIntegration(
|
const result = await new TechnitiumDnsIntegration(
|
||||||
makeInput(baseUrl, { kind: "credentials" }),
|
makeInput(baseUrl, { kind: "credentials" }),
|
||||||
).testConnectionAsync();
|
).testConnectionAsync();
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("succeeds with valid API key", async () => {
|
test("succeeds with valid API key", async () => {
|
||||||
const result = await new TechnitiumDnsIntegration(
|
const result = await new TechnitiumDnsIntegration(
|
||||||
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
||||||
apiVersion,
|
apiVersion,
|
||||||
).testConnectionAsync();
|
).testConnectionAsync();
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("fails with wrong password — returns authorization error", async () => {
|
test("fails with wrong password — returns authorization error", async () => {
|
||||||
const integration = new TechnitiumDnsIntegration(
|
const integration = new TechnitiumDnsIntegration(
|
||||||
makeInput(baseUrl, {
|
makeInput(baseUrl, {
|
||||||
kind: "credentials",
|
kind: "credentials",
|
||||||
password: "wrong-password",
|
password: "wrong-password",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await integration.testConnectionAsync();
|
const result = await integration.testConnectionAsync();
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
if (result.success) return;
|
if (result.success) return;
|
||||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||||
expect(result.error.type).toBe("authorization");
|
expect(result.error.type).toBe("authorization");
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
test("fails with invalid API key — returns authorization error", async () => {
|
test("fails with invalid API key — returns authorization error", async () => {
|
||||||
const result = await new TechnitiumDnsIntegration(
|
const result = await new TechnitiumDnsIntegration(
|
||||||
makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
|
makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
|
||||||
apiVersion,
|
apiVersion,
|
||||||
).testConnectionAsync();
|
).testConnectionAsync();
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
if (result.success) return;
|
if (result.success) return;
|
||||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||||
expect(result.error.type).toBe("authorization");
|
expect(result.error.type).toBe("authorization");
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
});
|
});
|
||||||
},
|
});
|
||||||
);
|
|
||||||
@@ -75,7 +75,7 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
|
|||||||
});
|
});
|
||||||
return enriched;
|
return enriched;
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(5, "seconds"),
|
cacheDuration: dayjs.duration(1, "second"),
|
||||||
fallbackToStaleOnError: true,
|
fallbackToStaleOnError: true,
|
||||||
retry: { attempts: 2, delayMs: 500 },
|
retry: { attempts: 2, delayMs: 500 },
|
||||||
queryKey: "beszelSystems",
|
queryKey: "beszelSystems",
|
||||||
@@ -104,19 +104,19 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
|
|||||||
});
|
});
|
||||||
return { alerts, history };
|
return { alerts, history };
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(15, "seconds"),
|
cacheDuration: dayjs.duration(1, "second"),
|
||||||
fallbackToStaleOnError: true,
|
fallbackToStaleOnError: true,
|
||||||
retry: { attempts: 2, delayMs: 500 },
|
retry: { attempts: 2, delayMs: 500 },
|
||||||
queryKey: "beszelAlerts",
|
queryKey: "beszelAlerts",
|
||||||
});
|
});
|
||||||
|
|
||||||
const timePeriodConfig: Record<string, { type: string; perPage: number; cacheSeconds: number }> = {
|
const timePeriodConfig: Record<string, { type: string; perPage: number }> = {
|
||||||
"1m": { type: "1m", perPage: 60, cacheSeconds: 5 },
|
"1m": { type: "1m", perPage: 60 },
|
||||||
"1h": { type: "1m", perPage: 60, cacheSeconds: 60 },
|
"1h": { type: "1m", perPage: 60 },
|
||||||
"12h": { type: "10m", perPage: 72, cacheSeconds: 300 },
|
"12h": { type: "10m", perPage: 72 },
|
||||||
"24h": { type: "20m", perPage: 72, cacheSeconds: 600 },
|
"24h": { type: "20m", perPage: 72 },
|
||||||
"1w": { type: "1440m", perPage: 9, cacheSeconds: 1800 },
|
"1w": { type: "480m", perPage: 21 },
|
||||||
"30d": { type: "1440m", perPage: 32, cacheSeconds: 3600 },
|
"30d": { type: "480m", perPage: 90 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BeszelStatsData {
|
export interface BeszelStatsData {
|
||||||
@@ -131,7 +131,7 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
|
|||||||
>({
|
>({
|
||||||
async requestAsync(integration, input) {
|
async requestAsync(integration, input) {
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 };
|
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 };
|
||||||
const instance = await createIntegrationAsync(integration);
|
const instance = await createIntegrationAsync(integration);
|
||||||
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
|
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
|
||||||
const containerStats = input.includeDocker
|
const containerStats = input.includeDocker
|
||||||
@@ -147,13 +147,8 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
|
|||||||
});
|
});
|
||||||
return { systemStats, containerStats };
|
return { systemStats, containerStats };
|
||||||
},
|
},
|
||||||
cacheDuration: dayjs.duration(60, "seconds"),
|
cacheDuration: dayjs.duration(1, "second"),
|
||||||
fallbackToStaleOnError: true,
|
fallbackToStaleOnError: true,
|
||||||
retry: { attempts: 2, delayMs: 500 },
|
retry: { attempts: 2, delayMs: 500 },
|
||||||
queryKey: "beszelStats",
|
queryKey: "beszelStats",
|
||||||
cacheDurationForInput(input) {
|
|
||||||
const config = timePeriodConfig[input.timePeriod];
|
|
||||||
if (!config) return undefined;
|
|
||||||
return dayjs.duration(config.cacheSeconds, "seconds");
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
|
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||||
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
|
import type { VpnSummary } from "@homarr/integrations/types";
|
||||||
|
|
||||||
|
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||||
|
|
||||||
|
const logger = createLogger({ module: "vpnRequestHandler" });
|
||||||
|
|
||||||
|
export const vpnSummaryHandler = createCachedIntegrationRequestHandler<
|
||||||
|
VpnSummary | null,
|
||||||
|
IntegrationKindByCategory<"vpn">,
|
||||||
|
Record<string, never>
|
||||||
|
>({
|
||||||
|
async requestAsync(integration, _input) {
|
||||||
|
try {
|
||||||
|
const integrationInstance = await createIntegrationAsync(integration);
|
||||||
|
return await integrationInstance.getSummaryAsync();
|
||||||
|
} catch (error) {
|
||||||
|
// The VPN control server is unreachable (e.g. the container is down).
|
||||||
|
// Return null so the widget can render an "unavailable" status instead of
|
||||||
|
// surfacing a hard error, and so it recovers automatically once it is back up.
|
||||||
|
logger.warn("Failed to fetch VPN summary, marking as unavailable", {
|
||||||
|
integrationId: integration.id,
|
||||||
|
integrationKind: integration.kind,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cacheDuration: dayjs.duration(5, "minutes"),
|
||||||
|
queryKey: "vpnSummary",
|
||||||
|
});
|
||||||
@@ -1021,6 +1021,7 @@
|
|||||||
"mediaMonitoring": "Monitoring",
|
"mediaMonitoring": "Monitoring",
|
||||||
"speedtest": "Speed Test",
|
"speedtest": "Speed Test",
|
||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
|
"vpn": "VPN",
|
||||||
"mediaLibrary": "Media Library",
|
"mediaLibrary": "Media Library",
|
||||||
"uptimeMonitoring": "Uptime Monitoring"
|
"uptimeMonitoring": "Uptime Monitoring"
|
||||||
},
|
},
|
||||||
@@ -2408,6 +2409,12 @@
|
|||||||
"title": "Instances"
|
"title": "Instances"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"vpn": {
|
||||||
|
"name": "VPN",
|
||||||
|
"description": "Overview of your VPN connections",
|
||||||
|
"serviceUnavailable": "VPN unavailable",
|
||||||
|
"option": {}
|
||||||
|
},
|
||||||
"ups": {
|
"ups": {
|
||||||
"name": "UPS",
|
"name": "UPS",
|
||||||
"description": "Monitor the status of your UPS devices through a NUT server",
|
"description": "Monitor the status of your UPS devices through a NUT server",
|
||||||
@@ -4588,6 +4595,9 @@
|
|||||||
},
|
},
|
||||||
"umamiActiveVisitors": {
|
"umamiActiveVisitors": {
|
||||||
"label": "Umami Active Visitors"
|
"label": "Umami Active Visitors"
|
||||||
|
},
|
||||||
|
"vpn": {
|
||||||
|
"label": "VPN Status"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interval": {
|
"interval": {
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ export default function BeszelAlertsWidget({
|
|||||||
error: alertsError,
|
error: alertsError,
|
||||||
isPending,
|
isPending,
|
||||||
} = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, {
|
} = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, {
|
||||||
staleTime: 15_000,
|
staleTime: 10_000,
|
||||||
refetchInterval: 15_000,
|
gcTime: 48 * 60 * 60 * 1000,
|
||||||
|
refetchInterval: 10_000,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const utils = clientApi.useUtils();
|
const utils = clientApi.useUtils();
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ export default function BeszelSystemGridWidget({
|
|||||||
isPending,
|
isPending,
|
||||||
} = clientApi.widget.beszel.getSystems.useQuery(
|
} = clientApi.widget.beszel.getSystems.useQuery(
|
||||||
{ integrationIds },
|
{ integrationIds },
|
||||||
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
|
{ staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, refetchInterval: 10_000, retry: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
useBeszelSystemsSubscription(integrationIds, !isEditMode);
|
useBeszelSystemsSubscription(integrationIds, !isEditMode);
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ export default function BeszelSystemStatsWidget({
|
|||||||
data: systemsResult = [],
|
data: systemsResult = [],
|
||||||
isPending: systemsPending,
|
isPending: systemsPending,
|
||||||
error: systemsError,
|
error: systemsError,
|
||||||
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }, { staleTime: 30 * 1000, retry: false });
|
} = clientApi.widget.beszel.getSystems.useQuery(
|
||||||
|
{ integrationIds },
|
||||||
|
{ staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, retry: false },
|
||||||
|
);
|
||||||
|
|
||||||
const systems = useMemo(
|
const systems = useMemo(
|
||||||
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
|
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
|
||||||
@@ -100,8 +103,9 @@ export default function BeszelSystemStatsWidget({
|
|||||||
includeDocker: showDocker,
|
includeDocker: showDocker,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
staleTime: 30 * 1000,
|
staleTime: 10_000,
|
||||||
refetchInterval: isLive ? false : 5_000,
|
gcTime: 48 * 60 * 60 * 1000,
|
||||||
|
refetchInterval: isLive ? false : 10_000,
|
||||||
enabled: !isLive && systemReady && selectedSystem !== "",
|
enabled: !isLive && systemReady && selectedSystem !== "",
|
||||||
retry: false,
|
retry: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default function BeszelSystemTableWidget({
|
|||||||
isPending,
|
isPending,
|
||||||
} = clientApi.widget.beszel.getSystems.useQuery(
|
} = clientApi.widget.beszel.getSystems.useQuery(
|
||||||
{ integrationIds },
|
{ integrationIds },
|
||||||
{ staleTime: 5_000, refetchInterval: 5_000, retry: false },
|
{ staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, refetchInterval: 10_000, retry: false },
|
||||||
);
|
);
|
||||||
const size = getSizeConfig(width);
|
const size = getSizeConfig(width);
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const BeszelSystemStatsModal = createModal<BeszelSystemStatsModalProps>((
|
|||||||
timePeriod,
|
timePeriod,
|
||||||
includeDocker: false,
|
includeDocker: false,
|
||||||
},
|
},
|
||||||
{ staleTime: 30_000 },
|
{ staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, refetchInterval: 10_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const mappers = useMemo(
|
const mappers = useMemo(
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
|
|||||||
acc.memory += safeValue(container.memoryUsage);
|
acc.memory += safeValue(container.memoryUsage);
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{ cpu: 0, memory: 0 }
|
{ cpu: 0, memory: 0 },
|
||||||
);
|
);
|
||||||
}, [containers]);
|
}, [containers]);
|
||||||
|
|
||||||
@@ -283,7 +283,7 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
|
|||||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
|
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
{t("table.totalMemory", { memory: humanFileSize(totals.memory) })}
|
{t("table.totalMemory", { memory: humanFileSize(totals.memory) })}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import * as timetable from "./timetable";
|
|||||||
import * as tracearr from "./tracearr";
|
import * as tracearr from "./tracearr";
|
||||||
import * as umami from "./umami";
|
import * as umami from "./umami";
|
||||||
import * as ups from "./ups";
|
import * as ups from "./ups";
|
||||||
|
import * as vpn from "./vpn";
|
||||||
import * as beszelSystemTable from "./beszel-system-table";
|
import * as beszelSystemTable from "./beszel-system-table";
|
||||||
import * as beszelSystemGrid from "./beszel-system-grid";
|
import * as beszelSystemGrid from "./beszel-system-grid";
|
||||||
import * as beszelAlerts from "./beszel-alerts";
|
import * as beszelAlerts from "./beszel-alerts";
|
||||||
@@ -108,6 +109,7 @@ export const widgetImports = {
|
|||||||
uptimeKuma,
|
uptimeKuma,
|
||||||
audioStats,
|
audioStats,
|
||||||
umami,
|
umami,
|
||||||
|
vpn,
|
||||||
ups,
|
ups,
|
||||||
beszelSystemTable,
|
beszelSystemTable,
|
||||||
beszelSystemGrid,
|
beszelSystemGrid,
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Flex, ScrollArea, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
|
||||||
|
import type { WidgetComponentProps } from "../definition";
|
||||||
|
import { updateVpnInfoFromSubscription } from "./helpers";
|
||||||
|
import { VpnIntegrationCard } from "./vpn-card";
|
||||||
|
|
||||||
|
export default function VpnWidget({ options, integrationIds }: WidgetComponentProps<"vpn">) {
|
||||||
|
const [integrations] = clientApi.widget.vpn.getSummaries.useSuspenseQuery(
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
integrationIds,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Initial load only. Live updates arrive via the subscription below, which is
|
||||||
|
// fed by the VPN cron job publishing to the integration channel.
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const utils = clientApi.useUtils();
|
||||||
|
|
||||||
|
clientApi.widget.vpn.subscribeSummaries.useSubscription(
|
||||||
|
{
|
||||||
|
integrationIds,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onData: (data) => {
|
||||||
|
utils.widget.vpn.getSummaries.setData(
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
integrationIds,
|
||||||
|
},
|
||||||
|
(prevData) => updateVpnInfoFromSubscription(prevData, data),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (integrations.length === 1) {
|
||||||
|
// It will always have at least one integration as otherwise the NoIntegrationSelectedError would be thrown in item-content.tsx
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
const vpn = integrations[0]!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex align="center" justify="center" w="100%" h="100%" px="xs" py="sm">
|
||||||
|
<VpnIntegrationCard vpn={vpn.summary} integrationName={vpn.integration.name} />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea className="scroll-area-w100" w="100%" h="100%" offsetScrollbars>
|
||||||
|
<Stack w="100%" gap="sm" py="xs" px="xs">
|
||||||
|
{integrations.map((vpn) => (
|
||||||
|
<VpnIntegrationCard
|
||||||
|
key={vpn.integration.id}
|
||||||
|
vpn={vpn.summary}
|
||||||
|
integrationName={vpn.integration.name}
|
||||||
|
variant="list"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
|
||||||
|
export const RUNNING_STATUS = "running";
|
||||||
|
|
||||||
|
type VpnInfoItem = RouterOutputs["widget"]["vpn"]["getSummaries"][number];
|
||||||
|
|
||||||
|
export function getStatusColor(status: string) {
|
||||||
|
return status === RUNNING_STATUS ? "green" : "red";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateVpnInfoFromSubscription(
|
||||||
|
prevData: VpnInfoItem[] | undefined,
|
||||||
|
data: { integration: { id: string }; summary: VpnInfoItem["summary"] },
|
||||||
|
): VpnInfoItem[] | undefined {
|
||||||
|
if (!prevData) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prevData.map((item) =>
|
||||||
|
item.integration.id === data.integration.id ? { ...item, summary: data.summary } : item,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IconShieldLock } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||||
|
|
||||||
|
import { createWidgetDefinition } from "../definition";
|
||||||
|
import { optionsBuilder } from "../options";
|
||||||
|
|
||||||
|
export const { componentLoader, definition } = createWidgetDefinition("vpn", {
|
||||||
|
icon: IconShieldLock,
|
||||||
|
createOptions() {
|
||||||
|
return optionsBuilder.from(() => ({}));
|
||||||
|
},
|
||||||
|
supportedIntegrations: getIntegrationKindsByCategory("vpn"),
|
||||||
|
}).withDynamicImport(() => import("./component"));
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Badge, Box, Card, Flex, Group, Text } from "@mantine/core";
|
||||||
|
import { IconArrowsExchange, IconMapPin, IconShieldCheck, IconShieldX } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
|
import { getStatusColor, RUNNING_STATUS } from "./helpers";
|
||||||
|
|
||||||
|
export type VpnInfo = RouterOutputs["widget"]["vpn"]["getSummaries"][number]["summary"];
|
||||||
|
|
||||||
|
export function VpnIntegrationCard({
|
||||||
|
vpn,
|
||||||
|
integrationName,
|
||||||
|
variant = "single",
|
||||||
|
}: {
|
||||||
|
vpn: VpnInfo;
|
||||||
|
integrationName?: string;
|
||||||
|
variant?: "single" | "list";
|
||||||
|
}) {
|
||||||
|
const board = useRequiredBoard();
|
||||||
|
const compact = variant === "list";
|
||||||
|
|
||||||
|
const content = vpn ? (
|
||||||
|
<Flex direction="row" w="100%" align="center" gap="xs">
|
||||||
|
<VpnStatusColumn vpnStatus={vpn.vpnStatus} dnsStatus={vpn.dnsStatus} compact={compact} />
|
||||||
|
<VpnInfoColumn
|
||||||
|
integrationName={compact ? integrationName : undefined}
|
||||||
|
publicIp={vpn.publicIp}
|
||||||
|
city={vpn.city}
|
||||||
|
country={vpn.country}
|
||||||
|
provider={vpn.vpnProvider.provider}
|
||||||
|
protocol={vpn.vpnProvider.protocol}
|
||||||
|
compact={compact}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
) : (
|
||||||
|
<VpnUnavailableContent compact={compact} integrationName={compact ? integrationName : undefined} />
|
||||||
|
);
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Card withBorder radius={board.itemRadius} p="xs" w="100%">
|
||||||
|
{content}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Box w="100%">{content}</Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VpnUnavailableContent({ compact, integrationName }: { compact: boolean; integrationName?: string }) {
|
||||||
|
const t = useScopedI18n("widget.vpn");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="row" w="100%" align="center" gap="xs">
|
||||||
|
{/* Empty statuses render the shield and DNS badge in red (see getStatusColor). */}
|
||||||
|
<VpnStatusColumn vpnStatus="" dnsStatus="" compact={compact} />
|
||||||
|
<Flex gap={2} direction="column" w="100%" align="flex-start" style={{ minWidth: 0 }}>
|
||||||
|
{integrationName ? (
|
||||||
|
<Text fw={600} size="xs" lh={1.2} c="dimmed" lineClamp={1}>
|
||||||
|
{integrationName}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text fw={700} size={compact ? "md" : "lg"} lh={1.2} c="red" lineClamp={2}>
|
||||||
|
{t("serviceUnavailable")}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VpnStatusColumn({
|
||||||
|
vpnStatus,
|
||||||
|
dnsStatus,
|
||||||
|
compact,
|
||||||
|
}: {
|
||||||
|
vpnStatus: string;
|
||||||
|
dnsStatus: string;
|
||||||
|
compact: boolean;
|
||||||
|
}) {
|
||||||
|
// Connected shows a shield with a check; anything else (stopped or unavailable) shows a shield with a cross.
|
||||||
|
const ShieldIcon = vpnStatus === RUNNING_STATUS ? IconShieldCheck : IconShieldX;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap={4} direction="column" w={compact ? "28%" : "30%"} miw={compact ? 56 : undefined} align="center">
|
||||||
|
<ShieldIcon stroke={2} color={getStatusColor(vpnStatus)} size={compact ? 44 : 52} />
|
||||||
|
<DnsStatusBadge status={dnsStatus} />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VpnInfoColumn({
|
||||||
|
integrationName,
|
||||||
|
publicIp,
|
||||||
|
city,
|
||||||
|
country,
|
||||||
|
provider,
|
||||||
|
protocol,
|
||||||
|
compact,
|
||||||
|
}: {
|
||||||
|
integrationName?: string;
|
||||||
|
publicIp: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
provider: string;
|
||||||
|
protocol: string;
|
||||||
|
compact: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Flex gap={2} direction="column" w="100%" align="flex-start" style={{ minWidth: 0 }}>
|
||||||
|
{integrationName ? (
|
||||||
|
<Text fw={600} size="xs" lh={1.2} c="dimmed" lineClamp={1}>
|
||||||
|
{integrationName}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text fw={700} size={compact ? "xl" : "2xl"} lh={1.1} lineClamp={1}>
|
||||||
|
{publicIp}
|
||||||
|
</Text>
|
||||||
|
<Group gap={4} justify="flex-start" wrap="nowrap">
|
||||||
|
<IconMapPin size={10} />
|
||||||
|
<Text fs="italic" fw={500} size="sm" lh={1.2} lineClamp={1}>
|
||||||
|
{city}, {country}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<VpnProviderDetails provider={provider} protocol={protocol} />
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VpnProviderDetails({ provider, protocol }: { provider: string; protocol: string }) {
|
||||||
|
return (
|
||||||
|
<Group gap={6} justify="flex-start" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<Text fz="xs" lh={1.2} tt="capitalize" lineClamp={1}>
|
||||||
|
{provider}
|
||||||
|
</Text>
|
||||||
|
<IconArrowsExchange stroke={1} size={12} style={{ flexShrink: 0 }} />
|
||||||
|
<Text fz="xs" lh={1.2} tt="capitalize" lineClamp={1}>
|
||||||
|
{protocol}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusDot({ status, size = 10 }: { status: string; size?: number }) {
|
||||||
|
return <Box bg={getStatusColor(status)} h={size} w={size} style={{ borderRadius: 999 }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DnsStatusBadge({ status }: { status: string }) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
color={getStatusColor(status)}
|
||||||
|
size="xs"
|
||||||
|
radius="xl"
|
||||||
|
leftSection={<StatusDot status={status} size={5} />}
|
||||||
|
>
|
||||||
|
DNS
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
Generated
+905
-783
File diff suppressed because it is too large.
Load diff
+51
-51
@@ -6,7 +6,7 @@ catalog:
|
|||||||
"@auth/core": ^0.41.2
|
"@auth/core": ^0.41.2
|
||||||
"@auth/drizzle-adapter": ^1.11.2
|
"@auth/drizzle-adapter": ^1.11.2
|
||||||
"@ctrl/deluge": ^8.0.0
|
"@ctrl/deluge": ^8.0.0
|
||||||
"@ctrl/qbittorrent": ^9.12.2
|
"@ctrl/qbittorrent": ^9.14.0
|
||||||
"@ctrl/transmission": ^7.4.0
|
"@ctrl/transmission": ^7.4.0
|
||||||
"@dnd-kit/core": ^6.3.1
|
"@dnd-kit/core": ^6.3.1
|
||||||
"@dnd-kit/modifiers": ^9.0.0
|
"@dnd-kit/modifiers": ^9.0.0
|
||||||
@@ -14,7 +14,7 @@ catalog:
|
|||||||
"@dnd-kit/utilities": ^3.2.2
|
"@dnd-kit/utilities": ^3.2.2
|
||||||
"@drizzle-team/brocli": ^0.12.0
|
"@drizzle-team/brocli": ^0.12.0
|
||||||
"@extractus/feed-extractor": 7.2.1
|
"@extractus/feed-extractor": 7.2.1
|
||||||
'@gfazioli/mantine-onboarding-tour': ^4.0.4
|
'@gfazioli/mantine-onboarding-tour': ^4.0.5
|
||||||
"@gitbeaker/rest": ^43.8.0
|
"@gitbeaker/rest": ^43.8.0
|
||||||
"@homarr/gridstack": ^1.12.0
|
"@homarr/gridstack": ^1.12.0
|
||||||
"@homarr/node-unifi": ^2.6.0
|
"@homarr/node-unifi": ^2.6.0
|
||||||
@@ -22,21 +22,21 @@ catalog:
|
|||||||
"@jellyfin/sdk": ^0.13.0
|
"@jellyfin/sdk": ^0.13.0
|
||||||
"@kubernetes/client-node": ^1.4.0
|
"@kubernetes/client-node": ^1.4.0
|
||||||
"@modelcontextprotocol/sdk": 1.29.0
|
"@modelcontextprotocol/sdk": 1.29.0
|
||||||
"@mantine/charts": ^9.3.1
|
"@mantine/charts": ^9.3.2
|
||||||
"@mantine/colors-generator": ^9.3.1
|
"@mantine/colors-generator": ^9.3.2
|
||||||
"@mantine/core": ^9.3.1
|
"@mantine/core": ^9.3.2
|
||||||
"@mantine/dates": ^9.3.1
|
"@mantine/dates": ^9.3.2
|
||||||
"@mantine/dropzone": ^9.3.1
|
"@mantine/dropzone": ^9.3.2
|
||||||
"@mantine/form": ^9.3.1
|
"@mantine/form": ^9.3.2
|
||||||
"@mantine/hooks": ^9.3.1
|
"@mantine/hooks": ^9.3.2
|
||||||
"@mantine/modals": ^9.3.1
|
"@mantine/modals": ^9.3.2
|
||||||
"@mantine/notifications": ^9.3.1
|
"@mantine/notifications": ^9.3.2
|
||||||
"@mantine/spotlight": ^9.3.1
|
"@mantine/spotlight": ^9.3.2
|
||||||
"@mantine/tiptap": ^9.3.1
|
"@mantine/tiptap": ^9.3.2
|
||||||
"@next/eslint-plugin-next": 16.2.9
|
"@next/eslint-plugin-next": 16.2.9
|
||||||
"@octokit/auth-app": ^8.2.0
|
"@octokit/auth-app": ^8.2.0
|
||||||
"@paralleldrive/cuid2": ^3.3.0
|
"@paralleldrive/cuid2": ^3.3.0
|
||||||
"@playwright/test": ^1.60.0
|
"@playwright/test": ^1.61.0
|
||||||
"@semantic-release/changelog": ^6.0.3
|
"@semantic-release/changelog": ^6.0.3
|
||||||
"@semantic-release/commit-analyzer": ^13.0.1
|
"@semantic-release/commit-analyzer": ^13.0.1
|
||||||
"@semantic-release/git": ^10.0.1
|
"@semantic-release/git": ^10.0.1
|
||||||
@@ -49,25 +49,25 @@ catalog:
|
|||||||
"@tanstack/react-query": ^5.101.0
|
"@tanstack/react-query": ^5.101.0
|
||||||
"@tanstack/react-query-devtools": ^5.101.0
|
"@tanstack/react-query-devtools": ^5.101.0
|
||||||
"@tanstack/react-query-next-experimental": ^5.101.0
|
"@tanstack/react-query-next-experimental": ^5.101.0
|
||||||
"@testcontainers/mysql": ^12.0.2
|
"@testcontainers/mysql": ^12.0.3
|
||||||
"@testcontainers/postgresql": ^12.0.2
|
"@testcontainers/postgresql": ^12.0.3
|
||||||
"@testcontainers/redis": ^12.0.2
|
"@testcontainers/redis": ^12.0.3
|
||||||
"@tiptap/extension-color": 3.27.0
|
"@tiptap/extension-color": 3.27.1
|
||||||
"@tiptap/extension-highlight": 3.27.0
|
"@tiptap/extension-highlight": 3.27.1
|
||||||
"@tiptap/extension-image": 3.27.0
|
"@tiptap/extension-image": 3.27.1
|
||||||
"@tiptap/extension-link": ^3.27.0
|
"@tiptap/extension-link": ^3.27.1
|
||||||
"@tiptap/extension-placeholder": ^3.27.0
|
"@tiptap/extension-placeholder": ^3.27.1
|
||||||
"@tiptap/extension-table": 3.27.0
|
"@tiptap/extension-table": 3.27.1
|
||||||
"@tiptap/extension-table-cell": 3.27.0
|
"@tiptap/extension-table-cell": 3.27.1
|
||||||
"@tiptap/extension-table-header": 3.27.0
|
"@tiptap/extension-table-header": 3.27.1
|
||||||
"@tiptap/extension-table-row": 3.27.0
|
"@tiptap/extension-table-row": 3.27.1
|
||||||
"@tiptap/extension-task-item": 3.27.0
|
"@tiptap/extension-task-item": 3.27.1
|
||||||
"@tiptap/extension-task-list": 3.27.0
|
"@tiptap/extension-task-list": 3.27.1
|
||||||
"@tiptap/extension-text-align": 3.27.0
|
"@tiptap/extension-text-align": 3.27.1
|
||||||
"@tiptap/extension-text-style": 3.27.0
|
"@tiptap/extension-text-style": 3.27.1
|
||||||
"@tiptap/extension-underline": 3.27.0
|
"@tiptap/extension-underline": 3.27.1
|
||||||
"@tiptap/react": ^3.27.0
|
"@tiptap/react": ^3.27.1
|
||||||
"@tiptap/starter-kit": ^3.27.0
|
"@tiptap/starter-kit": ^3.27.1
|
||||||
"@trpc/client": ^11.17.0
|
"@trpc/client": ^11.17.0
|
||||||
"@trpc/next": ^11.17.0
|
"@trpc/next": ^11.17.0
|
||||||
"@trpc/react-query": ^11.17.0
|
"@trpc/react-query": ^11.17.0
|
||||||
@@ -88,15 +88,15 @@ 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.46
|
"@scalar/api-reference-react": ^0.9.47
|
||||||
"@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
|
||||||
posthog-js: ^1.255.0
|
posthog-js: ^1.391.2
|
||||||
posthog-node: ^4.18.0
|
posthog-node: ^4.18.0
|
||||||
"@vitejs/plugin-react": ^6.0.2
|
"@vitejs/plugin-react": ^6.0.2
|
||||||
"@vitest/coverage-v8": ^4.1.8
|
"@vitest/coverage-v8": ^4.1.9
|
||||||
"@vitest/ui": ^4.1.8
|
"@vitest/ui": ^4.1.9
|
||||||
"@xterm/addon-canvas": ^0.7.0
|
"@xterm/addon-canvas": ^0.7.0
|
||||||
"@xterm/addon-fit": 0.11.0
|
"@xterm/addon-fit": 0.11.0
|
||||||
"@xterm/xterm": ^6.0.0
|
"@xterm/xterm": ^6.0.0
|
||||||
@@ -125,22 +125,22 @@ catalog:
|
|||||||
drizzle-orm: ^0.45.2
|
drizzle-orm: ^0.45.2
|
||||||
drizzle-zod: ^0.8.3
|
drizzle-zod: ^0.8.3
|
||||||
esbuild: ^0.28.0
|
esbuild: ^0.28.0
|
||||||
fast-xml-parser: ^5.8.0
|
fast-xml-parser: ^5.9.3
|
||||||
fastify: ^5.8.5
|
fastify: ^5.8.5
|
||||||
flag-icons: ^7.5.0
|
flag-icons: ^7.5.0
|
||||||
glob: ^13.0.6
|
glob: ^13.0.6
|
||||||
ical.js: ^2.2.1
|
ical.js: ^2.2.1
|
||||||
ioredis: 5.11.1
|
ioredis: 5.11.1
|
||||||
isomorphic-dompurify: ^3.17.0
|
isomorphic-dompurify: ^3.18.0
|
||||||
jotai: ^2.20.1
|
jotai: ^2.20.1
|
||||||
jsdom: ^29.1.1
|
jsdom: ^29.1.1
|
||||||
json5: ^2.2.3
|
json5: ^2.2.3
|
||||||
jsonpath-plus: ^10.4.0
|
jsonpath-plus: ^10.4.0
|
||||||
ldapts: 8.1.8
|
ldapts: 8.1.8
|
||||||
lodash.clonedeep: ^4.5.0
|
lodash.clonedeep: ^4.5.0
|
||||||
lucide-react: ^1.20.0
|
lucide-react: ^1.21.0
|
||||||
"mantine-contextmenu": ^9.3.0
|
"mantine-contextmenu": ^9.3.1
|
||||||
mantine-datatable: ^9.3.0
|
mantine-datatable: ^9.3.1
|
||||||
mantine-react-table: npm:mantine-react-table-open@9.0.3
|
mantine-react-table: npm:mantine-react-table-open@9.0.3
|
||||||
mcp-handler: ^1.1.0
|
mcp-handler: ^1.1.0
|
||||||
maria2: ^0.4.1
|
maria2: ^0.4.1
|
||||||
@@ -150,9 +150,9 @@ catalog:
|
|||||||
next-intl: 4.13.0
|
next-intl: 4.13.0
|
||||||
node-loader: ^2.1.0
|
node-loader: ^2.1.0
|
||||||
octokit: ^5.0.5
|
octokit: ^5.0.5
|
||||||
oxfmt: ^0.54.0
|
oxfmt: ^0.55.0
|
||||||
oxlint: ^1.69.0
|
oxlint: ^1.70.0
|
||||||
pg: ^8.21.0
|
pg: ^8.22.0
|
||||||
postcss-preset-mantine: ^1.18.0
|
postcss-preset-mantine: ^1.18.0
|
||||||
prismjs: ^1.30.0
|
prismjs: ^1.30.0
|
||||||
proxmox-api: 1.1.1
|
proxmox-api: 1.1.1
|
||||||
@@ -169,7 +169,7 @@ catalog:
|
|||||||
semver-parser: ^4.2.1
|
semver-parser: ^4.2.1
|
||||||
superjson: 2.2.6
|
superjson: 2.2.6
|
||||||
svgson: ^5.3.1
|
svgson: ^5.3.1
|
||||||
testcontainers: ^12.0.2
|
testcontainers: ^12.0.3
|
||||||
timezones-ical-library: ^2.2.0
|
timezones-ical-library: ^2.2.0
|
||||||
trpc-to-mcp: ^1.3.2
|
trpc-to-mcp: ^1.3.2
|
||||||
trpc-to-openapi: ^3.3.0
|
trpc-to-openapi: ^3.3.0
|
||||||
@@ -180,9 +180,9 @@ catalog:
|
|||||||
vite: ^8.0.16
|
vite: ^8.0.16
|
||||||
undici: 7.27.2
|
undici: 7.27.2
|
||||||
use-deep-compare-effect: ^1.8.1
|
use-deep-compare-effect: ^1.8.1
|
||||||
video.js: ^8.23.8
|
video.js: ^8.23.9
|
||||||
vite-tsconfig-paths: ^6.1.1
|
vite-tsconfig-paths: ^6.1.1
|
||||||
vitest: ^4.1.8
|
vitest: ^4.1.9
|
||||||
winston: 3.19.0
|
winston: 3.19.0
|
||||||
ws: ^8.21.0
|
ws: ^8.21.0
|
||||||
xml2js: ^0.6.2
|
xml2js: ^0.6.2
|
||||||
@@ -211,13 +211,13 @@ overrides:
|
|||||||
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6"
|
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6"
|
||||||
"esbuild@<=0.24.2": ">=0.28.0"
|
"esbuild@<=0.24.2": ">=0.28.0"
|
||||||
"form-data@>=4.0.0 <4.0.4": ">=4.0.6"
|
"form-data@>=4.0.0 <4.0.4": ">=4.0.6"
|
||||||
"hono@<4.6.5": ">=4.12.25"
|
"hono@<4.6.5": ">=4.12.26"
|
||||||
"linkifyjs@<4.3.2": ">=4.3.3"
|
"linkifyjs@<4.3.2": ">=4.3.3"
|
||||||
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11"
|
"nanoid@>=4.0.0 <5.0.9": ">=5.1.15"
|
||||||
"prismjs@<1.30.0": ">=1.30.0"
|
"prismjs@<1.30.0": ">=1.30.0"
|
||||||
"proxmox-api>undici": "7.27.1"
|
"proxmox-api>undici": "7.27.1"
|
||||||
"react-is": "^19.2.7"
|
"react-is": "^19.2.7"
|
||||||
"rollup@>=4.0.0 <4.22.4": ">=4.62.0"
|
"rollup@>=4.0.0 <4.22.4": ">=4.62.2"
|
||||||
"sha.js@<=2.4.11": ">=2.4.12"
|
"sha.js@<=2.4.11": ">=2.4.12"
|
||||||
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2"
|
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2"
|
||||||
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2"
|
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ runs:
|
|||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 24.16.0
|
node-version: 24.17.0
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
- shell: bash
|
- shell: bash
|
||||||
|
|||||||
Reference in new issue
Block a user