Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d9bfa3978 | ||
|
|
b97856dcb8 | ||
|
|
d6e22a6150 | ||
|
|
dceb3ebee7 | ||
|
|
7c3b42f3fd | ||
|
|
224648a708 | ||
|
|
7a21e2a9c3 | ||
|
|
c4a0c818d0 | ||
|
|
ef016061ae | ||
|
|
3492700e6b | ||
|
|
590d316671 | ||
|
|
6e9d20e651 | ||
|
|
ed829fddfa | ||
|
|
ed8b8a40eb | ||
|
|
7bc3bbb8c0 | ||
|
|
74943ef9bc | ||
|
|
5dd7f839ee | ||
|
|
d8c6c0f978 | ||
|
|
75b2d1bd1b | ||
|
|
7c12a9ce48 | ||
|
|
9033acc66c | ||
|
|
a42dc34d34 | ||
|
|
db3d58821e | ||
|
|
076526293d | ||
|
|
2443f1cd3f | ||
|
|
3864727502 | ||
|
|
f3608b26a0 | ||
|
|
73bed51df8 | ||
|
|
ab9aca8b60 | ||
|
|
dcde2819eb | ||
|
|
abd16354bc | ||
|
|
6ac352c520 | ||
|
|
19f9769d9e | ||
|
|
f0b2486c8a | ||
|
|
2443b89b46 | ||
|
|
915c8a3501 | ||
|
|
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 | ||
|
|
58cb65cb3a | ||
|
|
89380f81dd | ||
|
|
e01571b221 | ||
|
|
49c266374d | ||
|
|
577950d20e | ||
|
|
82bf71b073 | ||
|
|
e26883acfa | ||
|
|
8ee747cde8 | ||
|
|
faa7e55b1b | ||
|
|
ce021a89ac | ||
|
|
55fb227305 |
No files matched your search
@@ -33,6 +33,7 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.68.0
|
||||
- 1.67.0
|
||||
- 1.66.1
|
||||
- 1.66.0
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
- uses: actions/setup-node@v6
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
with:
|
||||
node-version: 24.16.0
|
||||
node-version: 24.17.0
|
||||
package-manager-cache: false
|
||||
- run: npm i -g pnpm
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
[tools]
|
||||
node = "24.16.0"
|
||||
node = "24.17.0"
|
||||
@@ -1,3 +1,29 @@
|
||||
## [1.68.0](https://github.com/homarr-labs/homarr/compare/v1.67.0...v1.68.0) (2026-06-26)
|
||||
|
||||
### Features
|
||||
|
||||
* add VPN status widget ([#5812](https://github.com/homarr-labs/homarr/issues/5812)) ([a2bb53a](https://github.com/homarr-labs/homarr/commit/a2bb53a8b8fd17da3d2e23851b75d4e179f66225))
|
||||
* re-add Docker quick-access button in user menu ([#6054](https://github.com/homarr-labs/homarr/issues/6054)) ([abd1635](https://github.com/homarr-labs/homarr/commit/abd16354bc9ef3dddbb497fd16b03476535046ac)), closes [#2165](https://github.com/homarr-labs/homarr/issues/2165)
|
||||
* ship source maps for readable production stack traces ([#6031](https://github.com/homarr-labs/homarr/issues/6031)) ([89380f8](https://github.com/homarr-labs/homarr/commit/89380f81ddd132f5b1041a8f2c3b6c6b138923dd))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **beszel:** harden cache layer and prevent blank widgets ([#6052](https://github.com/homarr-labs/homarr/issues/6052)) ([49c2663](https://github.com/homarr-labs/homarr/commit/49c266374df12fea2404710232ced42e2de11dbf)), closes [#6030](https://github.com/homarr-labs/homarr/issues/6030)
|
||||
* **beszel:** improve stats date display, add click-to-stats modal, fix grid ordering ([#6026](https://github.com/homarr-labs/homarr/issues/6026)) ([55fb227](https://github.com/homarr-labs/homarr/commit/55fb227305fe51963e486ffb25c4ce6811a2f0e4))
|
||||
* **beszel:** leverage TanStack Query for stale-while-revalidate caching ([#6055](https://github.com/homarr-labs/homarr/issues/6055)) ([bbc4589](https://github.com/homarr-labs/homarr/commit/bbc4589c9e1608721fb58a6f9b03cca8d65c1c4a))
|
||||
* **beszel:** use 480m interval for 1w/30d stats views ([4383da3](https://github.com/homarr-labs/homarr/commit/4383da38ef634327c288626c58480cc1e5a2f805))
|
||||
* **beszel:** widget transparency, error handling, and cache invalidation ([#5993](https://github.com/homarr-labs/homarr/issues/5993)) ([39e4245](https://github.com/homarr-labs/homarr/commit/39e424563a3187dcf0dba5f654e2225be76aef51))
|
||||
* **icon-picker:** keep URL visible in edit mode, add SVG-first ordering ([#6051](https://github.com/homarr-labs/homarr/issues/6051)) ([e01571b](https://github.com/homarr-labs/homarr/commit/e01571b221812cd19a6d67ebe3cf44432104ec27))
|
||||
* **integrations:** prevent certificate trust button from submitting the form ([#6020](https://github.com/homarr-labs/homarr/issues/6020)) ([0f66bfd](https://github.com/homarr-labs/homarr/commit/0f66bfd6c128253ba644d80d7c908de5314cf997))
|
||||
* **manage/about:** bypass self-fetch + add manage error boundary (LLM exploration) ([#6009](https://github.com/homarr-labs/homarr/issues/6009)) ([19f9769](https://github.com/homarr-labs/homarr/commit/19f9769d9e669d56d793e9f6a0d265316113d743)), closes [#6008](https://github.com/homarr-labs/homarr/issues/6008) [#6008](https://github.com/homarr-labs/homarr/issues/6008) [#6008](https://github.com/homarr-labs/homarr/issues/6008)
|
||||
* **plex:** use series title for media releases instead of season label ([#6083](https://github.com/homarr-labs/homarr/issues/6083)) ([2443b89](https://github.com/homarr-labs/homarr/commit/2443b89b46f88fddbf41f0382a9f3bc5469c6fa8))
|
||||
* show app first in add item modal ([#6087](https://github.com/homarr-labs/homarr/issues/6087)) ([915c8a3](https://github.com/homarr-labs/homarr/commit/915c8a3501551fdccf786d0c34dc01107d873ce0))
|
||||
* **truenas:** report actual memory usage and pool free space ([#6017](https://github.com/homarr-labs/homarr/issues/6017)) ([f69e545](https://github.com/homarr-labs/homarr/commit/f69e545d6429aece0e55975c3b2426f20e91e545))
|
||||
* **unraid:** report disk free space instead of total size ([#6018](https://github.com/homarr-labs/homarr/issues/6018)) ([1fb97ee](https://github.com/homarr-labs/homarr/commit/1fb97ee9c6fbe656a7dd3a9d1e436125982533f4))
|
||||
* **widgets:** clean up health monitoring disk and size display ([#6019](https://github.com/homarr-labs/homarr/issues/6019)) ([e29c79a](https://github.com/homarr-labs/homarr/commit/e29c79a9028d9311dcb3d8323e0950a444589c7a))
|
||||
* **widgets:** hide health monitoring default tab option for non-proxmox integrations ([#6021](https://github.com/homarr-labs/homarr/issues/6021)) ([52df56b](https://github.com/homarr-labs/homarr/commit/52df56b19da8e0bcb6c2e5df47b7936ec63f3149))
|
||||
* **widgets:** hide system disk usage value when it overflows and show it in a tooltip ([#6022](https://github.com/homarr-labs/homarr/issues/6022)) ([a19c9d8](https://github.com/homarr-labs/homarr/commit/a19c9d88caacc86b42854c732818f0b5e5cc8dd5))
|
||||
|
||||
## [1.67.0](https://github.com/homarr-labs/homarr/compare/v1.66.1...v1.67.0) (2026-06-19)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# ── CODEOWNERS ───────────────────────────────────────
|
||||
* @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)
|
||||
package.json
|
||||
**/package.json
|
||||
|
||||
+15
-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
|
||||
RUN apk add --no-cache libc6-compat
|
||||
@@ -20,6 +20,17 @@ ARG DISABLE_REDIS_LOGS='true'
|
||||
|
||||
RUN corepack enable pnpm && pnpm build
|
||||
|
||||
# The standalone output tracing omits server chunk source maps (.next/server/**/*.map), which is
|
||||
# where production Node stack frames point. Copy the .map files into the standalone tree (preserving
|
||||
# directory structure) so the existing standalone COPY ships them, keeping traces readable.
|
||||
# See https://github.com/homarr-labs/homarr/issues/5891
|
||||
RUN cd apps/nextjs/.next/server && \
|
||||
find . -name '*.map' | while IFS= read -r map; do \
|
||||
dest="../standalone/apps/nextjs/.next/server/$map"; \
|
||||
mkdir -p "$(dirname "$dest")"; \
|
||||
cp "$map" "$dest"; \
|
||||
done
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
@@ -64,6 +75,9 @@ ENV DB_DRIVER='better-sqlite3'
|
||||
ENV AUTH_PROVIDERS='credentials'
|
||||
ENV REDIS_IS_EXTERNAL='false'
|
||||
ENV NODE_ENV='production'
|
||||
# Tell Node to symbolicate stack traces using the shipped .map files so production logs
|
||||
# show original source locations instead of minified frames, see https://github.com/homarr-labs/homarr/issues/5891
|
||||
ENV NODE_OPTIONS='--enable-source-maps'
|
||||
|
||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||
CMD ["sh", "run.sh"]
|
||||
@@ -316,6 +316,7 @@ You can drag and drop items in to this category in the edit mode.
|
||||
Categories will have a big space before and after them in the edit mode to
|
||||
enable you to drag and drop items outside the category.
|
||||
You can also collapse or open categories in edit mode to facilitate your editing process.
|
||||
Use the category menu to open every app in that category in new tabs, or use the board header action to open every app on the current board layout in new tabs.
|
||||
|
||||
:::note
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -63,7 +63,9 @@ import withoutIntegrations from './img/without-integrations.png';
|
||||
|
||||
### Interactions
|
||||
|
||||
- View calendar events by clicking on the marked dates.
|
||||
- Hover over a marked date to preview its events in a popover.
|
||||
- Click on a marked date to pin the popover open (it will stay visible even after moving the mouse away).
|
||||
- Click the date again or click outside to close the pinned popover.
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
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",
|
||||
};
|
||||
@@ -31,6 +31,8 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
// Ship browser source maps so production client stack traces are readable, see https://github.com/homarr-labs/homarr/issues/5891
|
||||
productionBrowserSourceMaps: true,
|
||||
// react compiler breaks mantine-react-table, so disabled for now
|
||||
//reactCompiler: true,
|
||||
/** We already do typechecking as separate tasks in CI */
|
||||
@@ -41,6 +43,8 @@ const nextConfig: NextConfig = {
|
||||
*/
|
||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
||||
experimental: {
|
||||
// Ship server source maps so production Node stack traces in logs reference original source, see https://github.com/homarr-labs/homarr/issues/5891
|
||||
serverSourceMaps: true,
|
||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
preloadEntriesOnStart: false,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IconBox,
|
||||
IconBoxAlignTop,
|
||||
IconChevronDown,
|
||||
IconExternalLink,
|
||||
IconLayoutBoard,
|
||||
IconPencil,
|
||||
IconPencilOff,
|
||||
@@ -21,8 +22,11 @@ import {
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { openAppsInNewTabs } from "~/components/board/open-apps-in-new-tabs";
|
||||
import { filterByItemKind } from "~/components/board/sections/category/filter";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
@@ -46,9 +50,34 @@ export const BoardContentHeaderActions = () => {
|
||||
const board = useRequiredBoard();
|
||||
const { hasChangeAccess } = useBoardPermissions(board);
|
||||
const { data: demoMode, isLoading } = clientApi.info.isDemoMode.useQuery();
|
||||
const settings = useSettings();
|
||||
const t = useI18n();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const openAllInNewTabs = useCallback(async () => {
|
||||
const currentLayoutId = getCurrentLayout(board);
|
||||
const appIds = filterByItemKind(
|
||||
board.items.filter((item) => item.layouts.some((layout) => layout.layoutId === currentLayoutId)),
|
||||
settings,
|
||||
"app",
|
||||
).map((item) => {
|
||||
return item.options.appId;
|
||||
});
|
||||
|
||||
await openAppsInNewTabs(appIds, { t, openConfirmModal });
|
||||
}, [board, t, openConfirmModal, settings]);
|
||||
|
||||
if (!hasChangeAccess || isLoading) {
|
||||
return <SelectBoardsMenu />;
|
||||
return (
|
||||
<>
|
||||
{!isLoading && (
|
||||
<HeaderButton onClick={openAllInNewTabs} aria-label={t("section.category.action.openAllInNewTabs")}>
|
||||
<IconExternalLink stroke={1.5} />
|
||||
</HeaderButton>
|
||||
)}
|
||||
<SelectBoardsMenu />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -57,6 +86,12 @@ export const BoardContentHeaderActions = () => {
|
||||
|
||||
<EditModeMenu demoMode={demoMode ?? false} />
|
||||
|
||||
{!isEditMode && (
|
||||
<HeaderButton onClick={openAllInNewTabs} aria-label={t("section.category.action.openAllInNewTabs")}>
|
||||
<IconExternalLink stroke={1.5} />
|
||||
</HeaderButton>
|
||||
)}
|
||||
|
||||
{!demoMode && (
|
||||
<OnboardingTour.Target id="board-settings">
|
||||
<HeaderButton href={`/boards/${board.name}/settings`}>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { headers } from "next/headers";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
Accordion,
|
||||
@@ -41,11 +40,10 @@ import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import type { PackageJsonDependencies } from "~/versions/package-reader";
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
import type githubContributorsJson from "../../../../../../../static-data/contributors.json";
|
||||
import type openCollectiveContributorsJson from "../../../../../../../static-data/opencollective-contributors.json";
|
||||
import type crowdinContributorsJson from "../../../../../../../static-data/translators.json";
|
||||
import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader";
|
||||
import githubContributors from "@static-data/contributors.json";
|
||||
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
|
||||
import crowdinContributors from "@static-data/translators.json";
|
||||
import classes from "./about.module.css";
|
||||
|
||||
export async function generateMetadata() {
|
||||
@@ -56,32 +54,10 @@ export async function generateMetadata() {
|
||||
};
|
||||
}
|
||||
|
||||
const getHostAsync = async () => {
|
||||
if (process.env.HOSTNAME) {
|
||||
return `${process.env.HOSTNAME}:3000`;
|
||||
}
|
||||
|
||||
return (await headers()).get("host");
|
||||
};
|
||||
|
||||
export default async function AboutPage() {
|
||||
const baseServerUrl = `http://${await getHostAsync()}`;
|
||||
const t = await getScopedI18n("management.page.about");
|
||||
const version = getPackageVersion();
|
||||
const dependencies = (await fetch(`${baseServerUrl}/api/about/dependencies`).then((res) =>
|
||||
res.json(),
|
||||
)) as PackageJsonDependencies;
|
||||
const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>
|
||||
res.json(),
|
||||
)) as typeof githubContributorsJson;
|
||||
|
||||
const crowdinContributors = (await fetch(`${baseServerUrl}/api/about/contributors/crowdin`).then((res) =>
|
||||
res.json(),
|
||||
)) as typeof crowdinContributorsJson;
|
||||
|
||||
const openCollectiveContributors = (await fetch(`${baseServerUrl}/api/about/contributors/opencollective`).then(
|
||||
(res) => res.json(),
|
||||
)) as typeof openCollectiveContributorsJson;
|
||||
const dependencies = await getDependenciesAsync();
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -185,7 +161,7 @@ export default async function AboutPage() {
|
||||
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3, lg: 4, xl: 5 }} spacing="md">
|
||||
{Object.entries(dependencies)
|
||||
.filter(([, value]) => !value.includes("workspace:"))
|
||||
.sort(([key1], [key2]) => key1.localeCompare(key2))
|
||||
.toSorted(([key1], [key2]) => key1.localeCompare(key2))
|
||||
.map(([name, version]) => (
|
||||
<UnstyledButton
|
||||
key={name}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Button, Center, Stack, Text, Title } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export default function ManageErrorPage({
|
||||
error,
|
||||
unstable_retry,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
unstable_retry: () => void;
|
||||
}) {
|
||||
const tError = useScopedI18n("management.error");
|
||||
const t = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconAlertTriangle size={48} stroke={1.5} />
|
||||
<Title order={3}>{tError("title")}</Title>
|
||||
<Text size="sm" c="dimmed" maw={480} ta="center">
|
||||
{tError("text")}
|
||||
</Text>
|
||||
{error.digest && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{tError("digest", { digest: error.digest })}
|
||||
</Text>
|
||||
)}
|
||||
<Button variant="light" onClick={unstable_retry}>
|
||||
{t("common.action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -149,16 +149,20 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
|
||||
},
|
||||
];
|
||||
|
||||
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
|
||||
interface DockerTableProps {
|
||||
initialData?: RouterOutputs["docker"]["getContainers"];
|
||||
}
|
||||
|
||||
export function DockerTable({ initialData }: DockerTableProps) {
|
||||
const t = useI18n();
|
||||
const tDocker = useScopedI18n("docker");
|
||||
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
initialData: { containers, timestamp },
|
||||
const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {
|
||||
initialData,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
const relativeTime = useTimeAgo(data.timestamp);
|
||||
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
|
||||
async onSuccess() {
|
||||
@@ -176,8 +180,11 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
||||
},
|
||||
});
|
||||
|
||||
const containers = data?.containers ?? [];
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
data: data.containers,
|
||||
data: containers,
|
||||
state: { isLoading: isFetching },
|
||||
enableDensityToggle: false,
|
||||
enableColumnActions: false,
|
||||
enableColumnFilters: false,
|
||||
@@ -190,7 +197,7 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
|
||||
enableBottomToolbar: false,
|
||||
positionGlobalFilter: "right",
|
||||
mantineSearchTextInputProps: {
|
||||
placeholder: tDocker("table.search", { count: String(data.containers.length) }),
|
||||
placeholder: tDocker("table.search", { count: String(containers.length) }),
|
||||
style: { minWidth: 300 },
|
||||
autoFocus: true,
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@ export default async function DockerPage() {
|
||||
<DynamicBreadcrumb />
|
||||
<Stack>
|
||||
<Title order={1}>{tDocker("title")}</Title>
|
||||
<DockerTable containers={containers} timestamp={timestamp} />
|
||||
<DockerTable initialData={{ containers, timestamp }} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import crowdinContributors from "../../../../../../../../static-data/translators.json";
|
||||
import crowdinContributors from "@static-data/translators.json";
|
||||
|
||||
export const GET = () => {
|
||||
return NextResponse.json(crowdinContributors);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import githubContributors from "../../../../../../../../static-data/contributors.json";
|
||||
import githubContributors from "@static-data/contributors.json";
|
||||
|
||||
export const GET = () => {
|
||||
return NextResponse.json(githubContributors);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import openCollectiveContributors from "../../../../../../../../static-data/opencollective-contributors.json";
|
||||
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
|
||||
|
||||
export const GET = () => {
|
||||
return NextResponse.json(openCollectiveContributors);
|
||||
|
||||
@@ -211,7 +211,17 @@ describe("SQLite backup", () => {
|
||||
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -142,7 +142,8 @@ export const useBackupAnalysis = () => {
|
||||
phase: "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`),
|
||||
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],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
|
||||
type AppWithHref = {
|
||||
href: string | null;
|
||||
};
|
||||
|
||||
type TranslationKey = "section.category.openAllInNewTabs.title" | "section.category.openAllInNewTabs.text";
|
||||
|
||||
interface OpenAppsInNewTabsOptions {
|
||||
t: (key: TranslationKey) => string;
|
||||
openConfirmModal: (options: { title: string; children: string }) => void;
|
||||
fetchAppsByIds?: (appIds: string[]) => Promise<AppWithHref[]>;
|
||||
}
|
||||
|
||||
export const openAppsInNewTabs = async (
|
||||
appIds: string[],
|
||||
{ t, openConfirmModal, fetchAppsByIds = fetchApi.app.byIds.query }: OpenAppsInNewTabsOptions,
|
||||
) => {
|
||||
if (appIds.length === 0) return;
|
||||
|
||||
const distinctAppIds = [...new Set(appIds)];
|
||||
const openedWindows = distinctAppIds.map(() => window.open("", "_blank", "noopener,noreferrer"));
|
||||
for (const openedWindow of openedWindows) {
|
||||
if (!openedWindow) continue;
|
||||
openedWindow.opener = null;
|
||||
}
|
||||
|
||||
const apps = await fetchAppsByIds(distinctAppIds);
|
||||
const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0);
|
||||
const tabsToClose = openedWindows.slice(appsWithUrls.length).filter((openedWindow): openedWindow is Window => Boolean(openedWindow));
|
||||
for (const tabToClose of tabsToClose) {
|
||||
tabToClose.close();
|
||||
}
|
||||
|
||||
for (const [index, app] of appsWithUrls.entries()) {
|
||||
const openedWindow = openedWindows[index];
|
||||
if (openedWindow) {
|
||||
openedWindow.location.href = app.href;
|
||||
continue;
|
||||
}
|
||||
|
||||
openConfirmModal({
|
||||
title: t("section.category.openAllInNewTabs.title"),
|
||||
children: t("section.category.openAllInNewTabs.text"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { fetchApi } from "@homarr/api/client";
|
||||
import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
|
||||
import { createId } from "@homarr/common";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
@@ -8,6 +7,7 @@ import { useSettings } from "@homarr/settings";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { CategorySection } from "~/app/[locale]/boards/_types";
|
||||
import { openAppsInNewTabs } from "~/components/board/open-apps-in-new-tabs";
|
||||
import { useCategoryActions } from "./category-actions";
|
||||
import { CategoryEditModal } from "./category-edit-modal";
|
||||
import { filterByItemKind } from "./filter";
|
||||
@@ -115,21 +115,7 @@ export const useCategoryMenuActions = (category: CategorySection) => {
|
||||
return item.options.appId;
|
||||
});
|
||||
|
||||
const apps = await fetchApi.app.byIds.query(appIds);
|
||||
const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0);
|
||||
|
||||
for (const app of appsWithUrls) {
|
||||
const openedWindow = window.open(app.href ?? undefined);
|
||||
if (openedWindow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
openConfirmModal({
|
||||
title: t("section.category.openAllInNewTabs.title"),
|
||||
children: t("section.category.openAllInNewTabs.text"),
|
||||
});
|
||||
break;
|
||||
}
|
||||
await openAppsInNewTabs(appIds, { t, openConfirmModal });
|
||||
}, [category, board, t, openConfirmModal, settings]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { openAppsInNewTabs } from "../open-apps-in-new-tabs";
|
||||
|
||||
describe("openAppsInNewTabs", () => {
|
||||
test("opens tabs synchronously and navigates them to fetched URLs", async () => {
|
||||
const openedWindowOne = { opener: null, location: { href: "" }, close: vi.fn() } as unknown as Window;
|
||||
const openedWindowTwo = { opener: null, location: { href: "" }, close: vi.fn() } as unknown as Window;
|
||||
const openedWindowThree = { opener: null, location: { href: "" }, close: vi.fn() } as unknown as Window;
|
||||
const open = vi.spyOn(window, "open").mockReturnValueOnce(openedWindowOne).mockReturnValueOnce(openedWindowTwo).mockReturnValueOnce(openedWindowThree);
|
||||
const fetchAppsByIds = vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ href: "https://example.com/one" }, { href: null }, { href: "https://example.com/two" }]);
|
||||
const openConfirmModal = vi.fn();
|
||||
|
||||
await openAppsInNewTabs(["app-one", "app-two", "app-three"], {
|
||||
t: (key) => key,
|
||||
openConfirmModal,
|
||||
fetchAppsByIds,
|
||||
});
|
||||
|
||||
expect(fetchAppsByIds).toHaveBeenCalledWith(["app-one", "app-two", "app-three"]);
|
||||
expect(open).toHaveBeenCalledTimes(3);
|
||||
expect(open).toHaveBeenNthCalledWith(1, "", "_blank", "noopener,noreferrer");
|
||||
expect(open).toHaveBeenNthCalledWith(2, "", "_blank", "noopener,noreferrer");
|
||||
expect(open).toHaveBeenNthCalledWith(3, "", "_blank", "noopener,noreferrer");
|
||||
expect(openedWindowOne.location.href).toBe("https://example.com/one");
|
||||
expect(openedWindowTwo.location.href).toBe("https://example.com/two");
|
||||
expect(openedWindowThree.close).toHaveBeenCalledTimes(1);
|
||||
expect(openConfirmModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("de-duplicates app IDs before fetching", async () => {
|
||||
const openedWindow = { opener: null, location: { href: "" }, close: vi.fn() } as unknown as Window;
|
||||
const open = vi.spyOn(window, "open").mockReturnValue(openedWindow);
|
||||
const fetchAppsByIds = vi.fn().mockResolvedValue([{ href: "https://example.com/one" }]);
|
||||
const openConfirmModal = vi.fn();
|
||||
|
||||
await openAppsInNewTabs(["app-one", "app-one", "app-one"], {
|
||||
t: (key) => key,
|
||||
openConfirmModal,
|
||||
fetchAppsByIds,
|
||||
});
|
||||
|
||||
expect(fetchAppsByIds).toHaveBeenCalledWith(["app-one"]);
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("shows confirm modal when popup is blocked", async () => {
|
||||
vi.spyOn(window, "open").mockReturnValue(null);
|
||||
const fetchAppsByIds = vi.fn().mockResolvedValue([{ href: "https://example.com/one" }]);
|
||||
const openConfirmModal = vi.fn();
|
||||
|
||||
await openAppsInNewTabs(["app-one"], {
|
||||
t: (key) => key,
|
||||
openConfirmModal,
|
||||
fetchAppsByIds,
|
||||
});
|
||||
|
||||
expect(openConfirmModal).toHaveBeenCalledWith({
|
||||
title: "section.category.openAllInNewTabs.title",
|
||||
children: "section.category.openAllInNewTabs.text",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,7 @@ export const categoryTranslationKeys: Record<string, string> = {
|
||||
mediaMonitoring: "integration.category.mediaMonitoring",
|
||||
speedtest: "integration.category.speedtest",
|
||||
analytics: "integration.category.analytics",
|
||||
vpn: "integration.category.vpn",
|
||||
documents: "integration.category.documents",
|
||||
mediaLibrary: "integration.category.mediaLibrary",
|
||||
uptimeMonitoring: "integration.category.uptimeMonitoring",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { createModal } from "@homarr/modals";
|
||||
|
||||
import { DockerTable } from "~/app/[locale]/manage/tools/docker/docker-table";
|
||||
|
||||
export const DockerQuickAccessModal = createModal<void>(() => <DockerTable />).withOptions({
|
||||
defaultTitle: (t) => t("docker.title"),
|
||||
size: 1400,
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { UnstyledButton } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import { CurrentUserAvatar } from "~/components/user-avatar";
|
||||
import { UserAvatarMenu } from "~/components/user-avatar-menu";
|
||||
@@ -11,9 +12,10 @@ import { UpdateIndicator } from "./update";
|
||||
export const UserButton = async () => {
|
||||
const session = await auth();
|
||||
const isAdmin = session?.user.permissions.includes("admin");
|
||||
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
|
||||
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
|
||||
return (
|
||||
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise}>
|
||||
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
|
||||
<UnstyledButton>
|
||||
<Suspense fallback={<CurrentUserAvatar size="md" />}>
|
||||
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
|
||||
|
||||
@@ -5,7 +5,15 @@ import { useCallback, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import { useHotkeys, useTimeout } from "@mantine/hooks";
|
||||
import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
|
||||
import {
|
||||
IconBrandDocker,
|
||||
IconCheck,
|
||||
IconHome,
|
||||
IconLogin,
|
||||
IconLogout,
|
||||
IconSettings,
|
||||
IconTool,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { signOut, useSession } from "@homarr/auth/client";
|
||||
@@ -17,14 +25,16 @@ import { Link } from "@homarr/ui";
|
||||
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
|
||||
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
|
||||
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
|
||||
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
|
||||
import { AvailableUpdatesMenuItem } from "./layout/header/update";
|
||||
|
||||
interface UserAvatarMenuProps {
|
||||
children: ReactNode;
|
||||
availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
|
||||
isDockerEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
|
||||
export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => {
|
||||
const t = useScopedI18n("common.userAvatar.menu");
|
||||
const { toggleColorScheme } = useMantineColorScheme();
|
||||
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
|
||||
@@ -34,6 +44,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
||||
|
||||
const { logoutUrl } = useAuthContext();
|
||||
const { openModal } = useModalAction(LogoutModal);
|
||||
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
|
||||
|
||||
const handleSignout = useCallback(async () => {
|
||||
await signOut({
|
||||
@@ -80,6 +91,11 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
|
||||
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
|
||||
{t("management")}
|
||||
</Menu.Item>
|
||||
{isDockerEnabled && (
|
||||
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
|
||||
{t("docker")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"~/*": ["./src/*"],
|
||||
"@static-data/*": ["../../static-data/*"],
|
||||
"@homarr/node-unifi": ["../../node_modules/@types/node-unifi"]
|
||||
},
|
||||
"plugins": [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.16.0-trixie AS base
|
||||
FROM node:24.17.0-trixie AS base
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
COPY pnpm-lock.yaml .
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"better-sqlite3": "^12.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=11.6.0"
|
||||
"node": ">=24.17.0",
|
||||
"pnpm": ">=11.9.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.6.0"
|
||||
"packageManager": "pnpm@11.9.0"
|
||||
}
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.67.0",
|
||||
"version": "1.68.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -61,10 +61,10 @@
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=11.6.0"
|
||||
"node": ">=24.17.0",
|
||||
"pnpm": ">=11.9.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.7.0",
|
||||
"packageManager": "pnpm@11.9.0",
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "catalog:"
|
||||
}
|
||||
|
||||
@@ -15,20 +15,36 @@ export const iconsRouter = createTRPCRouter({
|
||||
})
|
||||
.input(iconsFindSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const term = input.searchText?.toLowerCase().trim() ?? "";
|
||||
const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0);
|
||||
|
||||
let whereCondition = undefined;
|
||||
if (term.length > 0) {
|
||||
whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`)));
|
||||
}
|
||||
|
||||
return {
|
||||
icons: await ctx.db.query.iconRepositories.findMany({
|
||||
with: {
|
||||
icons: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
columns: { id: true, name: true, url: true },
|
||||
where: whereCondition,
|
||||
orderBy: (table, { asc, sql }) => {
|
||||
// ponytail: SVG first, then alphabetical; with a search term,
|
||||
// prepend a relevance tier (exact > prefix > substring).
|
||||
const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
|
||||
const nameAsc = asc(table.name);
|
||||
if (term.length === 0) {
|
||||
return [svgFirst, nameAsc];
|
||||
}
|
||||
const tier = sql`CASE
|
||||
WHEN LOWER(${table.name}) = ${term} THEN 0
|
||||
WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1
|
||||
WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2
|
||||
ELSE 3
|
||||
END`;
|
||||
return [tier, svgFirst, nameAsc];
|
||||
},
|
||||
where:
|
||||
(input.searchText?.length ?? 0) > 0
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
|
||||
: undefined,
|
||||
limit: input.limitPerGroup,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||
import {
|
||||
@@ -14,6 +15,8 @@ import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "beszelRouter" });
|
||||
|
||||
export const beszelRouter = createTRPCRouter({
|
||||
getSystems: publicProcedure
|
||||
.meta({
|
||||
@@ -25,7 +28,9 @@ export const beszelRouter = createTRPCRouter({
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
const results = await Promise.all(
|
||||
const integrationIds = ctx.integrations.map((i) => i.id);
|
||||
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
|
||||
const settled = await Promise.allSettled(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
|
||||
@@ -40,6 +45,29 @@ export const beszelRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
);
|
||||
const results = settled.map((result, index) => {
|
||||
if (result.status === "fulfilled") return result.value;
|
||||
const integration = ctx.integrations[index];
|
||||
logger.warn("getSystems integration failed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration?.id,
|
||||
error: result.reason,
|
||||
});
|
||||
return {
|
||||
integrationId: integration?.id ?? "unknown",
|
||||
integrationName: integration?.name ?? "unknown",
|
||||
integrationUrl: integration?.url ?? "",
|
||||
systems: [],
|
||||
updatedAt: new Date(0),
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
};
|
||||
});
|
||||
logger.debug("getSystems completed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationIds,
|
||||
resultCount: results.length,
|
||||
errorCount: results.filter((r) => "error" in r).length,
|
||||
});
|
||||
return results;
|
||||
}),
|
||||
|
||||
@@ -83,7 +111,14 @@ export const beszelRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const results = await Promise.all(
|
||||
const integrationIds = ctx.integrations.map((i) => i.id);
|
||||
logger.debug("getAlerts called", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationIds,
|
||||
includeHistory: input.includeHistory,
|
||||
maxHistoryItems: input.maxHistoryItems,
|
||||
});
|
||||
const settled = await Promise.allSettled(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
|
||||
includeHistory: input.includeHistory,
|
||||
@@ -100,6 +135,7 @@ export const beszelRouter = createTRPCRouter({
|
||||
}
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
alerts: alertsResult.data.alerts,
|
||||
history: alertsResult.data.history,
|
||||
systemNameMap,
|
||||
@@ -107,6 +143,30 @@ export const beszelRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
);
|
||||
const results = settled.map((result, index) => {
|
||||
if (result.status === "fulfilled") return result.value;
|
||||
const integration = ctx.integrations[index];
|
||||
logger.warn("getAlerts integration failed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration?.id,
|
||||
error: result.reason,
|
||||
});
|
||||
return {
|
||||
integrationId: integration?.id ?? "unknown",
|
||||
integrationName: integration?.name ?? "unknown",
|
||||
alerts: [],
|
||||
history: [],
|
||||
systemNameMap: {},
|
||||
updatedAt: new Date(0),
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
};
|
||||
});
|
||||
logger.debug("getAlerts completed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationIds,
|
||||
resultCount: results.length,
|
||||
errorCount: results.filter((r) => "error" in r).length,
|
||||
});
|
||||
return results;
|
||||
}),
|
||||
|
||||
@@ -164,13 +224,41 @@ export const beszelRouter = createTRPCRouter({
|
||||
if (!integration) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
|
||||
}
|
||||
const innerHandler = beszelStatsRequestHandler.handler(integration, {
|
||||
logger.debug("getSystemStats called", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
timePeriod: input.timePeriod,
|
||||
includeDocker: input.includeDocker,
|
||||
});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return { integrationId: integration.id, ...data, updatedAt: timestamp };
|
||||
try {
|
||||
const innerHandler = beszelStatsRequestHandler.handler(integration, {
|
||||
systemId: input.systemId,
|
||||
timePeriod: input.timePeriod,
|
||||
includeDocker: input.includeDocker,
|
||||
});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
logger.debug("getSystemStats completed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
});
|
||||
return { integrationId: integration.id, ...data, updatedAt: timestamp };
|
||||
} catch (error) {
|
||||
logger.warn("getSystemStats failed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
error,
|
||||
});
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
systemStats: [],
|
||||
containerStats: [],
|
||||
updatedAt: new Date(0),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
subscribeSystemStats: publicProcedure
|
||||
@@ -190,6 +278,11 @@ export const beszelRouter = createTRPCRouter({
|
||||
emit.error(new Error("No Beszel integration configured"));
|
||||
return () => {};
|
||||
}
|
||||
logger.debug("subscribeSystemStats opened", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
@@ -199,6 +292,12 @@ export const beszelRouter = createTRPCRouter({
|
||||
await instance.subscribeRealtimeMetrics(input.systemId, (data) => emit.next(data), abortController.signal);
|
||||
} catch (error) {
|
||||
if (!abortController.signal.aborted) {
|
||||
logger.warn("subscribeSystemStats error", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
error,
|
||||
});
|
||||
if (error instanceof Error) {
|
||||
emit.error(error);
|
||||
} else {
|
||||
@@ -210,6 +309,11 @@ export const beszelRouter = createTRPCRouter({
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
logger.debug("subscribeSystemStats closed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -36,6 +36,7 @@ export const widgetRouter = createTRPCRouter({
|
||||
uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)),
|
||||
audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)),
|
||||
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
|
||||
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
|
||||
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
|
||||
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 { tracearrJob } from "./jobs/integrations/tracearr";
|
||||
import { umamiActiveVisitorsJob } from "./jobs/integrations/umami";
|
||||
import { vpnJob } from "./jobs/integrations/vpn";
|
||||
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
|
||||
import { pingJob } from "./jobs/ping";
|
||||
import { rssFeedsJob } from "./jobs/rss-feeds";
|
||||
@@ -57,6 +58,7 @@ const getJobGroup = () => {
|
||||
timetable: timetableJob,
|
||||
tracearr: tracearrJob,
|
||||
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/gitlab"
|
||||
| "/docs/integrations/glances"
|
||||
| "/docs/integrations/gluetun"
|
||||
| "/docs/integrations/gotify"
|
||||
| "/docs/integrations/home-assistant"
|
||||
| "/docs/integrations/ical"
|
||||
@@ -280,6 +281,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/ups"
|
||||
| "/docs/widgets/uptime-kuma"
|
||||
| "/docs/widgets/video"
|
||||
| "/docs/widgets/vpn"
|
||||
| "/docs/widgets/weather"
|
||||
| ""
|
||||
| "/sitemap.xml"
|
||||
@@ -337,6 +339,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/audiobookshelf"
|
||||
| "/docs/integrations/navidrome"
|
||||
| "/docs/integrations/umami"
|
||||
| "/docs/integrations/gluetun"
|
||||
| "/docs/integrations/peanut"
|
||||
| "/docs/integrations/beszel"
|
||||
| "/docs/widgets/clock"
|
||||
@@ -380,6 +383,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/uptime-kuma"
|
||||
| "/docs/widgets/audio-stats"
|
||||
| "/docs/widgets/umami"
|
||||
| "/docs/widgets/vpn"
|
||||
| "/docs/widgets/ups"
|
||||
| "/docs/widgets/beszel-system-table"
|
||||
| "/docs/widgets/beszel-system-grid"
|
||||
|
||||
@@ -55,6 +55,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
||||
audiobookshelf: "audiobookshelf",
|
||||
navidrome: "navidrome",
|
||||
umami: "umami",
|
||||
gluetun: "gluetun",
|
||||
peaNut: "peanut",
|
||||
beszel: "beszel",
|
||||
archiveTeamWarrior: null,
|
||||
|
||||
@@ -43,6 +43,7 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
uptimeKuma: "uptime-kuma",
|
||||
audioStats: "audio-stats",
|
||||
umami: "umami",
|
||||
vpn: "vpn",
|
||||
ups: "ups",
|
||||
archiveTeamWarrior: null,
|
||||
beszelSystemTable: "beszel-system-table",
|
||||
|
||||
@@ -489,6 +489,13 @@ export const integrationDefs = {
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
|
||||
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: {
|
||||
name: "ArchiveTeam Warrior",
|
||||
secretKinds: [[], ["username", "password"]],
|
||||
@@ -604,6 +611,7 @@ export const integrationCategories = [
|
||||
"mediaMonitoring",
|
||||
"speedtest",
|
||||
"analytics",
|
||||
"vpn",
|
||||
"archiving",
|
||||
"ups",
|
||||
"documents",
|
||||
|
||||
@@ -38,6 +38,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
speedtestTracker: ["speedtestTracker"],
|
||||
uptimeKuma: ["uptimeKuma"],
|
||||
audioStats: ["navidrome", "audiobookshelf"],
|
||||
vpn: getIntegrationKindsByCategory("vpn"),
|
||||
anchorNote: ["anchor"],
|
||||
};
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ export const widgetKinds = [
|
||||
"uptimeKuma",
|
||||
"audioStats",
|
||||
"umami",
|
||||
"vpn",
|
||||
"archiveTeamWarrior",
|
||||
"ups",
|
||||
"beszelSystemTable",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { FocusEventHandler } from "react";
|
||||
import { startTransition } from "react";
|
||||
import { startTransition, useEffect, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Combobox,
|
||||
Flex,
|
||||
@@ -18,7 +21,7 @@ import {
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
|
||||
import { IconUpload } from "@tabler/icons-react";
|
||||
import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
@@ -34,7 +37,6 @@ interface IconPickerProps {
|
||||
error?: string | null;
|
||||
onFocus?: FocusEventHandler;
|
||||
onBlur?: FocusEventHandler;
|
||||
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
withAsterisk?: boolean;
|
||||
@@ -50,29 +52,30 @@ export const IconPicker = ({
|
||||
label,
|
||||
placeholder,
|
||||
}: IconPickerProps) => {
|
||||
const [value, setValue] = useUncontrolled({
|
||||
value: propsValue,
|
||||
onChange,
|
||||
});
|
||||
const [search, setSearch] = useUncontrolled({
|
||||
value,
|
||||
onChange: (value) => {
|
||||
setValue(value);
|
||||
},
|
||||
});
|
||||
const [previewUrl, setPreviewUrl] = useUncontrolled({
|
||||
value: propsValue ?? null,
|
||||
});
|
||||
const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
// ponytail: reset the typed query when the committed value changes
|
||||
// externally (form reset, parent re-render) so the input reverts to the URL.
|
||||
useEffect(() => {
|
||||
setQuery("");
|
||||
}, [value]);
|
||||
|
||||
const { data: session } = useSession();
|
||||
|
||||
const tCommon = useScopedI18n("common");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 100);
|
||||
|
||||
const [debouncedSearch] = useDebouncedValue(search, 100);
|
||||
// ponytail: only search when the typed query differs from the committed
|
||||
// value. On mount the input shows the existing URL (edit mode), which
|
||||
// would never match the bare filenames in the icon table.
|
||||
const searchText = (debouncedQuery !== (value ?? "") && debouncedQuery) || "";
|
||||
|
||||
// We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX.
|
||||
const { data } = clientApi.icon.findIcons.useQuery({
|
||||
searchText: debouncedSearch,
|
||||
});
|
||||
// ponytail: not useSuspenseQuery — an above Suspense boundary would
|
||||
// trigger on every keystroke and the dropdown flash is bad UX.
|
||||
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
|
||||
{ searchText },
|
||||
{ placeholderData: (prev) => prev },
|
||||
);
|
||||
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
@@ -90,25 +93,15 @@ export const IconPicker = ({
|
||||
>
|
||||
<UnstyledButton
|
||||
onClick={() => {
|
||||
const value = item.url;
|
||||
startTransition(() => {
|
||||
setPreviewUrl(value);
|
||||
setSearch(value);
|
||||
setValue(value);
|
||||
setValue(item.url);
|
||||
setQuery("");
|
||||
combobox.closeDropdown();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
|
||||
<Card
|
||||
p="sm"
|
||||
pos="relative"
|
||||
style={{
|
||||
overflow: "visible",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
className={classes.iconCard}
|
||||
>
|
||||
<Card p="sm" pos="relative" style={{ overflow: "visible", cursor: "pointer" }} className={classes.iconCard}>
|
||||
<Box w={25} h={25}>
|
||||
<Image src={item.url} w={25} h={25} />
|
||||
</Box>
|
||||
@@ -130,6 +123,48 @@ export const IconPicker = ({
|
||||
);
|
||||
});
|
||||
|
||||
// ponytail: show the typed query if non-empty, else fall back to the URL.
|
||||
const inputValue = query || value || "";
|
||||
// ponytail: show the existing icon as a left-section preview.
|
||||
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
|
||||
// ponytail: prop override -> real count -> loading text.
|
||||
const placeholderText =
|
||||
placeholder ||
|
||||
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
|
||||
tCommon("iconPicker.headerLoading");
|
||||
|
||||
const renderDropdown = () => {
|
||||
if (isError) {
|
||||
return (
|
||||
<Stack align="center" gap="xs" p="md">
|
||||
<IconSearchOff size={32} stroke={1.5} />
|
||||
<Text size="sm">{tCommon("iconPicker.error")}</Text>
|
||||
<Button size="xs" variant="default" onClick={() => refetch()}>
|
||||
{tCommon("action.tryAgain")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
if (isLoading) {
|
||||
return Array(15)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
|
||||
<Skeleton height={25} visible />
|
||||
</Combobox.Option>
|
||||
));
|
||||
}
|
||||
if (totalOptions > 0) {
|
||||
return <Stack gap={4}>{groups}</Stack>;
|
||||
}
|
||||
return (
|
||||
<Stack align="center" gap="xs" p="md">
|
||||
<IconMoodEmpty size={32} stroke={1.5} />
|
||||
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox store={combobox} withinPortal>
|
||||
<Combobox.Target>
|
||||
@@ -137,16 +172,11 @@ export const IconPicker = ({
|
||||
<InputBase
|
||||
flex={1}
|
||||
rightSection={<Combobox.Chevron />}
|
||||
leftSection={
|
||||
shouldShowPreview(previewUrl) ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null
|
||||
}
|
||||
value={search}
|
||||
leftSection={leftSection}
|
||||
value={inputValue}
|
||||
onChange={(event) => {
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
setSearch(event.currentTarget.value);
|
||||
setValue(event.currentTarget.value);
|
||||
setPreviewUrl(null);
|
||||
setQuery(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={(event) => {
|
||||
@@ -156,25 +186,21 @@ export const IconPicker = ({
|
||||
onBlur={(event) => {
|
||||
onBlur?.(event);
|
||||
combobox.closeDropdown();
|
||||
setPreviewUrl(value);
|
||||
setSearch(value || "");
|
||||
setQuery("");
|
||||
}}
|
||||
rightSectionPointerEvents="none"
|
||||
withAsterisk={withAsterisk}
|
||||
error={error}
|
||||
label={label ?? tCommon("iconPicker.label")}
|
||||
placeholder={placeholder ?? tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })}
|
||||
placeholder={placeholderText}
|
||||
/>
|
||||
{session?.user.permissions.includes("media-upload") && (
|
||||
<UploadMedia
|
||||
onSuccess={(medias) => {
|
||||
const first = medias.at(0);
|
||||
if (!first) return;
|
||||
|
||||
startTransition(() => {
|
||||
setValue(first.url);
|
||||
setPreviewUrl(first.url);
|
||||
setSearch(first.url);
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -190,17 +216,7 @@ export const IconPicker = ({
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
|
||||
{totalOptions > 0 ? (
|
||||
<Stack gap={4}>{groups}</Stack>
|
||||
) : (
|
||||
Array(15)
|
||||
.fill(0)
|
||||
.map((_, index: number) => (
|
||||
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
|
||||
<Skeleton height={25} visible />
|
||||
</Combobox.Option>
|
||||
))
|
||||
)}
|
||||
{renderDropdown()}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
@@ -209,8 +225,8 @@ export const IconPicker = ({
|
||||
|
||||
// This regex is used to prevent loading a preview like en or /en which would trigger a language change
|
||||
// See https://github.com/homarr-labs/homarr/issues/3070
|
||||
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i" /* ignore casing */);
|
||||
const shouldShowPreview = (value: string | null): value is string => {
|
||||
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
|
||||
const shouldShowPreview = (value: string | null | undefined): value is string => {
|
||||
if (!value) return false;
|
||||
return !localizationPathRegex.test(value);
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import { GitHubContainerRegistryIntegration } from "../github-container-registry
|
||||
import { GithubIntegration } from "../github/github-integration";
|
||||
import { GitlabIntegration } from "../gitlab/gitlab-integration";
|
||||
import { GlancesIntegration } from "../glances/glances-integration";
|
||||
import { GluetunIntegration } from "../gluetun/gluetun-integration";
|
||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||
import { ICalIntegration } from "../ical/ical-integration";
|
||||
import { ImmichIntegration } from "../immich/immich-integration";
|
||||
@@ -137,6 +138,7 @@ export const integrationCreators = {
|
||||
audiobookshelf: AudiobookshelfIntegration,
|
||||
navidrome: NavidromeIntegration,
|
||||
umami: UmamiIntegration,
|
||||
gluetun: GluetunIntegration,
|
||||
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
|
||||
uptimeKuma: UptimeKumaIntegration,
|
||||
peaNut: PeaNutIntegration,
|
||||
|
||||
@@ -55,7 +55,7 @@ export abstract class Integration {
|
||||
return this.integration.decryptedSecrets.some((secret) => secret.kind === kind);
|
||||
}
|
||||
|
||||
protected createUrl(
|
||||
private createUrl(
|
||||
inputUrl: string,
|
||||
path: `/${string}`,
|
||||
queryParams?: Record<string, string | Date | number | boolean | null | undefined>,
|
||||
|
||||
@@ -46,10 +46,13 @@ export class BeszelIntegration extends Integration {
|
||||
private async authenticateAsync(): Promise<BeszelSession> {
|
||||
const existingSession = await this.sessionStore.getAsync();
|
||||
if (existingSession) {
|
||||
logger.debug("Using stored Beszel session", { integrationId: this.integration.id });
|
||||
return existingSession;
|
||||
}
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/collections/users/auth-with-password"), {
|
||||
const authUrl = this.url("/api/collections/users/auth-with-password");
|
||||
logger.debug("Authenticating with Beszel", { integrationId: this.integration.id, url: authUrl.pathname });
|
||||
const response = await fetchWithTrustedCertificatesAsync(authUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -57,23 +60,28 @@ export class BeszelIntegration extends Integration {
|
||||
password: this.getSecretValue("password"),
|
||||
}),
|
||||
});
|
||||
logger.debug("Beszel auth response received", { integrationId: this.integration.id, status: response.status });
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn("Beszel auth failed", { integrationId: this.integration.id, status: response.status });
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as BeszelAuthResponse;
|
||||
const session: BeszelSession = { token: data.token, userId: data.record.id };
|
||||
await this.sessionStore.setAsync(session);
|
||||
logger.debug("Saved Beszel session", { integrationId: this.integration.id, userId: session.userId });
|
||||
return session;
|
||||
}
|
||||
|
||||
private async fetchWithAuthAsync(url: URL, options: { method?: string; body?: string } = {}) {
|
||||
const method = options.method ?? "GET";
|
||||
const start = performance.now();
|
||||
let session = await this.authenticateAsync();
|
||||
|
||||
const doFetch = (token: string) =>
|
||||
fetchWithTrustedCertificatesAsync(url, {
|
||||
method: options.method ?? "GET",
|
||||
method,
|
||||
headers: {
|
||||
Authorization: token,
|
||||
...(options.body ? { "Content-Type": "application/json" } : {}),
|
||||
@@ -82,14 +90,42 @@ export class BeszelIntegration extends Integration {
|
||||
});
|
||||
|
||||
let response = await doFetch(session.token);
|
||||
logger.debug("Beszel response", {
|
||||
integrationId: this.integration.id,
|
||||
method,
|
||||
path: url.pathname,
|
||||
status: response.status,
|
||||
durationMs: Math.round(performance.now() - start),
|
||||
attempt: 1,
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
logger.warn("Beszel 401, clearing session and retrying", {
|
||||
integrationId: this.integration.id,
|
||||
method,
|
||||
path: url.pathname,
|
||||
});
|
||||
await this.sessionStore.clearAsync();
|
||||
session = await this.authenticateAsync();
|
||||
response = await doFetch(session.token);
|
||||
logger.debug("Beszel response (after re-auth)", {
|
||||
integrationId: this.integration.id,
|
||||
method,
|
||||
path: url.pathname,
|
||||
status: response.status,
|
||||
durationMs: Math.round(performance.now() - start),
|
||||
attempt: 2,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn("Beszel request failed", {
|
||||
integrationId: this.integration.id,
|
||||
method,
|
||||
path: url.pathname,
|
||||
status: response.status,
|
||||
durationMs: Math.round(performance.now() - start),
|
||||
});
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
@@ -257,6 +293,7 @@ export class BeszelIntegration extends Integration {
|
||||
): Promise<void> {
|
||||
const session = await this.authenticateAsync();
|
||||
const sseUrl = this.url("/api/realtime");
|
||||
logger.debug("Opening Beszel SSE connection", { integrationId: this.integration.id, systemId });
|
||||
|
||||
const sseResponse = await fetchWithTrustedCertificatesAsync(sseUrl, {
|
||||
headers: { Authorization: session.token },
|
||||
@@ -264,8 +301,14 @@ export class BeszelIntegration extends Integration {
|
||||
});
|
||||
|
||||
if (!sseResponse.ok || !sseResponse.body) {
|
||||
logger.warn("Beszel SSE connection failed", {
|
||||
integrationId: this.integration.id,
|
||||
systemId,
|
||||
status: sseResponse.status,
|
||||
});
|
||||
throw new ResponseError(sseResponse);
|
||||
}
|
||||
logger.debug("Beszel SSE connection opened", { integrationId: this.integration.id, systemId });
|
||||
|
||||
const reader = sseResponse.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -322,6 +365,10 @@ export class BeszelIntegration extends Integration {
|
||||
}
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
logger.warn("Beszel SSE stream ended unexpectedly", {
|
||||
integrationId: this.integration.id,
|
||||
systemId,
|
||||
});
|
||||
throw new Error("Beszel SSE stream ended unexpectedly");
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -16,19 +16,6 @@ import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-mo
|
||||
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
|
||||
|
||||
export class DashDotIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
||||
/**
|
||||
* Dashdot v5 had endpoints at /api/info, /api/load/* etc.
|
||||
* Dashdot v6 removed the /api prefix. Users who previously configured
|
||||
* their integration URL as http://host:port/api need it normalized.
|
||||
*/
|
||||
protected override url(
|
||||
path: `/${string}`,
|
||||
queryParams?: Record<string, string | Date | number | boolean | null | undefined>,
|
||||
) {
|
||||
const normalizedUrl = this.integration.url.replace(/\/api\/?$/, "");
|
||||
return this.createUrl(normalizedUrl, path, queryParams);
|
||||
}
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/info"));
|
||||
if (!response.ok) return TestConnectionError.StatusResult(response);
|
||||
|
||||
@@ -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() {
|
||||
const credentials = Buffer.from(
|
||||
`${super.getSecretValue("username")}:${super.getSecretValue("password")}`,
|
||||
).toString("base64");
|
||||
const credentials = Buffer.from(`${super.getSecretValue("username")}:${super.getSecretValue("password")}`).toString(
|
||||
"base64",
|
||||
);
|
||||
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}`);
|
||||
const parsed = result.data;
|
||||
|
||||
return parsed.messages.map((message): Notification => ({
|
||||
id: String(message.id),
|
||||
time: new Date(message.date),
|
||||
title: message.title,
|
||||
body: message.message,
|
||||
}));
|
||||
return parsed.messages.map(
|
||||
(message): Notification => ({
|
||||
id: String(message.id),
|
||||
time: new Date(message.date),
|
||||
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;
|
||||
}
|
||||
@@ -127,11 +127,18 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
|
||||
|
||||
const media =
|
||||
data.MediaContainer.Metadata?.filter((item) => item.Image).map((item) => {
|
||||
const title =
|
||||
item.type === "episode"
|
||||
? (item.grandparentTitle ?? item.title)
|
||||
: item.type === "season"
|
||||
? (item.parentTitle ?? item.title)
|
||||
: item.title;
|
||||
|
||||
return {
|
||||
id: item.Media?.at(0)?.id.toString() ?? item.key,
|
||||
type: mapType(item.type),
|
||||
title: item.title,
|
||||
subtitle: item.tagline,
|
||||
title,
|
||||
subtitle: title === item.title ? item.tagline : item.title,
|
||||
description: item.summary,
|
||||
releaseDate: item.originallyAvailableAt
|
||||
? new Date(item.originallyAvailableAt)
|
||||
@@ -222,6 +229,8 @@ const recentlyAddedSchema = z.object({
|
||||
studio: z.string().optional(),
|
||||
type: z.string(), // For example "movie", "album"
|
||||
title: z.string(),
|
||||
parentTitle: z.string().optional(),
|
||||
grandparentTitle: z.string().optional(),
|
||||
summary: z.string().optional(),
|
||||
duration: z.number().optional(),
|
||||
addedAt: z.number(),
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// @vitest-environment node
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationSecret } from "../../base/types";
|
||||
import { PlexIntegration } from "../plex-integration";
|
||||
|
||||
const imageProxyMocks = vi.hoisted(() => ({
|
||||
createImageAsync: vi.fn((url: string) => Promise.resolve(`proxied:${url}`)),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/image-proxy", () => ({
|
||||
ImageProxy: class {
|
||||
createImageAsync = imageProxyMocks.createImageAsync;
|
||||
},
|
||||
}));
|
||||
|
||||
const TEST_URL = "https://plex.example.com";
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const secrets: IntegrationSecret[] = [{ kind: "apiKey", value: "plex-token" }];
|
||||
|
||||
const createIntegration = () =>
|
||||
new PlexIntegration({
|
||||
id: "test-plex",
|
||||
name: "Test Plex",
|
||||
url: TEST_URL,
|
||||
externalUrl: null,
|
||||
decryptedSecrets: secrets,
|
||||
});
|
||||
|
||||
const createMetadataItem = (key: string, overrides: Record<string, unknown>) => ({
|
||||
key: `/library/metadata/${key}`,
|
||||
type: "movie",
|
||||
title: "Movie",
|
||||
addedAt: 1_735_689_600,
|
||||
Image: [
|
||||
{
|
||||
type: "coverPoster",
|
||||
url: `/library/metadata/${key}/thumb`,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
imageProxyMocks.createImageAsync.mockClear();
|
||||
});
|
||||
|
||||
describe("PlexIntegration.getMediaReleasesAsync", () => {
|
||||
test("uses the series title for TV seasons and episodes", async () => {
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlString = String(url);
|
||||
|
||||
if (urlString.endsWith("/identity")) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ MediaContainer: { machineIdentifier: "server-id" } }), { status: 200 }),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
if (urlString.endsWith("/library/recentlyAdded")) {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
MediaContainer: {
|
||||
Metadata: [
|
||||
createMetadataItem("season", {
|
||||
type: "season",
|
||||
title: "Season 1",
|
||||
parentTitle: "Breaking Bad",
|
||||
}),
|
||||
createMetadataItem("episode", {
|
||||
type: "episode",
|
||||
title: "Review",
|
||||
parentTitle: "Season 1",
|
||||
grandparentTitle: "The Bear",
|
||||
}),
|
||||
createMetadataItem("movie", {
|
||||
type: "movie",
|
||||
title: "Inception",
|
||||
tagline: "Dreams feel real",
|
||||
}),
|
||||
createMetadataItem("show", {
|
||||
type: "show",
|
||||
title: "The Last of Us",
|
||||
tagline: "A series tagline",
|
||||
}),
|
||||
createMetadataItem("missing-parent", {
|
||||
type: "season",
|
||||
title: "Season 3",
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected Plex request: ${urlString}`);
|
||||
});
|
||||
|
||||
const releases = await createIntegration().getMediaReleasesAsync();
|
||||
|
||||
expect(releases).toMatchObject([
|
||||
{
|
||||
title: "Breaking Bad",
|
||||
subtitle: "Season 1",
|
||||
type: "tv",
|
||||
},
|
||||
{
|
||||
title: "The Bear",
|
||||
subtitle: "Review",
|
||||
type: "tv",
|
||||
},
|
||||
{
|
||||
title: "Inception",
|
||||
subtitle: "Dreams feel real",
|
||||
type: "movie",
|
||||
},
|
||||
{
|
||||
title: "The Last of Us",
|
||||
subtitle: "A series tagline",
|
||||
type: "tv",
|
||||
},
|
||||
{
|
||||
title: "Season 3",
|
||||
subtitle: undefined,
|
||||
type: "tv",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ export * from "./interfaces/network-controller-summary/network-controller-summar
|
||||
export * from "./interfaces/firewall-summary/firewall-summary-types";
|
||||
export * from "./interfaces/health-monitoring/health-monitoring-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/media-requests/media-request-types";
|
||||
export * from "./base/searchable-integration";
|
||||
@@ -22,4 +23,5 @@ export * from "./audiobookshelf/audiobookshelf-types";
|
||||
export * from "./navidrome/navidrome-types";
|
||||
export * from "./paperless-ngx/paperless-ngx-types";
|
||||
export * from "./umami/umami-types";
|
||||
export * from "./gluetun/gluetun-types";
|
||||
export * from "./beszel/beszel-types";
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import type { IntegrationTestingInput } from "../src/base/integration";
|
||||
import { DashDotIntegration } from "../src/dashdot/dashdot-integration";
|
||||
|
||||
vi.mock("@homarr/db", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@homarr/db")>();
|
||||
return {
|
||||
...actual,
|
||||
db: createDb(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
|
||||
return {
|
||||
...actual,
|
||||
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@homarr/redis", () => ({}));
|
||||
|
||||
const createIntegration = (url: string) =>
|
||||
new DashDotIntegration({
|
||||
id: "test",
|
||||
name: "Dashdot",
|
||||
url,
|
||||
decryptedSecrets: [],
|
||||
externalUrl: null,
|
||||
});
|
||||
|
||||
describe("DashDot URL normalization", () => {
|
||||
test("url with /api suffix is stripped", async () => {
|
||||
const integration = createIntegration("http://dashdot:3001/api");
|
||||
// Access protected url() via testingAsync which calls this.url("/info")
|
||||
const url = (integration as unknown as { url: (path: string) => URL }).url("/info");
|
||||
expect(url.toString()).toBe("http://dashdot:3001/info");
|
||||
});
|
||||
|
||||
test("url with /api/ trailing slash is stripped", async () => {
|
||||
const integration = createIntegration("http://dashdot:3001/api/");
|
||||
const url = (integration as unknown as { url: (path: string) => URL }).url("/info");
|
||||
expect(url.toString()).toBe("http://dashdot:3001/info");
|
||||
});
|
||||
|
||||
test("url without /api suffix is unchanged", async () => {
|
||||
const integration = createIntegration("http://dashdot:3001");
|
||||
const url = (integration as unknown as { url: (path: string) => URL }).url("/info");
|
||||
expect(url.toString()).toBe("http://dashdot:3001/info");
|
||||
});
|
||||
|
||||
test("url with /api only at end is stripped (not mid-path)", async () => {
|
||||
const integration = createIntegration("http://dashdot:3001/somepath/api");
|
||||
const url = (integration as unknown as { url: (path: string) => URL }).url("/info");
|
||||
expect(url.toString()).toBe("http://dashdot:3001/somepath/info");
|
||||
});
|
||||
|
||||
test("url with path prefix other than /api is preserved", async () => {
|
||||
const integration = createIntegration("http://dashdot:3001/dashdot");
|
||||
const url = (integration as unknown as { url: (path: string) => URL }).url("/load/cpu");
|
||||
expect(url.toString()).toBe("http://dashdot:3001/dashdot/load/cpu");
|
||||
});
|
||||
});
|
||||
@@ -2,15 +2,7 @@
|
||||
|
||||
import type { StartedTestContainer } from "testcontainers";
|
||||
import { GenericContainer, Wait } from "testcontainers";
|
||||
import {
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
afterAll,
|
||||
describe,
|
||||
expect,
|
||||
test,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { beforeAll, beforeEach, afterAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
@@ -31,15 +23,10 @@ vi.mock("@homarr/db", async (importActual) => {
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
const actual =
|
||||
await importActual<
|
||||
typeof import("@homarr/core/infrastructure/certificates")
|
||||
>();
|
||||
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
|
||||
return {
|
||||
...actual,
|
||||
getTrustedCertificateHostnamesAsync: vi
|
||||
.fn()
|
||||
.mockImplementation(() => Promise.resolve([])),
|
||||
getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => Promise.resolve([])),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -90,9 +77,7 @@ const INTEGRATION_ID = "test-technitium";
|
||||
|
||||
const makeInput = (
|
||||
baseUrl: string,
|
||||
auth:
|
||||
| { kind: "credentials"; password?: string }
|
||||
| { kind: "apiKey"; value: string },
|
||||
auth: { kind: "credentials"; password?: string } | { kind: "apiKey"; value: string },
|
||||
): IntegrationInput => ({
|
||||
id: INTEGRATION_ID,
|
||||
name: "Technitium DNS",
|
||||
@@ -109,10 +94,7 @@ const makeInput = (
|
||||
|
||||
// Fetch an API token appropriate for the server version.
|
||||
// v15 has a dedicated createToken endpoint; legacy reuses the session token from login.
|
||||
async function fetchApiToken(
|
||||
baseUrl: string,
|
||||
apiVersion: TechnitiumVersion,
|
||||
): Promise<string> {
|
||||
async function fetchApiToken(baseUrl: string, apiVersion: TechnitiumVersion): Promise<string> {
|
||||
if (apiVersion === "v15") {
|
||||
// v15 exposes a dedicated endpoint for non-expiring API tokens.
|
||||
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
|
||||
// token that can be used as a long-lived API key via the ?token= query parameter.
|
||||
const res = await fetch(
|
||||
`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`,
|
||||
);
|
||||
const res = await fetch(`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`);
|
||||
return ((await res.json()) as { token: string }).token;
|
||||
}
|
||||
|
||||
@@ -144,16 +124,12 @@ const pool: Record<string, ContainerEntry> = {};
|
||||
beforeAll(async () => {
|
||||
await Promise.all(
|
||||
VERSIONS.map(async ({ tag, apiVersion }) => {
|
||||
const container = await new GenericContainer(
|
||||
`technitium/dns-server:${tag}`,
|
||||
)
|
||||
const container = await new GenericContainer(`technitium/dns-server:${tag}`)
|
||||
.withExposedPorts(5380)
|
||||
// Explicitly set the admin password so all versions use the same credentials.
|
||||
// Without this, older versions may generate a random password on first run.
|
||||
.withEnvironment({ DNS_SERVER_ADMIN_PASSWORD: DEFAULT_PASSWORD })
|
||||
.withWaitStrategy(
|
||||
Wait.forLogMessage("Technitium DNS Server was started successfully."),
|
||||
)
|
||||
.withWaitStrategy(Wait.forLogMessage("Technitium DNS Server was started successfully."))
|
||||
.start();
|
||||
const baseUrl = `http://${container.getHost()}:${container.getMappedPort(5380)}`;
|
||||
const apiToken = await fetchApiToken(baseUrl, apiVersion);
|
||||
@@ -163,238 +139,209 @@ beforeAll(async () => {
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(
|
||||
Object.values(pool).map(({ container }) => container.stop()),
|
||||
);
|
||||
await Promise.all(Object.values(pool).map(({ container }) => container.stop()));
|
||||
});
|
||||
|
||||
// ─── version-parameterised suites ────────────────────────────────────────────
|
||||
|
||||
describe.each(VERSIONS)(
|
||||
"Technitium DNS $tag ($apiVersion)",
|
||||
({ tag, apiVersion }) => {
|
||||
let baseUrl: string;
|
||||
let apiToken: string;
|
||||
describe.each(VERSIONS)("Technitium DNS $tag ($apiVersion)", ({ tag, apiVersion }) => {
|
||||
let baseUrl: string;
|
||||
let apiToken: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const entry = pool[tag];
|
||||
if (!entry)
|
||||
throw new Error(`Container pool entry for ${tag} was not initialized`);
|
||||
({ baseUrl, apiToken } = entry);
|
||||
});
|
||||
beforeAll(() => {
|
||||
const entry = pool[tag];
|
||||
if (!entry) throw new Error(`Container pool entry for ${tag} was not initialized`);
|
||||
({ baseUrl, apiToken } = entry);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionState[INTEGRATION_ID] = null;
|
||||
});
|
||||
beforeEach(() => {
|
||||
sessionState[INTEGRATION_ID] = null;
|
||||
});
|
||||
|
||||
// ── factory ────────────────────────────────────────────────────────────────
|
||||
// ── factory ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
|
||||
test("factory creates a working integration (auto-detects version)", async () => {
|
||||
const integration = await createTechnitiumDnsIntegrationAsync(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
|
||||
test("factory creates a working integration (auto-detects version)", async () => {
|
||||
const integration = await createTechnitiumDnsIntegrationAsync(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
const result = await integration.getSummaryAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("enabled");
|
||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||
}, 20_000);
|
||||
});
|
||||
expect(result.status).toBe("enabled");
|
||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
// ── getSummaryAsync ────────────────────────────────────────────────────────
|
||||
// ── getSummaryAsync ────────────────────────────────────────────────────────
|
||||
|
||||
describe("getSummaryAsync", () => {
|
||||
test("returns valid summary with credentials", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
describe("getSummaryAsync", () => {
|
||||
test("returns valid summary with credentials", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
const result = await integration.getSummaryAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||
expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
|
||||
expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
|
||||
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
||||
expect(result.status).toBe("enabled");
|
||||
}, 20_000);
|
||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||
expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
|
||||
expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
|
||||
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
||||
expect(result.status).toBe("enabled");
|
||||
}, 20_000);
|
||||
|
||||
test("returns valid summary with API key", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
||||
apiVersion,
|
||||
);
|
||||
test("returns valid summary with API key", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
||||
apiVersion,
|
||||
);
|
||||
|
||||
const result = await integration.getSummaryAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("enabled");
|
||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||
}, 20_000);
|
||||
expect(result.status).toBe("enabled");
|
||||
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
|
||||
}, 20_000);
|
||||
|
||||
test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
const result = await integration.getSummaryAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(typeof result.domainsBeingBlocked).toBe("number");
|
||||
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
||||
}, 20_000);
|
||||
});
|
||||
expect(typeof result.domainsBeingBlocked).toBe("number");
|
||||
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
// ── session caching ────────────────────────────────────────────────────────
|
||||
// ── session caching ────────────────────────────────────────────────────────
|
||||
|
||||
describe("session caching", () => {
|
||||
test("login is performed once and token is cached for subsequent calls", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
describe("session caching", () => {
|
||||
test("login is performed once and token is cached for subsequent calls", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
await integration.getSummaryAsync();
|
||||
const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
|
||||
expect(tokenAfterFirst).toBeDefined();
|
||||
await integration.getSummaryAsync();
|
||||
const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
|
||||
expect(tokenAfterFirst).toBeDefined();
|
||||
|
||||
await integration.getSummaryAsync();
|
||||
const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
|
||||
await integration.getSummaryAsync();
|
||||
const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
|
||||
|
||||
expect(tokenAfterSecond).toBe(tokenAfterFirst);
|
||||
}, 20_000);
|
||||
expect(tokenAfterSecond).toBe(tokenAfterFirst);
|
||||
}, 20_000);
|
||||
|
||||
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
|
||||
sessionState[INTEGRATION_ID] = {
|
||||
token: "deliberately-invalid-token",
|
||||
version: apiVersion,
|
||||
};
|
||||
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
|
||||
sessionState[INTEGRATION_ID] = {
|
||||
token: "deliberately-invalid-token",
|
||||
version: apiVersion,
|
||||
};
|
||||
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
apiVersion,
|
||||
);
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }), apiVersion);
|
||||
|
||||
const result = await integration.getSummaryAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("enabled");
|
||||
expect(sessionState[INTEGRATION_ID]).not.toBeNull();
|
||||
expect(sessionState[INTEGRATION_ID]?.token).not.toBe(
|
||||
"deliberately-invalid-token",
|
||||
);
|
||||
}, 20_000);
|
||||
});
|
||||
expect(result.status).toBe("enabled");
|
||||
expect(sessionState[INTEGRATION_ID]).not.toBeNull();
|
||||
expect(sessionState[INTEGRATION_ID]?.token).not.toBe("deliberately-invalid-token");
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
// ── blocking state changes ─────────────────────────────────────────────────
|
||||
// ── blocking state changes ─────────────────────────────────────────────────
|
||||
|
||||
describe("blocking state changes", () => {
|
||||
test("disableAsync permanently disables blocking", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
describe("blocking state changes", () => {
|
||||
test("disableAsync permanently disables blocking", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
await integration.disableAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
await integration.disableAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("disabled");
|
||||
expect(result.status).toBe("disabled");
|
||||
|
||||
await integration.enableAsync(); // restore
|
||||
}, 20_000);
|
||||
await integration.enableAsync(); // restore
|
||||
}, 20_000);
|
||||
|
||||
test("enableAsync re-enables blocking after permanent disable", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
test("enableAsync re-enables blocking after permanent disable", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
await integration.disableAsync();
|
||||
await integration.enableAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
await integration.disableAsync();
|
||||
await integration.enableAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("enabled");
|
||||
}, 20_000);
|
||||
expect(result.status).toBe("enabled");
|
||||
}, 20_000);
|
||||
|
||||
test("disableAsync with duration temporarily disables blocking", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
test("disableAsync with duration temporarily disables blocking", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
await integration.disableAsync(120); // 120s → 2 min
|
||||
const result = await integration.getSummaryAsync();
|
||||
await integration.disableAsync(120); // 120s → 2 min
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("disabled");
|
||||
expect(result.status).toBe("disabled");
|
||||
|
||||
await integration.enableAsync(); // restore
|
||||
}, 20_000);
|
||||
await integration.enableAsync(); // restore
|
||||
}, 20_000);
|
||||
|
||||
test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
await integration.disableAsync(1);
|
||||
const result = await integration.getSummaryAsync();
|
||||
await integration.disableAsync(1);
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("disabled");
|
||||
expect(result.status).toBe("disabled");
|
||||
|
||||
await integration.enableAsync(); // restore
|
||||
}, 20_000);
|
||||
await integration.enableAsync(); // restore
|
||||
}, 20_000);
|
||||
|
||||
test("enableAsync cancels an active timed disable immediately", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
);
|
||||
test("enableAsync cancels an active timed disable immediately", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
|
||||
|
||||
await integration.disableAsync(600); // 10 minutes
|
||||
await integration.enableAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
await integration.disableAsync(600); // 10 minutes
|
||||
await integration.enableAsync();
|
||||
const result = await integration.getSummaryAsync();
|
||||
|
||||
expect(result.status).toBe("enabled");
|
||||
}, 20_000);
|
||||
});
|
||||
expect(result.status).toBe("enabled");
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
// ── testConnectionAsync ────────────────────────────────────────────────────
|
||||
// ── testConnectionAsync ────────────────────────────────────────────────────
|
||||
|
||||
describe("testConnectionAsync", () => {
|
||||
test("succeeds with valid credentials", async () => {
|
||||
const result = await new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
).testConnectionAsync();
|
||||
expect(result.success).toBe(true);
|
||||
}, 20_000);
|
||||
describe("testConnectionAsync", () => {
|
||||
test("succeeds with valid credentials", async () => {
|
||||
const result = await new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "credentials" }),
|
||||
).testConnectionAsync();
|
||||
expect(result.success).toBe(true);
|
||||
}, 20_000);
|
||||
|
||||
test("succeeds with valid API key", async () => {
|
||||
const result = await new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
||||
apiVersion,
|
||||
).testConnectionAsync();
|
||||
expect(result.success).toBe(true);
|
||||
}, 20_000);
|
||||
test("succeeds with valid API key", async () => {
|
||||
const result = await new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
|
||||
apiVersion,
|
||||
).testConnectionAsync();
|
||||
expect(result.success).toBe(true);
|
||||
}, 20_000);
|
||||
|
||||
test("fails with wrong password — returns authorization error", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, {
|
||||
kind: "credentials",
|
||||
password: "wrong-password",
|
||||
}),
|
||||
);
|
||||
test("fails with wrong password — returns authorization error", async () => {
|
||||
const integration = new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, {
|
||||
kind: "credentials",
|
||||
password: "wrong-password",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await integration.testConnectionAsync();
|
||||
const result = await integration.testConnectionAsync();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||
expect(result.error.type).toBe("authorization");
|
||||
}, 20_000);
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||
expect(result.error.type).toBe("authorization");
|
||||
}, 20_000);
|
||||
|
||||
test("fails with invalid API key — returns authorization error", async () => {
|
||||
const result = await new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
|
||||
apiVersion,
|
||||
).testConnectionAsync();
|
||||
test("fails with invalid API key — returns authorization error", async () => {
|
||||
const result = await new TechnitiumDnsIntegration(
|
||||
makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
|
||||
apiVersion,
|
||||
).testConnectionAsync();
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||
expect(result.error.type).toBe("authorization");
|
||||
}, 20_000);
|
||||
});
|
||||
},
|
||||
);
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) return;
|
||||
expect(result.error).toBeInstanceOf(TestConnectionError);
|
||||
expect(result.error.type).toBe("authorization");
|
||||
}, 20_000);
|
||||
});
|
||||
});
|
||||
@@ -415,8 +415,18 @@ export const createChannelWithLatestAndEvents = <TData>(channelName: string) =>
|
||||
});
|
||||
},
|
||||
publishAndUpdateLastStateAsync: async (data: TData) => {
|
||||
await publisher.publish(channelName, superjson.stringify(data));
|
||||
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
||||
// Set first, then publish. If publish fails the storage is still consistent
|
||||
// and subscribers will catch up on the next event.
|
||||
const payload = superjson.stringify({ data, timestamp: new Date() });
|
||||
await getSetClient.set(channelName, payload);
|
||||
try {
|
||||
await publisher.publish(channelName, superjson.stringify(data));
|
||||
} catch (error) {
|
||||
logger.warn("Failed to publish cache update (storage is correct, subscribers will catch up)", {
|
||||
channel: channelName,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
setAsync: async (data: TData) => {
|
||||
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type {
|
||||
BeszelAlert,
|
||||
@@ -13,6 +14,8 @@ import type {
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
|
||||
const logger = createLogger({ module: "beszelRequestHandler" });
|
||||
|
||||
export type { BeszelSystemRow } from "@homarr/integrations/types";
|
||||
|
||||
/**
|
||||
@@ -56,6 +59,7 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
|
||||
Record<string, never>
|
||||
>({
|
||||
async requestAsync(integration) {
|
||||
const start = performance.now();
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
const systems = await instance.getSystemsAsync();
|
||||
const enriched = await Promise.all(
|
||||
@@ -64,9 +68,16 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
|
||||
return mapToSystemRow(system, details);
|
||||
}),
|
||||
);
|
||||
logger.debug("beszelSystems fetch completed", {
|
||||
integrationId: integration.id,
|
||||
durationMs: Math.round(performance.now() - start),
|
||||
count: enriched.length,
|
||||
});
|
||||
return enriched;
|
||||
},
|
||||
cacheDuration: dayjs.duration(5, "seconds"),
|
||||
cacheDuration: dayjs.duration(1, "second"),
|
||||
fallbackToStaleOnError: true,
|
||||
retry: { attempts: 2, delayMs: 500 },
|
||||
queryKey: "beszelSystems",
|
||||
});
|
||||
|
||||
@@ -81,22 +92,31 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
{ includeHistory: boolean; maxHistoryItems: number }
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const start = performance.now();
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
const alerts = await instance.getAlertsAsync();
|
||||
const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : [];
|
||||
logger.debug("beszelAlerts fetch completed", {
|
||||
integrationId: integration.id,
|
||||
durationMs: Math.round(performance.now() - start),
|
||||
alertsCount: alerts.length,
|
||||
historyCount: history.length,
|
||||
});
|
||||
return { alerts, history };
|
||||
},
|
||||
cacheDuration: dayjs.duration(15, "seconds"),
|
||||
cacheDuration: dayjs.duration(1, "second"),
|
||||
fallbackToStaleOnError: true,
|
||||
retry: { attempts: 2, delayMs: 500 },
|
||||
queryKey: "beszelAlerts",
|
||||
});
|
||||
|
||||
const timePeriodConfig: Record<string, { type: string; perPage: number; cacheSeconds: number }> = {
|
||||
"1m": { type: "1m", perPage: 60, cacheSeconds: 5 },
|
||||
"1h": { type: "1m", perPage: 60, cacheSeconds: 60 },
|
||||
"12h": { type: "10m", perPage: 72, cacheSeconds: 300 },
|
||||
"24h": { type: "20m", perPage: 72, cacheSeconds: 600 },
|
||||
"1w": { type: "120m", perPage: 84, cacheSeconds: 1800 },
|
||||
"30d": { type: "480m", perPage: 90, cacheSeconds: 3600 },
|
||||
const timePeriodConfig: Record<string, { type: string; perPage: number }> = {
|
||||
"1m": { type: "1m", perPage: 60 },
|
||||
"1h": { type: "1m", perPage: 60 },
|
||||
"12h": { type: "10m", perPage: 72 },
|
||||
"24h": { type: "20m", perPage: 72 },
|
||||
"1w": { type: "480m", perPage: 21 },
|
||||
"30d": { type: "480m", perPage: 90 },
|
||||
};
|
||||
|
||||
export interface BeszelStatsData {
|
||||
@@ -110,19 +130,25 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
|
||||
{ systemId: string; timePeriod: string; includeDocker: boolean }
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 };
|
||||
const start = performance.now();
|
||||
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 };
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
|
||||
const containerStats = input.includeDocker
|
||||
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => [])
|
||||
: [];
|
||||
logger.debug("beszelStats fetch completed", {
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
timePeriod: input.timePeriod,
|
||||
durationMs: Math.round(performance.now() - start),
|
||||
systemStatsCount: systemStats.length,
|
||||
containerStatsCount: containerStats.length,
|
||||
});
|
||||
return { systemStats, containerStats };
|
||||
},
|
||||
cacheDuration: dayjs.duration(60, "seconds"),
|
||||
cacheDuration: dayjs.duration(1, "second"),
|
||||
fallbackToStaleOnError: true,
|
||||
retry: { attempts: 2, delayMs: 500 },
|
||||
queryKey: "beszelStats",
|
||||
cacheDurationForInput(input) {
|
||||
const config = timePeriodConfig[input.timePeriod];
|
||||
if (!config) return undefined;
|
||||
return dayjs.duration(config.cacheSeconds, "seconds");
|
||||
},
|
||||
});
|
||||
@@ -18,6 +18,9 @@ interface Options<TData, TKind extends IntegrationKind, TInput extends Record<st
|
||||
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
||||
cacheDuration: Duration;
|
||||
cacheDurationForInput?: (input: TInput) => Duration | undefined;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
retry?: { attempts?: number; delayMs?: number };
|
||||
isValid?: (data: TData) => boolean;
|
||||
}
|
||||
|
||||
export const createCachedIntegrationRequestHandler = <
|
||||
@@ -35,6 +38,9 @@ export const createCachedIntegrationRequestHandler = <
|
||||
return await options.requestAsync(input.integration, input.options);
|
||||
},
|
||||
cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration,
|
||||
fallbackToStaleOnError: options.fallbackToStaleOnError,
|
||||
retry: options.retry,
|
||||
isValid: options.isValid,
|
||||
createRedisChannel(input, handlerOptions) {
|
||||
return createIntegrationOptionsChannel<TData>(input.integration.id, handlerOptions.queryKey, input.options);
|
||||
},
|
||||
|
||||
@@ -7,6 +7,16 @@ import type { createChannelWithLatestAndEvents } from "@homarr/redis";
|
||||
|
||||
const logger = createLogger({ module: "cachedRequestHandler" });
|
||||
|
||||
type FetchResult<TData> = { data: TData; timestamp: Date };
|
||||
type InFlightEntry = Promise<FetchResult<unknown>>;
|
||||
|
||||
const inFlightFetches = new Map<string, InFlightEntry>();
|
||||
|
||||
const isAbortedError = (error: unknown): boolean => {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return error.name === "AbortError" || error.name === "TimeoutError";
|
||||
};
|
||||
|
||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
// Unique key for this request handler
|
||||
queryKey: string;
|
||||
@@ -17,28 +27,70 @@ interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
) => ReturnType<typeof createChannelWithLatestAndEvents<TData>>;
|
||||
cacheDuration: Duration;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
// Retry the upstream call on failure. attempts=1 means no retry (default).
|
||||
// AbortError / TimeoutError are never retried.
|
||||
retry?: { attempts?: number; delayMs?: number };
|
||||
// Predicate to decide whether a fetched response is worth caching.
|
||||
// Return false to skip the cache write (next call will refetch).
|
||||
isValid?: (data: TData) => boolean;
|
||||
}
|
||||
|
||||
const defaultRetry = { attempts: 1, delayMs: 0 };
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export const createCachedRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => {
|
||||
return {
|
||||
handler: (input: TInput) => {
|
||||
const channel = options.createRedisChannel(input, options);
|
||||
const retryConfig = { ...defaultRetry, ...options.retry };
|
||||
|
||||
return {
|
||||
async getCachedOrUpdatedDataAsync({ forceUpdate = false }) {
|
||||
const channelData = await channel.getAsync();
|
||||
|
||||
const requestNewDataAsync = async () => {
|
||||
try {
|
||||
const data = await options.requestAsync(input);
|
||||
await channel.publishAndUpdateLastStateAsync(data);
|
||||
return {
|
||||
data,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
const requestNewDataAsync = (): Promise<FetchResult<TData>> => {
|
||||
const inflight = inFlightFetches.get(channel.name);
|
||||
if (inflight) {
|
||||
logger.debug("Cached request handler joining in-flight fetch", {
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
});
|
||||
return inflight as Promise<FetchResult<TData>>;
|
||||
}
|
||||
|
||||
const promise = (async (): Promise<FetchResult<TData>> => {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= retryConfig.attempts; attempt++) {
|
||||
try {
|
||||
const data = await options.requestAsync(input);
|
||||
if (options.isValid && !options.isValid(data)) {
|
||||
logger.warn("Cached request handler received invalid data, not caching", {
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
attempt,
|
||||
});
|
||||
return { data, timestamp: new Date() };
|
||||
}
|
||||
await channel.publishAndUpdateLastStateAsync(data);
|
||||
return { data, timestamp: new Date() };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (isAbortedError(error) || attempt === retryConfig.attempts) {
|
||||
break;
|
||||
}
|
||||
logger.warn("Cached request handler fetch failed, retrying", {
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
attempt,
|
||||
nextDelayMs: retryConfig.delayMs,
|
||||
error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
|
||||
});
|
||||
await sleep(retryConfig.delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (options.fallbackToStaleOnError && channelData) {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
@@ -46,15 +98,21 @@ export const createCachedRequestHandler = <TData, TInput extends Record<string,
|
||||
{
|
||||
channel: channel.name,
|
||||
queryKey: options.queryKey,
|
||||
attempts: retryConfig.attempts,
|
||||
},
|
||||
{ cause: error },
|
||||
{ cause: lastError },
|
||||
),
|
||||
);
|
||||
return channelData;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
throw lastError;
|
||||
})().finally(() => {
|
||||
inFlightFetches.delete(channel.name);
|
||||
});
|
||||
|
||||
inFlightFetches.set(channel.name, promise as InFlightEntry);
|
||||
return promise;
|
||||
};
|
||||
|
||||
if (forceUpdate) {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -3374,27 +3374,27 @@
|
||||
"pausedCount": "Pausiert"
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": ""
|
||||
"internalServerError": "Fehler beim Abrufen der Laufzeit Daten"
|
||||
}
|
||||
},
|
||||
"audioStats": {
|
||||
"name": "Audiostatistiken",
|
||||
"description": "",
|
||||
"description": "Bibliotheks- und Wiedergabestatistiken von Navidrome oder Audiobookshelf",
|
||||
"option": {
|
||||
"showArtists": {
|
||||
"label": "Künstler anzeigen",
|
||||
"description": ""
|
||||
"description": "Zeige die Gesamtzahl der Künstler in deiner Musikbibliothek (Navidrome)"
|
||||
},
|
||||
"showAlbums": {
|
||||
"label": "",
|
||||
"label": "Alben anzeigen",
|
||||
"description": "Zeigt die Gesamtzahl der Alben in deiner Musikbibliothek an (Navidrome)"
|
||||
},
|
||||
"showSongs": {
|
||||
"label": "Songs anzeigen",
|
||||
"description": ""
|
||||
"description": "Zeigt die Gesamtzahl der Titel in deiner Musikbibliothek an (Navidrome)"
|
||||
},
|
||||
"showNowPlaying": {
|
||||
"label": "",
|
||||
"label": "\"Sie hören\" anzeigen",
|
||||
"description": ""
|
||||
},
|
||||
"showNowPlayingList": {
|
||||
@@ -3720,7 +3720,7 @@
|
||||
},
|
||||
"chart": {
|
||||
"cpu": {
|
||||
"title": "CPU-Auslastung",
|
||||
"title": "CPU Auslastung",
|
||||
"subtitle": "",
|
||||
"series": "CPU"
|
||||
},
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
},
|
||||
"password": {
|
||||
"label": "Password",
|
||||
"suggestionsTitle": "",
|
||||
"suggestionsTitle": "Password suggestions",
|
||||
"suggestion": {
|
||||
"length": "Includes a minimum of 8 characters",
|
||||
"lowercase": "Includes a lower-case letter",
|
||||
@@ -687,28 +687,28 @@
|
||||
"customWidget": {
|
||||
"page": {
|
||||
"list": {
|
||||
"title": "",
|
||||
"noResults": "",
|
||||
"loading": ""
|
||||
"title": "Custom Widgets",
|
||||
"noResults": "No custom widgets yet. Create your first one!",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"create": {
|
||||
"title": "",
|
||||
"subtitle": ""
|
||||
"title": "Create Custom Widget",
|
||||
"subtitle": "Define an API URL, authentication, and how to display the response data."
|
||||
},
|
||||
"edit": {
|
||||
"title": ""
|
||||
"title": "Edit Custom Widget"
|
||||
}
|
||||
},
|
||||
"action": {
|
||||
"create": "",
|
||||
"import": "",
|
||||
"save": "",
|
||||
"delete": "",
|
||||
"deleteConfirm": "",
|
||||
"export": "",
|
||||
"addMapping": "",
|
||||
"addColumn": "",
|
||||
"edit": "",
|
||||
"create": "Create",
|
||||
"import": "Import",
|
||||
"save": "Save",
|
||||
"delete": "Delete Custom Widget",
|
||||
"deleteConfirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
|
||||
"export": "Export",
|
||||
"addMapping": "Add Mapping",
|
||||
"addColumn": "Add Column",
|
||||
"edit": "Edit",
|
||||
"removeMapping": "",
|
||||
"removeColumn": "",
|
||||
"addItem": "",
|
||||
@@ -5599,71 +5599,71 @@
|
||||
"label": "",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Select your colour scheme"
|
||||
}
|
||||
}
|
||||
},
|
||||
"locale": {
|
||||
"label": "",
|
||||
"label": "Language",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Select your preferred language"
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultSearchEngineId": {
|
||||
"label": "",
|
||||
"label": "Default search engine",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Select your default search engine"
|
||||
}
|
||||
}
|
||||
},
|
||||
"openSearchInNewTab": {
|
||||
"label": "",
|
||||
"label": "Open search results in new tab",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Open search results in new tab"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ddgBangs": {
|
||||
"label": "",
|
||||
"label": "Enable DuckDuckGo bangs",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Enable DuckDuckGo bangs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"firstDayOfWeek": {
|
||||
"label": "",
|
||||
"label": "First day of the week",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Select the first day of the week"
|
||||
}
|
||||
}
|
||||
},
|
||||
"homeBoardId": {
|
||||
"label": "",
|
||||
"label": "Home board",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Select your home board"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mobileHomeBoardId": {
|
||||
"label": "",
|
||||
"label": "Mobile home board",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Select your mobile home board"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pingIconsEnabled": {
|
||||
"label": "",
|
||||
"label": "Use icons for pings",
|
||||
"children": {
|
||||
"detail": {
|
||||
"title": ""
|
||||
"title": "Use icons for pings"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"mediaMonitoring": "Monitoring",
|
||||
"speedtest": "Speed Test",
|
||||
"analytics": "Analytics",
|
||||
"vpn": "VPN",
|
||||
"mediaLibrary": "Media Library",
|
||||
"uptimeMonitoring": "Uptime Monitoring"
|
||||
},
|
||||
@@ -1412,7 +1413,10 @@
|
||||
"here": "here",
|
||||
"iconPicker": {
|
||||
"label": "Icon URL",
|
||||
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you."
|
||||
"header": "Type name or objects to filter for icons... Homarr will search through {countIcons} icons for you.",
|
||||
"headerLoading": "Type to search icons…",
|
||||
"noResults": "No icons match \"{search}\".",
|
||||
"error": "Couldn't load icons."
|
||||
},
|
||||
"colorScheme": {
|
||||
"options": {
|
||||
@@ -1464,6 +1468,7 @@
|
||||
"switchToDarkMode": "Switch to dark mode",
|
||||
"switchToLightMode": "Switch to light mode",
|
||||
"management": "Management",
|
||||
"docker": "Docker",
|
||||
"preferences": "Your preferences",
|
||||
"logout": "Logout",
|
||||
"login": "Login",
|
||||
@@ -2352,6 +2357,9 @@
|
||||
"name": "Docker stats",
|
||||
"description": "Stats of your containers (This widget can only be added with administrator privileges)",
|
||||
"option": {
|
||||
"columns": {
|
||||
"label": "Columns to show"
|
||||
},
|
||||
"enableRowSorting": {
|
||||
"label": "Enable items sorting"
|
||||
},
|
||||
@@ -2405,6 +2413,12 @@
|
||||
"title": "Instances"
|
||||
}
|
||||
},
|
||||
"vpn": {
|
||||
"name": "VPN",
|
||||
"description": "Overview of your VPN connections",
|
||||
"serviceUnavailable": "VPN unavailable",
|
||||
"option": {}
|
||||
},
|
||||
"ups": {
|
||||
"name": "UPS",
|
||||
"description": "Monitor the status of your UPS devices through a NUT server",
|
||||
@@ -3639,6 +3653,9 @@
|
||||
"error": {
|
||||
"internalServerError": "Unable to connect to Beszel. Check your integration settings."
|
||||
},
|
||||
"empty": {
|
||||
"noSystems": "No systems found"
|
||||
},
|
||||
"metric": {
|
||||
"cpu": "CPU",
|
||||
"memory": "Memory",
|
||||
@@ -3712,6 +3729,9 @@
|
||||
}
|
||||
},
|
||||
"selectSystem": "Select system",
|
||||
"empty": {
|
||||
"noSystems": "No systems found"
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Unable to connect to Beszel. Check your integration settings.",
|
||||
"liveConnectionFailed": "Live connection failed. Your Beszel instance may not support real-time streaming.",
|
||||
@@ -4133,6 +4153,11 @@
|
||||
"title": "Not Found",
|
||||
"text": "Could not find requested resource"
|
||||
},
|
||||
"error": {
|
||||
"title": "This page couldn't load",
|
||||
"text": "An unexpected error occurred while loading this page. Your navigation is still available — try reloading, or use another section from the sidebar.",
|
||||
"digest": "Error reference: {digest}"
|
||||
},
|
||||
"navbar": {
|
||||
"items": {
|
||||
"home": "Home",
|
||||
@@ -4286,7 +4311,7 @@
|
||||
"notStarted": "Not started"
|
||||
},
|
||||
"name": {
|
||||
"management": "Management",
|
||||
"management": "Management",
|
||||
"dashboard": "Dashboard"
|
||||
}
|
||||
}
|
||||
@@ -4579,6 +4604,9 @@
|
||||
},
|
||||
"umamiActiveVisitors": {
|
||||
"label": "Umami Active Visitors"
|
||||
},
|
||||
"vpn": {
|
||||
"label": "VPN Status"
|
||||
}
|
||||
},
|
||||
"interval": {
|
||||
@@ -5071,7 +5099,8 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "Failed to fetch Docker containers"
|
||||
"internalServerError": "Failed to fetch Docker containers",
|
||||
"noColumns": "Select at least one column in the widget settings"
|
||||
}
|
||||
},
|
||||
"kubernetes": {
|
||||
|
||||
@@ -4943,8 +4943,8 @@
|
||||
"search": "Cerca {count} container",
|
||||
"selected": "{selectCount} di {totalCount} containers selezionati",
|
||||
"footer": "Totale container {count}",
|
||||
"totalCpu": "",
|
||||
"totalMemory": ""
|
||||
"totalCpu": "CPU Totale: {cpu}%",
|
||||
"totalMemory": "Mem Totale: {memory}"
|
||||
},
|
||||
"field": {
|
||||
"name": {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -687,7 +687,7 @@
|
||||
"customWidget": {
|
||||
"page": {
|
||||
"list": {
|
||||
"title": "",
|
||||
"title": "Власні віджети",
|
||||
"noResults": "",
|
||||
"loading": ""
|
||||
},
|
||||
@@ -1021,7 +1021,7 @@
|
||||
"mediaMonitoring": "",
|
||||
"speedtest": "",
|
||||
"analytics": "",
|
||||
"mediaLibrary": "",
|
||||
"mediaLibrary": "Медіа бібліотека",
|
||||
"uptimeMonitoring": ""
|
||||
},
|
||||
"testConnection": {
|
||||
@@ -1139,7 +1139,7 @@
|
||||
"description": {
|
||||
"connection": {
|
||||
"hostUnreachable": "",
|
||||
"networkUnreachable": "",
|
||||
"networkUnreachable": "Мережа недоступна. Перевірте підключення до Інтернету або налаштування мережі.",
|
||||
"refused": "",
|
||||
"reset": ""
|
||||
},
|
||||
@@ -1328,7 +1328,7 @@
|
||||
}
|
||||
},
|
||||
"paperlessNgx": {
|
||||
"name": "",
|
||||
"name": "Безпаперовий ngx",
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
@@ -1679,9 +1679,9 @@
|
||||
"label": "",
|
||||
"option": {
|
||||
"row": "",
|
||||
"row-reverse": "",
|
||||
"row-reverse": "Горизонтально (зворотнє)",
|
||||
"column": "",
|
||||
"column-reverse": ""
|
||||
"column-reverse": "Вертикальна (перевернута)"
|
||||
}
|
||||
},
|
||||
"descriptionDisplayMode": {
|
||||
@@ -1690,7 +1690,7 @@
|
||||
"option": {
|
||||
"normal": "",
|
||||
"tooltip": "",
|
||||
"hidden": ""
|
||||
"hidden": "Приховати"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1703,7 +1703,7 @@
|
||||
},
|
||||
"bookmarks": {
|
||||
"name": "Закладки",
|
||||
"description": "",
|
||||
"description": "Показує декілька посилань додатка",
|
||||
"option": {
|
||||
"title": {
|
||||
"label": "Назва"
|
||||
@@ -1747,8 +1747,8 @@
|
||||
}
|
||||
},
|
||||
"archiveTeamWarrior": {
|
||||
"name": "",
|
||||
"description": "",
|
||||
"name": "АрхівTeam Warrior",
|
||||
"description": "Слідкуйте за станом, проєктами та активними архівними завданнями ArchiveTeam Warrior.",
|
||||
"option": {
|
||||
"showBroadcastMessage": {
|
||||
"label": ""
|
||||
@@ -1778,7 +1778,7 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"internalServerError": "",
|
||||
"internalServerError": "Не вдалося отримати резюме DNS Hole",
|
||||
"integrationsDisconnected": "Немає даних, всі інтеграції відключені"
|
||||
},
|
||||
"data": {
|
||||
@@ -1808,7 +1808,7 @@
|
||||
}
|
||||
},
|
||||
"showToggleAllButtons": {
|
||||
"label": ""
|
||||
"label": "Показати всі кнопки"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Badge, Box, Divider, Group, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { Badge, Box, Center, Divider, Group, Loader, ScrollArea, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
@@ -12,6 +12,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
|
||||
const alertIconMap: Record<string, LucideIcon> = {
|
||||
CPU: Cpu,
|
||||
@@ -40,9 +41,14 @@ export default function BeszelAlertsWidget({
|
||||
() => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
|
||||
[integrationIds, options.showHistory, options.maxHistoryItems],
|
||||
);
|
||||
const { data: results = [], error: alertsError } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, {
|
||||
staleTime: 15_000,
|
||||
refetchInterval: 15_000,
|
||||
const {
|
||||
data: results = [],
|
||||
error: alertsError,
|
||||
isPending,
|
||||
} = clientApi.widget.beszel.getAlerts.useQuery(alertsInput, {
|
||||
staleTime: 10_000,
|
||||
gcTime: 48 * 60 * 60 * 1000,
|
||||
refetchInterval: 10_000,
|
||||
retry: false,
|
||||
});
|
||||
const utils = clientApi.useUtils();
|
||||
@@ -93,113 +99,126 @@ export default function BeszelAlertsWidget({
|
||||
|
||||
if (alertsError) throw alertsError;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<Stack gap="sm" p="sm">
|
||||
{alerts.length === 0 && (
|
||||
<Stack align="center" justify="center" py="xl" gap="xs">
|
||||
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
|
||||
<IconBellOff size={18} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("empty")}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{triggeredAlerts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconFlame size={14} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="red">
|
||||
{t("status.triggered")} ({triggeredAlerts.length})
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||
<BeszelIntegrationErrorIndicator results={results} />
|
||||
</Box>
|
||||
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<Stack gap="sm" p="sm">
|
||||
{alerts.length === 0 && (
|
||||
<Stack align="center" justify="center" py="xl" gap="xs">
|
||||
<ThemeIcon variant="light" color="gray" size="lg" radius="xl">
|
||||
<IconBellOff size={18} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("empty")}
|
||||
</Text>
|
||||
</Group>
|
||||
{triggeredAlerts.map((alert) => (
|
||||
<AlertRow
|
||||
key={alert._key}
|
||||
name={alert.name}
|
||||
value={alert.value}
|
||||
min={alert.min}
|
||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||
triggered
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
|
||||
|
||||
{okAlerts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||
{t("status.ok")} ({okAlerts.length})
|
||||
</Text>
|
||||
</Group>
|
||||
{okAlerts.map((alert) => (
|
||||
<AlertRow
|
||||
key={alert._key}
|
||||
name={alert.name}
|
||||
value={alert.value}
|
||||
min={alert.min}
|
||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||
triggered={false}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{options.showHistory && history.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
{triggeredAlerts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconHistory size={14} opacity={0.5} />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||
{t("history")}
|
||||
<IconFlame size={14} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="red">
|
||||
{t("status.triggered")} ({triggeredAlerts.length})
|
||||
</Text>
|
||||
</Group>
|
||||
{history.map((entry) => {
|
||||
const systemName = systemNameMap[entry.system] ?? entry.system;
|
||||
const isResolved = !!entry.resolved;
|
||||
const HistoryIcon = alertIconMap[entry.name] ?? Server;
|
||||
return (
|
||||
<Group key={entry._key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
w={3}
|
||||
h={24}
|
||||
style={{ borderRadius: 2, flexShrink: 0 }}
|
||||
bg={isResolved ? "green.6" : "red.6"}
|
||||
/>
|
||||
<HistoryIcon size={12} opacity={0.5} style={{ flexShrink: 0 }} />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={500} truncate>
|
||||
{entry.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{systemName}
|
||||
{triggeredAlerts.map((alert) => (
|
||||
<AlertRow
|
||||
key={alert._key}
|
||||
name={alert.name}
|
||||
value={alert.value}
|
||||
min={alert.min}
|
||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||
triggered
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{triggeredAlerts.length > 0 && okAlerts.length > 0 && <Divider />}
|
||||
|
||||
{okAlerts.length > 0 && (
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconCircleCheck size={14} color="var(--mantine-color-green-6)" />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||
{t("status.ok")} ({okAlerts.length})
|
||||
</Text>
|
||||
</Group>
|
||||
{okAlerts.map((alert) => (
|
||||
<AlertRow
|
||||
key={alert._key}
|
||||
name={alert.name}
|
||||
value={alert.value}
|
||||
min={alert.min}
|
||||
systemName={systemNameMap[alert.system] ?? alert.system}
|
||||
triggered={false}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{options.showHistory && history.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap={6}>
|
||||
<Group gap={6}>
|
||||
<IconHistory size={14} opacity={0.5} />
|
||||
<Text size="xs" fw={600} tt="uppercase" c="dimmed">
|
||||
{t("history")}
|
||||
</Text>
|
||||
</Group>
|
||||
{history.map((entry) => {
|
||||
const systemName = systemNameMap[entry.system] ?? entry.system;
|
||||
const isResolved = !!entry.resolved;
|
||||
const HistoryIcon = alertIconMap[entry.name] ?? Server;
|
||||
return (
|
||||
<Group key={entry._key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
w={3}
|
||||
h={24}
|
||||
style={{ borderRadius: 2, flexShrink: 0 }}
|
||||
bg={isResolved ? "green.6" : "red.6"}
|
||||
/>
|
||||
<HistoryIcon size={12} opacity={0.5} style={{ flexShrink: 0 }} />
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text size="xs" fw={500} truncate>
|
||||
{entry.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{systemName}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
|
||||
<Badge size="xs" variant="dot" color={isResolved ? "green" : "red"}>
|
||||
{isResolved ? t("resolved") : t("status.triggered")}
|
||||
</Badge>
|
||||
<Text size="10px" c="dimmed">
|
||||
{dayjs(entry.created).fromNow()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack gap={0} align="flex-end" style={{ flexShrink: 0 }}>
|
||||
<Badge size="xs" variant="dot" color={isResolved ? "green" : "red"}>
|
||||
{isResolved ? t("resolved") : t("status.triggered")}
|
||||
</Badge>
|
||||
<Text size="10px" c="dimmed">
|
||||
{dayjs(entry.created).fromNow()}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.clickableCard {
|
||||
transition: background-color 150ms;
|
||||
}
|
||||
|
||||
.clickableCard:hover {
|
||||
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { MantineSize } from "@mantine/core";
|
||||
import { Badge, Box, Card, Group, Progress, Text, Stack } from "@mantine/core";
|
||||
import { Badge, Box, Card, Center, Group, Loader, Progress, Text, Stack } from "@mantine/core";
|
||||
import {
|
||||
Activity,
|
||||
Battery,
|
||||
@@ -15,16 +15,22 @@ import {
|
||||
Thermometer,
|
||||
Wifi,
|
||||
} from "lucide-react";
|
||||
import { IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./component.module.css";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import type { BeszelSystemRow } from "../beszel/_shared/types";
|
||||
import { statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
||||
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
|
||||
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
|
||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
|
||||
interface SizeConfig {
|
||||
iconSize: number;
|
||||
@@ -154,6 +160,7 @@ interface SystemCardProps {
|
||||
size: SizeConfig;
|
||||
maxMetrics: number;
|
||||
itemRadius: MantineSize;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const metricRenderers = [
|
||||
@@ -310,7 +317,7 @@ const metricRenderers = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius }: SystemCardProps) => {
|
||||
const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick }: SystemCardProps) => {
|
||||
const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics);
|
||||
|
||||
return (
|
||||
@@ -319,11 +326,14 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius }: System
|
||||
radius={itemRadius}
|
||||
bg="transparent"
|
||||
h="100%"
|
||||
onClick={onClick}
|
||||
className={onClick ? classes.clickableCard : undefined}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
border: "0.0625rem solid var(--border-color)",
|
||||
cursor: onClick ? "pointer" : undefined,
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" mb={2}>
|
||||
@@ -353,10 +363,15 @@ export default function BeszelSystemGridWidget({
|
||||
}: WidgetComponentProps<"beszelSystemGrid">) {
|
||||
const t = useScopedI18n("widget.beszelSystemGrid");
|
||||
const board = useRequiredBoard();
|
||||
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
||||
|
||||
const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery(
|
||||
const {
|
||||
data: results = [],
|
||||
error: systemsError,
|
||||
isPending,
|
||||
} = clientApi.widget.beszel.getSystems.useQuery(
|
||||
{ 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);
|
||||
@@ -364,6 +379,30 @@ export default function BeszelSystemGridWidget({
|
||||
|
||||
if (systemsError) throw systemsError;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredSystems.length === 0) {
|
||||
return (
|
||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<BeszelIntegrationErrorIndicator results={results} />
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={28} opacity={0.5} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("empty.noSystems")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const cols = getColCount(width, filteredSystems.length);
|
||||
const rows = Math.ceil(filteredSystems.length / cols) || 1;
|
||||
const rawCellHeight = height / rows;
|
||||
@@ -374,28 +413,37 @@ export default function BeszelSystemGridWidget({
|
||||
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
|
||||
|
||||
return (
|
||||
<Box
|
||||
h="100%"
|
||||
style={{
|
||||
pointerEvents: isEditMode ? "none" : undefined,
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
|
||||
gap: size.gap,
|
||||
overflow: scrollEnabled ? "auto" : "hidden",
|
||||
}}
|
||||
>
|
||||
{filteredSystems.map((system) => (
|
||||
<SystemCard
|
||||
key={system._key}
|
||||
system={system}
|
||||
options={options}
|
||||
t={t}
|
||||
size={size}
|
||||
maxMetrics={maxMetrics}
|
||||
itemRadius={board.itemRadius}
|
||||
/>
|
||||
))}
|
||||
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
|
||||
<BeszelIntegrationErrorIndicator results={results} />
|
||||
<Box
|
||||
h="100%"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
|
||||
gap: size.gap,
|
||||
overflow: scrollEnabled ? "auto" : "hidden",
|
||||
}}
|
||||
>
|
||||
{filteredSystems.map((system) => {
|
||||
const integrationId = system._key.split(":")[0] ?? "";
|
||||
const handleClick = isEditMode
|
||||
? undefined
|
||||
: () => openModal({ integrationId, systemId: system.id }, { title: system.name });
|
||||
return (
|
||||
<SystemCard
|
||||
key={system._key}
|
||||
system={system}
|
||||
options={options}
|
||||
t={t}
|
||||
size={size}
|
||||
maxMetrics={maxMetrics}
|
||||
itemRadius={board.itemRadius}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Button, Center, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconPlugConnectedX, IconServer, IconQuestionMark } from "@tabler/icons-react";
|
||||
import { Button, Box, Center, Loader, Menu, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
import { IconPlugConnectedX, IconServer, IconQuestionMark, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
@@ -15,6 +15,7 @@ import classes from "./component.module.css";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { containerColors } from "../beszel/_shared/colors";
|
||||
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
|
||||
import {
|
||||
BeszelChartPanel,
|
||||
CPU_Y_AXIS_DOMAIN,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
formatPercent,
|
||||
formatStorageBytes,
|
||||
} from "../beszel/_shared/format";
|
||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
import { makeTooltipProps } from "../beszel/_shared/tooltip";
|
||||
import { useLiveStats } from "../beszel/_shared/use-live-stats";
|
||||
|
||||
@@ -65,7 +67,10 @@ export default function BeszelSystemStatsWidget({
|
||||
data: systemsResult = [],
|
||||
isPending: systemsPending,
|
||||
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(
|
||||
() => systemsResult.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name }))),
|
||||
@@ -97,13 +102,19 @@ export default function BeszelSystemStatsWidget({
|
||||
timePeriod: options.timePeriod as "1m" | "1h" | "12h" | "24h" | "1w" | "30d",
|
||||
includeDocker: showDocker,
|
||||
},
|
||||
{ staleTime: 30 * 1000, enabled: !isLive && systemReady && selectedSystem !== "", retry: false },
|
||||
{
|
||||
staleTime: 10_000,
|
||||
gcTime: 48 * 60 * 60 * 1000,
|
||||
refetchInterval: isLive ? false : 10_000,
|
||||
enabled: !isLive && systemReady && selectedSystem !== "",
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: liveData, error: liveError } = useLiveStats(integrationIds, selectedSystem, isLive && systemReady);
|
||||
|
||||
let activeStats:
|
||||
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[] }
|
||||
| { systemStats: BeszelSystemStatsRecord[]; containerStats: BeszelContainerStatsRecord[]; error?: string }
|
||||
| null
|
||||
| undefined = statsResult;
|
||||
if (isLive) {
|
||||
@@ -153,30 +164,31 @@ export default function BeszelSystemStatsWidget({
|
||||
const systemStats = activeStats?.systemStats;
|
||||
const containerStatsRaw = activeStats?.containerStats;
|
||||
|
||||
const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, isLive);
|
||||
const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, isLive);
|
||||
const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, isLive);
|
||||
const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, isLive);
|
||||
const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, isLive);
|
||||
const timePeriod = options.timePeriod as BeszelTimePeriod;
|
||||
const cpuData = useSystemChartData(statsWhenShown(options.showCpu, systemStats), mappers.cpu, timePeriod);
|
||||
const memoryData = useSystemChartData(statsWhenShown(options.showMemory, systemStats), mappers.memory, timePeriod);
|
||||
const diskData = useSystemChartData(statsWhenShown(options.showDisk, systemStats), mappers.disk, timePeriod);
|
||||
const diskIOData = useSystemChartData(statsWhenShown(options.showDiskIO, systemStats), mappers.diskIO, timePeriod);
|
||||
const networkData = useSystemChartData(statsWhenShown(options.showNetwork, systemStats), mappers.network, timePeriod);
|
||||
|
||||
const containerNames = useContainerNames(statsWhenShown(showDocker, containerStatsRaw));
|
||||
const dockerCpuData = useDockerChartData(
|
||||
statsWhenShown(options.showDockerCpu, containerStatsRaw),
|
||||
containerNames,
|
||||
"cpu",
|
||||
isLive,
|
||||
timePeriod,
|
||||
);
|
||||
const dockerMemData = useDockerChartData(
|
||||
statsWhenShown(options.showDockerMemory, containerStatsRaw),
|
||||
containerNames,
|
||||
"memory",
|
||||
isLive,
|
||||
timePeriod,
|
||||
);
|
||||
const dockerNetData = useDockerChartData(
|
||||
statsWhenShown(options.showDockerNetwork, containerStatsRaw),
|
||||
containerNames,
|
||||
"network",
|
||||
isLive,
|
||||
timePeriod,
|
||||
);
|
||||
|
||||
const containerSeries = useMemo(
|
||||
@@ -189,6 +201,30 @@ export default function BeszelSystemStatsWidget({
|
||||
if (systemsError) throw systemsError;
|
||||
if (!isLive && statsError) throw statsError;
|
||||
|
||||
if (systemsPending) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (systems.length === 0) {
|
||||
return (
|
||||
<Box h="100%" pos="relative">
|
||||
<BeszelIntegrationErrorIndicator results={systemsResult} />
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={28} opacity={0.5} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("empty.noSystems")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!systemsPending && !systemExists && systems.length > 0) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
@@ -226,164 +262,186 @@ export default function BeszelSystemStatsWidget({
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
h="100%"
|
||||
className={classes.beszelStatsContainer}
|
||||
style={{ pointerEvents: (isEditMode && "none") || undefined }}
|
||||
>
|
||||
<Stack gap="md" p="sm">
|
||||
{!isEditMode && systems.length > 1 && (
|
||||
<Menu position="bottom-start" withArrow shadow="md" withinPortal>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-xs"
|
||||
leftSection={<IconServer size={14} />}
|
||||
className={classes.beszelStatsSystemToggle}
|
||||
>
|
||||
{selectedLabel}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{systems.map((s) => (
|
||||
<Menu.Item
|
||||
key={s.value}
|
||||
fz="xs"
|
||||
fw={s.value === selectedSystem ? 600 : 400}
|
||||
c={s.value !== selectedSystem ? "dimmed" : undefined}
|
||||
onClick={() => handleSelectSystem(s.value)}
|
||||
<Box h="100%" pos="relative">
|
||||
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
|
||||
<BeszelIntegrationErrorIndicator results={systemsResult} />
|
||||
</Box>
|
||||
<ScrollArea
|
||||
h="100%"
|
||||
className={classes.beszelStatsContainer}
|
||||
style={{ pointerEvents: (isEditMode && "none") || undefined }}
|
||||
>
|
||||
<Stack gap="md" p="sm">
|
||||
{!isEditMode && systems.length > 1 && (
|
||||
<Menu position="bottom-start" withArrow shadow="md" withinPortal>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-xs"
|
||||
leftSection={<IconServer size={14} />}
|
||||
className={classes.beszelStatsSystemToggle}
|
||||
>
|
||||
{s.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
{selectedLabel}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{systems.map((s) => (
|
||||
<Menu.Item
|
||||
key={s.value}
|
||||
fz="xs"
|
||||
fw={s.value === selectedSystem ? 600 : 400}
|
||||
c={s.value !== selectedSystem ? "dimmed" : undefined}
|
||||
onClick={() => handleSelectSystem(s.value)}
|
||||
>
|
||||
{s.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
|
||||
{activeStats && (
|
||||
<SimpleGrid cols={cols} spacing="md">
|
||||
{options.showCpu && cpuData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.cpu.title")}
|
||||
subtitle={t("chart.cpu.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: cpuData,
|
||||
series: series.cpu,
|
||||
yAxisFormatter: chartAxisFormatters.percent,
|
||||
yAxisDomain: CPU_Y_AXIS_DOMAIN,
|
||||
tooltipProps: tooltipPercent,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isLive && !activeStats && systemReady && selectedSystem !== "" && (
|
||||
<Center h={CHART_HEIGHT}>
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{options.showMemory && memoryData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.memory.title")}
|
||||
subtitle={t("chart.memory.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: memoryData,
|
||||
type: "stacked",
|
||||
series: series.memory,
|
||||
yAxisFormatter: chartAxisFormatters.gb,
|
||||
tooltipProps: tooltipGB,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isLive && activeStats && "error" in activeStats && activeStats.error && (
|
||||
<Center h={CHART_HEIGHT}>
|
||||
<Stack align="center" gap="xs">
|
||||
<IconServerOff size={24} opacity={0.5} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{activeStats.error}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{options.showDisk && diskData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.disk.title")}
|
||||
subtitle={t("chart.disk.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: diskData,
|
||||
series: series.disk,
|
||||
yAxisFormatter: chartAxisFormatters.gb,
|
||||
tooltipProps: tooltipGB,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{activeStats && (!("error" in activeStats) || !activeStats.error) && (
|
||||
<SimpleGrid cols={cols} spacing="md">
|
||||
{options.showCpu && cpuData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.cpu.title")}
|
||||
subtitle={t("chart.cpu.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: cpuData,
|
||||
series: series.cpu,
|
||||
yAxisFormatter: chartAxisFormatters.percent,
|
||||
yAxisDomain: CPU_Y_AXIS_DOMAIN,
|
||||
tooltipProps: tooltipPercent,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.showDiskIO && diskIOData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.diskIO.title")}
|
||||
subtitle={t("chart.diskIO.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: diskIOData,
|
||||
series: series.diskIO,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{options.showMemory && memoryData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.memory.title")}
|
||||
subtitle={t("chart.memory.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: memoryData,
|
||||
type: "stacked",
|
||||
series: series.memory,
|
||||
yAxisFormatter: chartAxisFormatters.gb,
|
||||
tooltipProps: tooltipGB,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.showNetwork && networkData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.network.title")}
|
||||
subtitle={t("chart.network.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: networkData,
|
||||
series: series.network,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{options.showDisk && diskData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.disk.title")}
|
||||
subtitle={t("chart.disk.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: diskData,
|
||||
series: series.disk,
|
||||
yAxisFormatter: chartAxisFormatters.gb,
|
||||
tooltipProps: tooltipGB,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDocker && containerSeries.length > 0 && (
|
||||
<>
|
||||
{options.showDockerCpu && dockerCpuData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.dockerCpu.title")}
|
||||
subtitle={t("chart.dockerCpu.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerCpuData,
|
||||
type: "stacked",
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.percent,
|
||||
tooltipProps: tooltipPercentTotal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{options.showDiskIO && diskIOData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.diskIO.title")}
|
||||
subtitle={t("chart.diskIO.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: diskIOData,
|
||||
series: series.diskIO,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.showDockerMemory && dockerMemData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.dockerMemory.title")}
|
||||
subtitle={t("chart.dockerMemory.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerMemData,
|
||||
type: "stacked",
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.bytes,
|
||||
tooltipProps: tooltipBytesTotal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{options.showNetwork && networkData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.network.title")}
|
||||
subtitle={t("chart.network.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: networkData,
|
||||
series: series.network,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.showDockerNetwork && dockerNetData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.dockerNetwork.title")}
|
||||
subtitle={t("chart.dockerNetwork.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerNetData,
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
{showDocker && containerSeries.length > 0 && (
|
||||
<>
|
||||
{options.showDockerCpu && dockerCpuData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.dockerCpu.title")}
|
||||
subtitle={t("chart.dockerCpu.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerCpuData,
|
||||
type: "stacked",
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.percent,
|
||||
tooltipProps: tooltipPercentTotal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.showDockerMemory && dockerMemData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.dockerMemory.title")}
|
||||
subtitle={t("chart.dockerMemory.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerMemData,
|
||||
type: "stacked",
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.bytes,
|
||||
tooltipProps: tooltipBytesTotal,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{options.showDockerNetwork && dockerNetData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.dockerNetwork.title")}
|
||||
subtitle={t("chart.dockerNetwork.subtitle")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: dockerNetData,
|
||||
series: containerSeries,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import "./styles.css";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Group, Indicator, Progress, Text } from "@mantine/core";
|
||||
import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core";
|
||||
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
|
||||
import { DataTable } from "mantine-datatable";
|
||||
import {
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
@@ -27,6 +28,8 @@ import type { BeszelSystemRow } from "../beszel/_shared/types";
|
||||
import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors";
|
||||
import { formatByteRate, formatLoadAvg, formatPercent, formatTemp, formatUptime } from "../beszel/_shared/format";
|
||||
import { useBeszelFilteredSystems, useBeszelSystemsSubscription } from "../beszel/_shared/hooks";
|
||||
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
|
||||
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
|
||||
|
||||
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
|
||||
|
||||
@@ -66,9 +69,14 @@ export default function BeszelSystemTableWidget({
|
||||
width,
|
||||
}: WidgetComponentProps<"beszelSystemTable">) {
|
||||
const t = useScopedI18n("widget.beszelSystemTable");
|
||||
const { data: results = [], error: systemsError } = clientApi.widget.beszel.getSystems.useQuery(
|
||||
const { openModal } = useModalAction(BeszelSystemStatsModal);
|
||||
const {
|
||||
data: results = [],
|
||||
error: systemsError,
|
||||
isPending,
|
||||
} = clientApi.widget.beszel.getSystems.useQuery(
|
||||
{ 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);
|
||||
|
||||
@@ -255,23 +263,42 @@ export default function BeszelSystemTableWidget({
|
||||
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
|
||||
}, [options, t, size]);
|
||||
|
||||
const handleRowClick = ({ record }: { record: SystemRowWithKey }) => {
|
||||
const integrationId = record._key.split(":")[0] ?? "";
|
||||
openModal({ integrationId, systemId: record.id }, { title: record.name });
|
||||
};
|
||||
|
||||
if (systemsError) throw systemsError;
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
withTableBorder={false}
|
||||
borderRadius={0}
|
||||
highlightOnHover
|
||||
fz={size.fontSize}
|
||||
records={sortedSystems}
|
||||
columns={columns}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
noRecordsText={t("noRecords")}
|
||||
idAccessor="_key"
|
||||
height="100%"
|
||||
className="beszel-table"
|
||||
/>
|
||||
<div style={{ position: "relative", height: "100%" }}>
|
||||
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
|
||||
<BeszelIntegrationErrorIndicator results={results} />
|
||||
</div>
|
||||
<DataTable
|
||||
style={{ pointerEvents: isEditMode ? "none" : undefined }}
|
||||
withTableBorder={false}
|
||||
borderRadius={0}
|
||||
highlightOnHover
|
||||
fz={size.fontSize}
|
||||
records={sortedSystems}
|
||||
columns={columns}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
noRecordsText={t("noRecords")}
|
||||
idAccessor="_key"
|
||||
height="100%"
|
||||
className="beszel-table"
|
||||
onRowClick={isEditMode ? undefined : handleRowClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,14 +6,23 @@
|
||||
.beszel-table .mantine-datatable-table thead,
|
||||
.beszel-table .mantine-datatable-table tbody,
|
||||
.beszel-table .mantine-datatable-table tfoot,
|
||||
.beszel-table .mantine-datatable-table tr,
|
||||
.beszel-table th,
|
||||
.beszel-table td {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.beszel-table .mantine-datatable-table tr {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.beszel-table .mantine-datatable-table tbody tr:hover {
|
||||
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
|
||||
}
|
||||
|
||||
.beszel-table th {
|
||||
white-space: nowrap;
|
||||
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.beszel-table td {
|
||||
|
||||
@@ -8,14 +8,46 @@ import dayjs from "dayjs";
|
||||
|
||||
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
|
||||
|
||||
const formatTime = (timestamp: string) => dayjs(timestamp).format("HH:mm");
|
||||
const formatTimeLive = (timestamp: string) => dayjs(timestamp).format("HH:mm:ss");
|
||||
export type BeszelTimePeriod = "1m" | "1h" | "12h" | "24h" | "1w" | "30d";
|
||||
|
||||
function prepareRecords<T>(records: T[], live: boolean) {
|
||||
if (live) {
|
||||
return { fmt: formatTimeLive, ordered: records };
|
||||
const timeFormats: Record<BeszelTimePeriod, string> = {
|
||||
"1m": "HH:mm:ss",
|
||||
"1h": "HH:mm",
|
||||
"12h": "HH:mm",
|
||||
"24h": "HH:mm",
|
||||
"1w": "MMM D",
|
||||
"30d": "MMM D",
|
||||
};
|
||||
|
||||
const periodDays: Partial<Record<BeszelTimePeriod, number>> = { "1w": 7, "30d": 30 };
|
||||
|
||||
function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod) {
|
||||
const fmt = (timestamp: string) => dayjs(timestamp).format(timeFormats[timePeriod]);
|
||||
if (timePeriod === "1m") {
|
||||
return { fmt, ordered: records };
|
||||
}
|
||||
return { fmt: formatTime, ordered: [...records].toReversed() };
|
||||
return { fmt, ordered: [...records].toReversed() };
|
||||
}
|
||||
|
||||
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod) {
|
||||
const days = periodDays[timePeriod];
|
||||
if (!days) return data;
|
||||
|
||||
const fmt = (ts: string) => dayjs(ts).format(timeFormats[timePeriod]);
|
||||
const now = dayjs();
|
||||
const existingDays = new Set(data.map((d) => dayjs(d.rawTime as string).format("YYYY-MM-DD")));
|
||||
const result = [...data];
|
||||
|
||||
for (let i = days; i >= 0; i--) {
|
||||
const d = now.subtract(i, "day").startOf("day");
|
||||
const key = d.format("YYYY-MM-DD");
|
||||
if (!existingDays.has(key)) {
|
||||
const iso = d.toISOString();
|
||||
result.push({ time: fmt(iso), rawTime: iso });
|
||||
}
|
||||
}
|
||||
|
||||
return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime());
|
||||
}
|
||||
|
||||
const yAxisBase = { tickMargin: 0, tick: { fontSize: 10 } } as const;
|
||||
@@ -77,7 +109,7 @@ const BeszelAreaChart = memo(
|
||||
dataKey="time"
|
||||
curveType="monotone"
|
||||
withGradient={false}
|
||||
connectNulls
|
||||
connectNulls={false}
|
||||
withDots={false}
|
||||
type={type}
|
||||
strokeWidth={1}
|
||||
@@ -96,16 +128,18 @@ const BeszelAreaChart = memo(
|
||||
export const useSystemChartData = (
|
||||
systemStats: BeszelSystemStatsRecord[] | undefined,
|
||||
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
|
||||
live = false,
|
||||
timePeriod: BeszelTimePeriod = "1h",
|
||||
) =>
|
||||
useMemo(() => {
|
||||
if (!systemStats) return [];
|
||||
const { fmt, ordered } = prepareRecords(systemStats, live);
|
||||
return ordered.map((r) => ({
|
||||
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
|
||||
const mapped = ordered.map((r) => ({
|
||||
time: fmt(r.created),
|
||||
rawTime: r.created,
|
||||
...mapFn(r.stats),
|
||||
}));
|
||||
}, [systemStats, mapFn, live]);
|
||||
return padTimeGrid(mapped, timePeriod);
|
||||
}, [systemStats, mapFn, timePeriod]);
|
||||
|
||||
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => {
|
||||
const prevRef = useRef<string[]>([]);
|
||||
@@ -144,19 +178,20 @@ export const useDockerChartData = (
|
||||
containerStats: BeszelContainerStatsRecord[] | undefined,
|
||||
containerNames: string[],
|
||||
metric: "cpu" | "memory" | "network",
|
||||
live = false,
|
||||
timePeriod: BeszelTimePeriod = "1h",
|
||||
) =>
|
||||
useMemo(() => {
|
||||
if (!containerStats?.length) return [];
|
||||
const extract = defaultContainerExtractors[metric];
|
||||
if (!extract) return [];
|
||||
const { fmt, ordered } = prepareRecords(containerStats, live);
|
||||
return ordered.map((record) => {
|
||||
const point: Record<string, unknown> = { time: fmt(record.created) };
|
||||
const { fmt, ordered } = prepareRecords(containerStats, timePeriod);
|
||||
const mapped = ordered.map((record) => {
|
||||
const point: Record<string, unknown> = { time: fmt(record.created), rawTime: record.created };
|
||||
const byName = new Map(record.stats.map((c) => [c.n, c]));
|
||||
for (const name of containerNames) {
|
||||
point[name] = extract(byName.get(name));
|
||||
}
|
||||
return point;
|
||||
});
|
||||
}, [containerStats, containerNames, metric, live]);
|
||||
return padTimeGrid(mapped, timePeriod);
|
||||
}, [containerStats, containerNames, metric, timePeriod]);
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { Tooltip } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
interface IntegrationResult {
|
||||
integrationId: string;
|
||||
integrationName?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface BeszelIntegrationErrorIndicatorProps {
|
||||
results: IntegrationResult[];
|
||||
}
|
||||
|
||||
export function BeszelIntegrationErrorIndicator({ results }: BeszelIntegrationErrorIndicatorProps) {
|
||||
const failed = results.filter((r) => "error" in r && r.error);
|
||||
if (failed.length === 0) return null;
|
||||
const label = failed.map((r) => r.integrationName ?? r.integrationId).join(", ");
|
||||
return (
|
||||
<Tooltip label={label} position="left" withArrow>
|
||||
<IconAlertTriangle size={14} color="var(--mantine-color-orange-6)" style={{ cursor: "help", flexShrink: 0 }} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,10 @@ export const useBeszelFilteredSystems = (
|
||||
statusFilter: string,
|
||||
): SystemRowWithKey[] => {
|
||||
const allSystems = useMemo(
|
||||
() => results.flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` }))),
|
||||
() =>
|
||||
results
|
||||
.flatMap((r) => r.systems.map((s) => ({ ...s, _key: `${r.integrationId}:${s.id}` })))
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
[results],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { SegmentedControl, SimpleGrid, Stack } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { BeszelTimePeriod } from "./chart";
|
||||
import { BeszelChartPanel, CPU_Y_AXIS_DOMAIN, useSystemChartData } from "./chart";
|
||||
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent } from "./format";
|
||||
import { makeTooltipProps } from "./tooltip";
|
||||
|
||||
const tooltipPercent = makeTooltipProps(formatPercent);
|
||||
const tooltipGB = makeTooltipProps(formatGB, true);
|
||||
const tooltipRate = makeTooltipProps(formatByteRate, true);
|
||||
|
||||
const CHART_HEIGHT = 180;
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
const timePeriodOptions: { value: BeszelTimePeriod; label: string }[] = [
|
||||
{ value: "1h", label: "1H" },
|
||||
{ value: "12h", label: "12H" },
|
||||
{ value: "24h", label: "24H" },
|
||||
{ value: "1w", label: "1W" },
|
||||
{ value: "30d", label: "30D" },
|
||||
];
|
||||
|
||||
interface BeszelSystemStatsModalProps {
|
||||
integrationId: string;
|
||||
systemId: string;
|
||||
}
|
||||
|
||||
export const BeszelSystemStatsModal = createModal<BeszelSystemStatsModalProps>(({ innerProps }) => {
|
||||
const t = useScopedI18n("widget.beszelSystemStats");
|
||||
const [timePeriod, setTimePeriod] = useState<BeszelTimePeriod>("1h");
|
||||
|
||||
const { data } = clientApi.widget.beszel.getSystemStats.useQuery(
|
||||
{
|
||||
integrationIds: [innerProps.integrationId],
|
||||
systemId: innerProps.systemId,
|
||||
timePeriod,
|
||||
includeDocker: false,
|
||||
},
|
||||
{ staleTime: 10_000, gcTime: 48 * 60 * 60 * 1000, refetchInterval: 10_000 },
|
||||
);
|
||||
|
||||
const mappers = useMemo(
|
||||
() => ({
|
||||
cpu: (s: { cpu: number }) => ({ [t("chart.cpu.series")]: s.cpu }),
|
||||
memory: (s: { mu: number; mb: number }) => ({
|
||||
[t("chart.memory.series")]: s.mu,
|
||||
[t("chart.memory.cache")]: s.mb ?? 0,
|
||||
}),
|
||||
disk: (s: { du: number }) => ({ [t("chart.disk.series")]: s.du }),
|
||||
diskIO: (s: { dr?: number; dw?: number }) => ({
|
||||
[t("chart.diskIO.read")]: (s.dr ?? 0) * MB,
|
||||
[t("chart.diskIO.write")]: (s.dw ?? 0) * MB,
|
||||
}),
|
||||
network: (s: { ns: number; nr: number; b?: [number, number] }) => ({
|
||||
[t("chart.network.sent")]: s.b?.[0] ?? s.ns,
|
||||
[t("chart.network.recv")]: s.b?.[1] ?? s.nr,
|
||||
}),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const series = useMemo(
|
||||
() => ({
|
||||
cpu: [{ name: t("chart.cpu.series"), color: "teal.6" }],
|
||||
memory: [
|
||||
{ name: t("chart.memory.series"), color: "teal.5" },
|
||||
{ name: t("chart.memory.cache"), color: "teal.8" },
|
||||
],
|
||||
disk: [{ name: t("chart.disk.series"), color: "grape.6" }],
|
||||
diskIO: [
|
||||
{ name: t("chart.diskIO.write"), color: "orange.6" },
|
||||
{ name: t("chart.diskIO.read"), color: "blue.6" },
|
||||
],
|
||||
network: [
|
||||
{ name: t("chart.network.sent"), color: "blue.6" },
|
||||
{ name: t("chart.network.recv"), color: "teal.6" },
|
||||
],
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const systemStats = data?.systemStats;
|
||||
const cpuData = useSystemChartData(systemStats, mappers.cpu, timePeriod);
|
||||
const memoryData = useSystemChartData(systemStats, mappers.memory, timePeriod);
|
||||
const diskData = useSystemChartData(systemStats, mappers.disk, timePeriod);
|
||||
const diskIOData = useSystemChartData(systemStats, mappers.diskIO, timePeriod);
|
||||
const networkData = useSystemChartData(systemStats, mappers.network, timePeriod);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={timePeriod}
|
||||
onChange={(v) => setTimePeriod(v as BeszelTimePeriod)}
|
||||
data={timePeriodOptions}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
{cpuData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.cpu.title")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: cpuData,
|
||||
series: series.cpu,
|
||||
yAxisFormatter: chartAxisFormatters.percent,
|
||||
yAxisDomain: CPU_Y_AXIS_DOMAIN,
|
||||
tooltipProps: tooltipPercent,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{memoryData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.memory.title")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: memoryData,
|
||||
type: "stacked",
|
||||
series: series.memory,
|
||||
yAxisFormatter: chartAxisFormatters.gb,
|
||||
tooltipProps: tooltipGB,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{diskData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.disk.title")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: diskData,
|
||||
series: series.disk,
|
||||
yAxisFormatter: chartAxisFormatters.gb,
|
||||
tooltipProps: tooltipGB,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{diskIOData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.diskIO.title")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: diskIOData,
|
||||
series: series.diskIO,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{networkData.length > 0 && (
|
||||
<BeszelChartPanel
|
||||
title={t("chart.network.title")}
|
||||
chartProps={{
|
||||
h: CHART_HEIGHT,
|
||||
data: networkData,
|
||||
series: series.network,
|
||||
yAxisFormatter: chartAxisFormatters.rate,
|
||||
tooltipProps: tooltipRate,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("widget.beszelSystemStats.name"),
|
||||
size: 1200,
|
||||
});
|
||||
@@ -3,6 +3,10 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import dayjs from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
|
||||
dayjs.extend(localizedFormat);
|
||||
|
||||
const styles: Record<string, CSSProperties> = {
|
||||
wrapper: {
|
||||
@@ -40,6 +44,7 @@ export interface TooltipPayloadItem {
|
||||
value: number;
|
||||
color: string;
|
||||
dataKey: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PortalTooltipProps {
|
||||
@@ -50,16 +55,39 @@ interface PortalTooltipProps {
|
||||
showTotal?: boolean;
|
||||
}
|
||||
|
||||
const MARGIN = 14;
|
||||
|
||||
const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: PortalTooltipProps) => {
|
||||
const mouseRef = useRef({ x: 0, y: 0 });
|
||||
const [mouse, setMouse] = useState({ x: 0, y: 0 });
|
||||
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||
const rafRef = useRef(0);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleMouse = useCallback((e: MouseEvent) => {
|
||||
mouseRef.current = { x: e.clientX, y: e.clientY };
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
setMouse(mouseRef.current);
|
||||
const el = tooltipRef.current;
|
||||
const mx = mouseRef.current.x;
|
||||
const my = mouseRef.current.y;
|
||||
|
||||
if (!el) {
|
||||
setPos({ x: mx + MARGIN, y: my });
|
||||
return;
|
||||
}
|
||||
|
||||
const { width, height } = el.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
let x = mx + MARGIN;
|
||||
let y = my - height / 2;
|
||||
|
||||
if (x + width > vw - MARGIN) x = mx - width - MARGIN;
|
||||
if (y < MARGIN) y = MARGIN;
|
||||
if (y + height > vh - MARGIN) y = vh - MARGIN - height;
|
||||
|
||||
setPos({ x, y });
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -84,21 +112,22 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }:
|
||||
|
||||
if (!sorted.length) return null;
|
||||
|
||||
const rawTime = sorted[0]?.payload?.rawTime as string | undefined;
|
||||
const tooltipLabel = rawTime ? dayjs(rawTime).format("MMM D, LT") : label;
|
||||
const total = sorted.reduce((sum, p) => sum + p.value, 0);
|
||||
|
||||
const portalStyle: CSSProperties = {
|
||||
position: "fixed",
|
||||
left: mouse.x + 14,
|
||||
top: mouse.y,
|
||||
transform: "translateY(-50%)",
|
||||
left: pos.x,
|
||||
top: pos.y,
|
||||
zIndex: 10000,
|
||||
pointerEvents: "none",
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div style={portalStyle}>
|
||||
<div ref={tooltipRef} style={portalStyle}>
|
||||
<div style={styles.wrapper}>
|
||||
<div style={styles.header}>{label}</div>
|
||||
<div style={styles.header}>{tooltipLabel}</div>
|
||||
{sorted.map((item) => (
|
||||
<div key={item.dataKey} style={styles.row}>
|
||||
<div style={{ ...styles.indicator, background: item.color }} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Box, Container, Flex, Popover, Text, useMantineTheme } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
@@ -13,10 +13,12 @@ interface CalendarDayProps {
|
||||
disabled: boolean;
|
||||
rootWidth: number;
|
||||
rootHeight: number;
|
||||
onOpen: (close: () => void) => void;
|
||||
}
|
||||
|
||||
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => {
|
||||
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth, onOpen }: CalendarDayProps) => {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [pinned, setPinned] = useState(false);
|
||||
const { primaryColor } = useMantineTheme();
|
||||
const { ref, height } = useElementSize();
|
||||
const board = useRequiredBoard();
|
||||
@@ -29,6 +31,19 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
|
||||
|
||||
const isTooSmallForIndicators = height < 30;
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (disabled) return;
|
||||
onOpen(() => {
|
||||
setOpened(false);
|
||||
setPinned(false);
|
||||
});
|
||||
setOpened(true);
|
||||
}, [disabled, onOpen]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (!pinned) setOpened(false);
|
||||
}, [pinned]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
position="bottom"
|
||||
@@ -39,7 +54,10 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
|
||||
transitionProps={{
|
||||
transition: "pop",
|
||||
}}
|
||||
onChange={setOpened}
|
||||
onChange={(value) => {
|
||||
setOpened(value);
|
||||
if (!value) setPinned(false);
|
||||
}}
|
||||
opened={opened}
|
||||
disabled={disabled}
|
||||
>
|
||||
@@ -59,10 +77,10 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
|
||||
cursor: disabled ? "default" : "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
|
||||
setOpened((prev) => !prev);
|
||||
if (!disabled) setPinned((prev) => !prev);
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
|
||||
{date.getDate()}
|
||||
@@ -71,7 +89,15 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
|
||||
</Container>
|
||||
</Popover.Target>
|
||||
{/* Popover has some offset on the left side, padding is removed because of scrollarea paddings */}
|
||||
<Popover.Dropdown maw="calc(100vw - 24px)" w={512} pe={4} pb={0} style={{ overflow: "hidden" }}>
|
||||
<Popover.Dropdown
|
||||
maw="calc(100vw - 24px)"
|
||||
w={512}
|
||||
pe={4}
|
||||
pb={0}
|
||||
style={{ overflow: "hidden" }}
|
||||
onMouseEnter={() => setOpened(true)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<CalendarEventList events={events} />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useMantineTheme } from "@mantine/core";
|
||||
import { Calendar } from "@mantine/dates";
|
||||
@@ -16,11 +16,15 @@ import type { WidgetComponentProps } from "../definition";
|
||||
import { CalendarDay } from "./calender-day";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function CalendarWidget(props: WidgetComponentProps<"calendar">) {
|
||||
export default function CalendarWidget(
|
||||
props: WidgetComponentProps<"calendar">,
|
||||
) {
|
||||
const [month, setMonth] = useState(new Date());
|
||||
|
||||
if (props.integrationIds.length === 0) {
|
||||
return <CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />;
|
||||
return (
|
||||
<CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />
|
||||
);
|
||||
}
|
||||
|
||||
return <FetchCalendar month={month} setMonth={setMonth} {...props} />;
|
||||
@@ -31,7 +35,13 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
|
||||
setMonth: (date: Date) => void;
|
||||
}
|
||||
|
||||
const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: FetchCalendarProps) => {
|
||||
const FetchCalendar = ({
|
||||
month,
|
||||
setMonth,
|
||||
isEditMode,
|
||||
integrationIds,
|
||||
options,
|
||||
}: FetchCalendarProps) => {
|
||||
const input = {
|
||||
integrationIds,
|
||||
month: month.getMonth(),
|
||||
@@ -61,9 +71,20 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }:
|
||||
},
|
||||
});
|
||||
|
||||
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
|
||||
const events = useMemo(
|
||||
() => data?.flatMap((item) => item.events) ?? [],
|
||||
[data],
|
||||
);
|
||||
|
||||
return <CalendarBase isEditMode={isEditMode} events={events} month={month} setMonth={setMonth} options={options} />;
|
||||
return (
|
||||
<CalendarBase
|
||||
isEditMode={isEditMode}
|
||||
events={events}
|
||||
month={month}
|
||||
setMonth={setMonth}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface CalendarBaseProps {
|
||||
@@ -74,7 +95,13 @@ interface CalendarBaseProps {
|
||||
options: WidgetComponentProps<"calendar">["options"];
|
||||
}
|
||||
|
||||
const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => {
|
||||
const CalendarBase = ({
|
||||
isEditMode,
|
||||
events,
|
||||
month,
|
||||
setMonth,
|
||||
options,
|
||||
}: CalendarBaseProps) => {
|
||||
const params = useParams();
|
||||
const locale = params.locale as string;
|
||||
const { firstDayOfWeek } = useSettings();
|
||||
@@ -85,6 +112,11 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
|
||||
const isSmall = width < 256;
|
||||
|
||||
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
|
||||
const activeCloseRef = useRef<(() => void) | null>(null);
|
||||
const onDayOpen = useCallback((close: () => void) => {
|
||||
activeCloseRef.current?.();
|
||||
activeCloseRef.current = close;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Calendar
|
||||
@@ -150,9 +182,14 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
|
||||
const eventsForDate = normalizedEvents
|
||||
.filter((event) => dayjs(event.startDate).isSame(tileDate, "day"))
|
||||
.filter(
|
||||
(event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
|
||||
(event) =>
|
||||
event.metadata?.type !== "radarr" ||
|
||||
options.releaseType.includes(event.metadata.releaseType),
|
||||
)
|
||||
.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
|
||||
.toSorted(
|
||||
(eventA, eventB) =>
|
||||
eventA.startDate.getTime() - eventB.startDate.getTime(),
|
||||
);
|
||||
|
||||
return (
|
||||
<CalendarDay
|
||||
@@ -161,6 +198,7 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
|
||||
disabled={isEditMode || eventsForDate.length === 0}
|
||||
rootWidth={width}
|
||||
rootHeight={height}
|
||||
onOpen={onDayOpen}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
@@ -198,7 +236,9 @@ export const splitEvents = (events: CalendarEvent[]): CalendarEvent[] => {
|
||||
splitEvents.push({
|
||||
...event,
|
||||
startDate: currentStart.toDate(),
|
||||
endDate: currentStart.endOf("day").isAfter(event.endDate) ? event.endDate : currentStart.endOf("day").toDate(),
|
||||
endDate: currentStart.endOf("day").isAfter(event.endDate)
|
||||
? event.endDate
|
||||
: currentStart.endOf("day").toDate(),
|
||||
});
|
||||
|
||||
currentStart = currentStart.add(1, "day").startOf("day");
|
||||
|
||||
@@ -57,6 +57,7 @@ export default function DnsHoleControlsWidget({
|
||||
},
|
||||
{
|
||||
staleTime: 5 * 1000,
|
||||
refetchInterval: 5 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: false,
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget
|
||||
},
|
||||
{
|
||||
staleTime: 5 * 1000,
|
||||
refetchInterval: 5 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
retry: false,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { ActionIcon, Avatar, Badge, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { ActionIcon, Avatar, Badge, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import type { IconProps } from "@tabler/icons-react";
|
||||
import { IconBrandDocker, IconPlayerPlay, IconPlayerStop, IconRotateClockwise } from "@tabler/icons-react";
|
||||
import type { MRT_ColumnDef } from "mantine-react-table";
|
||||
import type { MRT_ColumnDef, MRT_VisibilityState } from "mantine-react-table";
|
||||
import { MantineReactTable } from "mantine-react-table";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
@@ -207,12 +207,21 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
|
||||
acc.memory += safeValue(container.memoryUsage);
|
||||
return acc;
|
||||
},
|
||||
{ cpu: 0, memory: 0 }
|
||||
{ cpu: 0, memory: 0 },
|
||||
);
|
||||
}, [containers]);
|
||||
|
||||
const columns = useMemo(() => createColumns(t), [t]);
|
||||
|
||||
const columnVisibility: MRT_VisibilityState = {
|
||||
name: options.columns.includes("name"),
|
||||
state: options.columns.includes("state"),
|
||||
host: options.columns.includes("host"),
|
||||
cpuUsage: options.columns.includes("cpuUsage"),
|
||||
memoryUsage: options.columns.includes("memoryUsage"),
|
||||
actions: options.columns.includes("actions"),
|
||||
};
|
||||
|
||||
const table = useTranslatedMantineReactTable({
|
||||
columns,
|
||||
data: containers,
|
||||
@@ -233,6 +242,9 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
|
||||
sorting: [{ id: options.defaultSort, desc: options.descendingDefaultSort }],
|
||||
density: "xs",
|
||||
},
|
||||
state: {
|
||||
columnVisibility,
|
||||
},
|
||||
mantinePaperProps: {
|
||||
flex: 1,
|
||||
withBorder: false,
|
||||
@@ -260,6 +272,13 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
|
||||
},
|
||||
});
|
||||
|
||||
if (options.columns.length === 0)
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text>{t("error.noColumns")}</Text>
|
||||
</Center>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap={0} h="100%" display="flex">
|
||||
<MantineReactTable table={table} />
|
||||
@@ -283,7 +302,7 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("table.totalCpu", { cpu: totals.cpu.toFixed(2) })}
|
||||
</Text>
|
||||
|
||||
|
||||
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
|
||||
{t("table.totalMemory", { memory: humanFileSize(totals.memory) })}
|
||||
</Text>
|
||||
|
||||
@@ -12,10 +12,29 @@ const columnsList = [
|
||||
"memoryUsage",
|
||||
] as const satisfies (keyof RouterOutputs["docker"]["getContainers"]["containers"][number])[];
|
||||
|
||||
const allColumnsList = ["name", "state", "host", "cpuUsage", "memoryUsage", "actions"] as const;
|
||||
|
||||
const columnTranslationKeyMap = {
|
||||
name: "docker.field.name.label",
|
||||
state: "docker.field.state.label",
|
||||
host: "docker.field.host.label",
|
||||
cpuUsage: "docker.field.stats.cpu.label",
|
||||
memoryUsage: "docker.field.stats.memory.label",
|
||||
actions: "docker.action.title",
|
||||
} as const satisfies Record<(typeof allColumnsList)[number], string>;
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
|
||||
icon: IconBrandDocker,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
columns: factory.multiSelect({
|
||||
defaultValue: [...allColumnsList],
|
||||
options: allColumnsList.map((value) => ({
|
||||
value,
|
||||
label: (t) => t(columnTranslationKeyMap[value]),
|
||||
})),
|
||||
searchable: true,
|
||||
}),
|
||||
enableRowSorting: factory.switch({
|
||||
defaultValue: false,
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import "../widgets-common.css";
|
||||
import "./styles.css";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { MantineStyleProp } from "@mantine/core";
|
||||
@@ -577,7 +578,7 @@ export default function DownloadClientsWidget({
|
||||
enableColumnOrdering: isEditMode,
|
||||
enableRowVirtualization: true,
|
||||
rowVirtualizerOptions: { overscan: 5 },
|
||||
mantinePaperProps: { flex: 1, withBorder: false, shadow: undefined },
|
||||
mantinePaperProps: { flex: 1, withBorder: false, shadow: undefined, bg: "transparent" },
|
||||
mantineTableContainerProps: { style: { height: "100%" } },
|
||||
mantineTableProps: {
|
||||
className: "downloads-widget-table",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/* Make the MantineReactTable transparent so the board card's opacity-controlled
|
||||
background shows through. MRT defaults to var(--mantine-color-body) which is opaque. */
|
||||
.downloads-widget-table,
|
||||
.downloads-widget-table .mantine-Table-table,
|
||||
.downloads-widget-table .mantine-Table-thead,
|
||||
.downloads-widget-table .mantine-Table-tbody,
|
||||
.downloads-widget-table .mantine-Table-tfoot,
|
||||
.downloads-widget-table .mantine-Table-tr,
|
||||
.downloads-widget-table th,
|
||||
.downloads-widget-table td {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
@@ -52,6 +52,7 @@ import * as timetable from "./timetable";
|
||||
import * as tracearr from "./tracearr";
|
||||
import * as umami from "./umami";
|
||||
import * as ups from "./ups";
|
||||
import * as vpn from "./vpn";
|
||||
import * as beszelSystemTable from "./beszel-system-table";
|
||||
import * as beszelSystemGrid from "./beszel-system-grid";
|
||||
import * as beszelAlerts from "./beszel-alerts";
|
||||
@@ -108,6 +109,7 @@ export const widgetImports = {
|
||||
uptimeKuma,
|
||||
audioStats,
|
||||
umami,
|
||||
vpn,
|
||||
ups,
|
||||
beszelSystemTable,
|
||||
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>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user